Skip to main content

github_copilot_sdk/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4#![cfg_attr(test, allow(clippy::unwrap_used))]
5
6/// Canvas declarations, provider callbacks, and host-side canvas RPC types.
7pub mod canvas;
8mod canvas_dispatch;
9/// Bundled CLI binary extraction and caching.
10#[cfg(feature = "bundled-cli")]
11pub(crate) mod embeddedcli;
12mod errors;
13pub use errors::*;
14/// Connection-level Copilot request handler — intercept and replace the
15/// model-layer HTTP and WebSocket traffic the runtime issues for both CAPI and
16/// BYOK sessions.
17pub mod copilot_request_handler;
18/// GitHub telemetry forwarding callback surface (experimental). Public but
19/// `#[doc(hidden)]` — re-exports the generated telemetry payload types.
20#[doc(hidden)]
21pub mod github_telemetry;
22/// Event handler traits for session lifecycle.
23pub mod handler;
24/// Lifecycle hook callbacks (pre/post tool use, prompt submission, session start/end).
25pub mod hooks;
26mod jsonrpc;
27/// Permission-policy helpers that produce a [`handler::PermissionHandler`].
28pub mod permission;
29/// BYOK bearer-token provider callbacks.
30pub mod provider_token;
31mod provider_token_dispatch;
32/// GitHub Copilot CLI binary resolution (env var, embedded, dev cache).
33pub(crate) mod resolve;
34mod router;
35/// Session management — create, resume, send messages, and interact with the agent.
36pub mod session;
37/// Custom session filesystem provider (virtualizable filesystem layer).
38pub mod session_fs;
39mod session_fs_dispatch;
40/// Event subscription handles returned by `subscribe()` methods.
41pub mod subscription;
42/// Typed tool definition framework and dispatch router.
43pub mod tool;
44/// W3C Trace Context propagation for distributed tracing.
45pub mod trace_context;
46/// System message transform callbacks for customizing agent prompts.
47pub mod transforms;
48/// Protocol types shared between the SDK and the GitHub Copilot CLI.
49pub mod types;
50mod wire;
51
52/// Session event payload types — auto-generated from the protocol schema.
53pub mod session_events;
54
55/// JSON-RPC request/response types and typed namespace builders for
56/// [`Client::rpc`] and [`session::Session::rpc`](crate::session::Session::rpc).
57pub mod rpc;
58
59// Auto-generated protocol-type modules. Crate-private so the only public
60// access path is via the `session_events` and `rpc` facade modules above —
61// callers can never depend on the implementation-detail layout under
62// `generated::*`.
63pub(crate) mod generated;
64
65/// Client-level mode ([`ClientMode`]) and the [`ToolSet`] builder for
66/// source-qualified tool filter patterns.
67pub mod mode;
68
69use std::ffi::OsString;
70use std::path::{Path, PathBuf};
71use std::process::Stdio;
72use std::sync::{Arc, OnceLock};
73use std::time::{Duration, Instant};
74
75use async_trait::async_trait;
76/// Re-export of [`indexmap::IndexMap`], used for order-preserving maps in the
77/// public API (e.g. [`Tool::parameters`](types::Tool::parameters) and
78/// `SessionConfig::mcp_servers`) so serialized key order stays deterministic.
79pub use indexmap::IndexMap;
80// JSON-RPC wire types are internal transport details.
81// External callers interact via Client/Session methods, not raw RPC.
82pub(crate) use jsonrpc::{
83    JsonRpcClient, JsonRpcError, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, error_codes,
84};
85pub use mode::{BUILTIN_TOOLS_ISOLATED, ClientMode, ToolSet};
86pub use provider_token::{BearerTokenError, BearerTokenProvider, ProviderTokenArgs};
87
88/// Re-exported JSON-RPC internals for integration tests (requires `test-support` feature).
89#[cfg(feature = "test-support")]
90pub mod test_support {
91    pub use crate::jsonrpc::{
92        JsonRpcClient, JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse,
93        error_codes,
94    };
95}
96use serde::{Deserialize, Serialize};
97use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader};
98use tokio::net::TcpStream;
99use tokio::process::{Child, Command};
100use tokio::sync::{broadcast, mpsc, oneshot};
101use tracing::{Instrument, debug, error, info, warn};
102pub use types::*;
103
104mod sdk_protocol_version;
105pub use sdk_protocol_version::{SDK_PROTOCOL_VERSION, get_sdk_protocol_version};
106pub use subscription::{EventSubscription, LifecycleSubscription};
107
108/// Minimum protocol version this SDK can communicate with.
109const MIN_PROTOCOL_VERSION: u32 = 3;
110const RUNTIME_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
111
112/// How the SDK communicates with the CLI server.
113#[derive(Debug, Default)]
114#[non_exhaustive]
115pub enum Transport {
116    /// Communicate over stdin/stdout pipes (default).
117    #[default]
118    Stdio,
119    /// Spawn the CLI with `--port` and connect via TCP.
120    Tcp {
121        /// Port to listen on (0 for OS-assigned).
122        port: u16,
123        /// Optional connection token. When `None` and the SDK is spawning
124        /// the CLI, the SDK auto-generates a 128-bit hex token so the
125        /// loopback listener is safe by default.
126        connection_token: Option<String>,
127    },
128    /// Connect to an already-running CLI server (no process spawning).
129    External {
130        /// Hostname or IP of the running server.
131        host: String,
132        /// Port of the running server.
133        port: u16,
134        /// Optional connection token. Required when the external server
135        /// was started with a token, ignored otherwise.
136        connection_token: Option<String>,
137    },
138}
139
140/// How the SDK locates the GitHub Copilot CLI binary.
141#[derive(Debug, Clone, Default)]
142pub enum CliProgram {
143    /// Auto-resolve: `COPILOT_CLI_PATH` → embedded CLI → dev cache.
144    /// This is the default.
145    #[default]
146    Resolve,
147    /// Use an explicit binary path (skips resolution).
148    Path(PathBuf),
149}
150
151impl From<PathBuf> for CliProgram {
152    fn from(path: PathBuf) -> Self {
153        Self::Path(path)
154    }
155}
156
157/// `true` when this build of the SDK has the Copilot CLI embedded in
158/// its binary — i.e. the `bundled-cli` cargo feature is on **and** the
159/// target platform is one for which `build.rs` shipped an archive.
160///
161/// Useful for branching on bundling presence without forcing the lazy
162/// extraction triggered by [`install_bundled_cli`].
163pub const HAS_BUNDLED_CLI: bool = cfg!(has_bundled_cli);
164
165/// Returns the path to the bundled Copilot CLI, extracting it from the
166/// embedded archive on first call.
167///
168/// This is the same path [`Client::start`] resolves to when
169/// [`ClientOptions::program`] is [`CliProgram::Resolve`], no
170/// `COPILOT_CLI_PATH` override is set, and no
171/// [`ClientOptions::bundled_cli_extract_dir`] is configured — exposing
172/// it directly so callers (health checks, diagnostics, version probes)
173/// can reach the bundled binary without spinning up a full [`Client`].
174///
175/// Subsequent calls return the cached result. Extraction is skipped when
176/// an already-published binary passes a cheap integrity re-check; a
177/// truncated, empty, or antivirus-quarantined binary is re-extracted and
178/// re-verified rather than returned.
179///
180/// Returns `None` when the `bundled-cli` feature is off, the target
181/// platform isn't supported by `build.rs`, or extraction failed (the
182/// failure is logged via `tracing::warn!`). When `None` is returned for
183/// the "feature off" reason, [`HAS_BUNDLED_CLI`] is also `false`.
184///
185/// This deliberately does not fall back to the build-time-extracted
186/// dev-cache path used when `bundled-cli` is off — callers that want
187/// that resolution should continue to use [`CliProgram::Resolve`].
188pub fn install_bundled_cli() -> Option<PathBuf> {
189    #[cfg(feature = "bundled-cli")]
190    {
191        embeddedcli::path()
192    }
193    #[cfg(not(feature = "bundled-cli"))]
194    {
195        None
196    }
197}
198
199/// Options for starting a [`Client`].
200///
201/// When `program` is [`CliProgram::Resolve`] (the default), [`Client::start`]
202/// uses `COPILOT_CLI_PATH` when set to a real file. Otherwise it uses the
203/// bundled Copilot CLI when the default `bundled-cli` cargo feature is enabled,
204/// or the build-time extracted dev-cache CLI when that feature is disabled.
205///
206/// Set `program` to [`CliProgram::Path`] to use an explicit binary instead.
207/// This skips auto-resolution entirely.
208#[non_exhaustive]
209pub struct ClientOptions {
210    /// How to locate the CLI binary.
211    pub program: CliProgram,
212    /// Arguments prepended before `--server` (e.g. the script path for node).
213    pub prefix_args: Vec<OsString>,
214    /// Working directory for the CLI process.
215    pub working_directory: PathBuf,
216    /// Environment variables set on the child process.
217    pub env: Vec<(OsString, OsString)>,
218    /// Environment variable names to remove from the child process.
219    pub env_remove: Vec<OsString>,
220    /// Extra CLI flags appended after the transport-specific arguments.
221    pub extra_args: Vec<String>,
222    /// Transport mode used to communicate with the CLI server.
223    pub transport: Transport,
224    /// GitHub token for authentication. When set, the SDK passes the token
225    /// to the CLI via `--auth-token-env COPILOT_SDK_AUTH_TOKEN` and exports
226    /// the token in that env var. When set, the CLI defaults to *not*
227    /// using the logged-in user (override with [`Self::use_logged_in_user`]).
228    pub github_token: Option<String>,
229    /// Whether the CLI should fall back to the logged-in `gh` user when no
230    /// token is provided. `None` means use the runtime default (true unless
231    /// [`Self::github_token`] is set, in which case false).
232    pub use_logged_in_user: Option<bool>,
233    /// Log level passed to the CLI server via `--log-level`. When `None`,
234    /// the SDK does not pass `--log-level` to the runtime at all and the
235    /// CLI uses its built-in default.
236    pub log_level: Option<LogLevel>,
237    /// Server-wide idle timeout for sessions, in seconds. When set to a
238    /// positive value, the SDK passes `--session-idle-timeout <secs>` to
239    /// the CLI; sessions without activity for this duration are
240    /// automatically cleaned up. `None` or `Some(0)` leaves sessions
241    /// running indefinitely (the CLI default).
242    pub session_idle_timeout_seconds: Option<u64>,
243    /// Optional override for [`Client::list_models`].
244    ///
245    /// When set, [`Client::list_models`] returns the handler's result
246    /// without making a `models.list` RPC. This is the BYOK escape hatch
247    /// for environments where the model catalog is provisioned separately
248    /// from the GitHub Copilot CLI (e.g. external inference servers selected via
249    /// [`Transport::External`]).
250    pub on_list_models: Option<Arc<dyn ListModelsHandler>>,
251    /// Custom session filesystem provider configuration.
252    ///
253    /// When set, the SDK calls `sessionFs.setProvider` during
254    /// [`Client::start`] to register a virtualizable filesystem layer with
255    /// the CLI. Each session created on this client must supply its own
256    /// [`SessionFsProvider`] via
257    /// [`SessionConfig::with_session_fs_provider`](crate::SessionConfig::with_session_fs_provider).
258    pub session_fs: Option<SessionFsConfig>,
259    /// Connection-level Copilot request handler configuration.
260    ///
261    /// When set, the SDK registers itself as the runtime's request handler
262    /// during [`Client::start`], so the runtime routes its model-layer HTTP and
263    /// WebSocket traffic — for both CAPI and BYOK sessions — through the
264    /// configured
265    /// [`CopilotRequestHandler`]
266    /// instead of issuing the calls itself.
267    pub request_handler: Option<Arc<dyn crate::copilot_request_handler::CopilotRequestHandler>>,
268    /// Connection-level GitHub telemetry forwarding callback (experimental).
269    ///
270    /// When set, every session created or resumed on this client opts into
271    /// telemetry forwarding (`enableGitHubTelemetryForwarding`) and the
272    /// callback is invoked for each `gitHubTelemetry.event` notification the
273    /// runtime forwards. `#[doc(hidden)]`, consistent with the experimental
274    /// telemetry payload types.
275    #[doc(hidden)]
276    pub on_github_telemetry: Option<crate::github_telemetry::GitHubTelemetryCallback>,
277    /// Optional [`TraceContextProvider`] used to inject W3C Trace Context
278    /// headers (`traceparent` / `tracestate`) on outbound `session.create`,
279    /// `session.resume`, and `session.send` requests.
280    ///
281    /// When [`MessageOptions`] carries a per-turn override (set via
282    /// [`MessageOptions::with_trace_context`](crate::types::MessageOptions::with_trace_context)
283    /// or the underlying fields), it takes precedence over this provider.
284    ///
285    /// [`MessageOptions`]: crate::types::MessageOptions
286    pub on_get_trace_context: Option<Arc<dyn TraceContextProvider>>,
287    /// OpenTelemetry config forwarded to the spawned CLI process. See
288    /// [`TelemetryConfig`] for the env-var mapping. The SDK takes no
289    /// OpenTelemetry dependency — this is pure spawn-time env injection.
290    pub telemetry: Option<TelemetryConfig>,
291    /// Override the directory where the CLI persists its state (sessions,
292    /// auth, telemetry buffers). When set, exported as `COPILOT_HOME` to
293    /// the spawned CLI process. Useful for sandboxing test runs or
294    /// running multiple isolated SDK instances side-by-side.
295    pub base_directory: Option<PathBuf>,
296    /// Enable remote session support (Mission Control integration).
297    /// When `true`, the SDK passes `--remote` to the spawned CLI process so
298    /// sessions in a GitHub repository working directory are accessible from
299    /// GitHub web and mobile. Ignored when connecting to an external server
300    /// via [`Transport::External`].
301    pub enable_remote_sessions: bool,
302    /// Override the directory where the bundled CLI binary is extracted on
303    /// first use.
304    ///
305    /// When `None` (the default), the SDK extracts the embedded CLI to
306    /// `<platform cache dir>/github-copilot-sdk/cli/<version>/copilot[.exe]`,
307    /// where the cache dir is [`dirs::cache_dir()`] —
308    /// `%LOCALAPPDATA%` on Windows, `~/Library/Caches/` on macOS,
309    /// `$XDG_CACHE_HOME` (or `~/.cache/`) on Linux. Use this knob to
310    /// redirect the extraction (e.g. to a session-scoped temp directory in
311    /// CI runners) without changing the global cache layout.
312    ///
313    /// Only applies when the `bundled-cli` cargo feature is on (the
314    /// default). With `bundled-cli` disabled (`default-features = false`)
315    /// there is no archive to re-extract at runtime — the binary lives
316    /// at a build-time-known conventional path. To relocate that
317    /// extraction, set `COPILOT_CLI_EXTRACT_DIR` (honored symmetrically
318    /// at build and runtime); to point the runtime at a different
319    /// binary altogether, use [`CliProgram::Path`] or `COPILOT_CLI_PATH`.
320    pub bundled_cli_extract_dir: Option<PathBuf>,
321    /// SDK-level mode controlling whether sessions get CLI-style defaults
322    /// (the default) or are stripped to a minimal/safe baseline. See
323    /// [`ClientMode`] for the contract and trade-offs.
324    pub mode: ClientMode,
325}
326
327impl std::fmt::Debug for ClientOptions {
328    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
329        f.debug_struct("ClientOptions")
330            .field("program", &self.program)
331            .field("prefix_args", &self.prefix_args)
332            .field("working_directory", &self.working_directory)
333            .field("env", &self.env)
334            .field("env_remove", &self.env_remove)
335            .field("extra_args", &self.extra_args)
336            .field("transport", &self.transport)
337            .field(
338                "github_token",
339                &self.github_token.as_ref().map(|_| "<redacted>"),
340            )
341            .field("use_logged_in_user", &self.use_logged_in_user)
342            .field("log_level", &self.log_level)
343            .field(
344                "session_idle_timeout_seconds",
345                &self.session_idle_timeout_seconds,
346            )
347            .field(
348                "on_list_models",
349                &self.on_list_models.as_ref().map(|_| "<set>"),
350            )
351            .field("session_fs", &self.session_fs)
352            .field(
353                "request_handler",
354                &self.request_handler.as_ref().map(|_| "<set>"),
355            )
356            .field(
357                "on_github_telemetry",
358                &self.on_github_telemetry.as_ref().map(|_| "<set>"),
359            )
360            .field(
361                "on_get_trace_context",
362                &self.on_get_trace_context.as_ref().map(|_| "<set>"),
363            )
364            .field("telemetry", &self.telemetry)
365            .field("base_directory", &self.base_directory)
366            .field("enable_remote_sessions", &self.enable_remote_sessions)
367            .field("bundled_cli_extract_dir", &self.bundled_cli_extract_dir)
368            .finish()
369    }
370}
371
372/// Custom handler for [`Client::list_models`].
373///
374/// Implementations override the default `models.list` RPC, returning a
375/// caller-supplied catalog of models. Set via [`ClientOptions::on_list_models`].
376///
377/// Implementations must be `Send + Sync` because [`Client`] is shared across
378/// tasks. Errors returned by [`list_models`](Self::list_models) are propagated
379/// from [`Client::list_models`] unchanged.
380#[async_trait]
381pub trait ListModelsHandler: Send + Sync + 'static {
382    /// Return the list of available models.
383    async fn list_models(&self) -> Result<Vec<Model>>;
384}
385
386/// Log verbosity for the CLI server (passed via `--log-level`).
387#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
388#[serde(rename_all = "lowercase")]
389pub enum LogLevel {
390    /// Suppress all CLI logs.
391    None,
392    /// Errors only.
393    Error,
394    /// Warnings and errors.
395    Warning,
396    /// Info and above.
397    Info,
398    /// Debug, info, warnings, errors.
399    Debug,
400    /// Everything, including trace output.
401    All,
402}
403
404impl LogLevel {
405    /// CLI argument value (e.g. `"info"`, `"debug"`).
406    pub fn as_str(self) -> &'static str {
407        match self {
408            Self::None => "none",
409            Self::Error => "error",
410            Self::Warning => "warning",
411            Self::Info => "info",
412            Self::Debug => "debug",
413            Self::All => "all",
414        }
415    }
416}
417
418impl std::fmt::Display for LogLevel {
419    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420        f.write_str(self.as_str())
421    }
422}
423
424/// Backend exporter for the CLI's OpenTelemetry pipeline.
425///
426/// Maps to the `COPILOT_OTEL_EXPORTER_TYPE` environment variable on the
427/// spawned CLI process. Wire values are `"otlp-http"` and `"file"`.
428#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
429#[serde(rename_all = "kebab-case")]
430#[non_exhaustive]
431pub enum OtelExporterType {
432    /// Export via OTLP HTTP to the endpoint configured by
433    /// [`TelemetryConfig::otlp_endpoint`].
434    OtlpHttp,
435    /// Export to a JSON-lines file at the path configured by
436    /// [`TelemetryConfig::file_path`].
437    File,
438}
439
440impl OtelExporterType {
441    /// Environment-variable value (`"otlp-http"` or `"file"`).
442    pub fn as_str(self) -> &'static str {
443        match self {
444            Self::OtlpHttp => "otlp-http",
445            Self::File => "file",
446        }
447    }
448}
449
450/// OTLP HTTP protocol used by the CLI's OpenTelemetry OTLP exporter.
451///
452/// Maps to the standard `OTEL_EXPORTER_OTLP_PROTOCOL` environment variable on
453/// the spawned CLI process. Wire values are `"http/json"` and
454/// `"http/protobuf"`.
455#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
456#[non_exhaustive]
457pub enum OtlpHttpProtocol {
458    /// Export using OTLP/HTTP JSON.
459    #[serde(rename = "http/json")]
460    HttpJson,
461    /// Export using OTLP/HTTP protobuf.
462    #[serde(rename = "http/protobuf")]
463    HttpProtobuf,
464}
465
466impl OtlpHttpProtocol {
467    /// Environment-variable value (`"http/json"` or `"http/protobuf"`).
468    pub fn as_str(self) -> &'static str {
469        match self {
470            Self::HttpJson => "http/json",
471            Self::HttpProtobuf => "http/protobuf",
472        }
473    }
474}
475
476/// OpenTelemetry configuration forwarded to the spawned GitHub Copilot CLI
477/// process.
478///
479/// When [`ClientOptions::telemetry`] is `Some(...)`, the SDK sets
480/// `COPILOT_OTEL_ENABLED=true` plus any populated fields below as the
481/// corresponding `OTEL_*` / `COPILOT_OTEL_*` environment variables. The
482/// CLI's built-in OpenTelemetry exporter consumes these at startup. The
483/// SDK itself takes no OpenTelemetry dependency.
484///
485/// Environment-variable mapping:
486///
487/// | Field                | Variable                                              |
488/// |----------------------|-------------------------------------------------------|
489/// | (any field set)      | `COPILOT_OTEL_ENABLED=true`                           |
490/// | [`otlp_endpoint`]    | `OTEL_EXPORTER_OTLP_ENDPOINT`                         |
491/// | [`otlp_protocol`]    | `OTEL_EXPORTER_OTLP_PROTOCOL`                         |
492/// | [`file_path`]        | `COPILOT_OTEL_FILE_EXPORTER_PATH`                     |
493/// | [`exporter_type`]    | `COPILOT_OTEL_EXPORTER_TYPE`                          |
494/// | [`source_name`]      | `COPILOT_OTEL_SOURCE_NAME`                            |
495/// | [`capture_content`]  | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`  |
496///
497/// Caller-supplied entries in [`ClientOptions::env`] override these, so a
498/// developer can pin any individual variable to a different value while
499/// keeping the rest of the config managed by [`TelemetryConfig`].
500///
501/// Marked `#[non_exhaustive]` so future CLI-side telemetry knobs can be
502/// added without breaking callers.
503///
504/// [`otlp_endpoint`]: Self::otlp_endpoint
505/// [`otlp_protocol`]: Self::otlp_protocol
506/// [`file_path`]: Self::file_path
507/// [`exporter_type`]: Self::exporter_type
508/// [`source_name`]: Self::source_name
509/// [`capture_content`]: Self::capture_content
510#[derive(Debug, Clone, Default)]
511#[non_exhaustive]
512pub struct TelemetryConfig {
513    /// OTLP HTTP endpoint URL for trace/metric export.
514    pub otlp_endpoint: Option<String>,
515    /// OTLP HTTP protocol for all signals.
516    pub otlp_protocol: Option<OtlpHttpProtocol>,
517    /// File path for JSON-lines trace output.
518    pub file_path: Option<PathBuf>,
519    /// Exporter backend type. Typically [`OtelExporterType::OtlpHttp`] or
520    /// [`OtelExporterType::File`].
521    pub exporter_type: Option<OtelExporterType>,
522    /// Instrumentation scope name. Useful for distinguishing this
523    /// embedder's traces from other Copilot-CLI consumers exporting to the
524    /// same backend.
525    pub source_name: Option<String>,
526    /// Whether the CLI captures GenAI message content (prompts and
527    /// responses) on emitted spans. `Some(true)` opts in; `Some(false)`
528    /// opts out; `None` leaves the CLI default (typically off).
529    pub capture_content: Option<bool>,
530}
531
532impl TelemetryConfig {
533    /// Construct an empty [`TelemetryConfig`]; all fields default to
534    /// unset (`is_empty()` returns `true`).
535    pub fn new() -> Self {
536        Self::default()
537    }
538
539    /// Set the OTLP HTTP endpoint URL for trace/metric export.
540    pub fn with_otlp_endpoint(mut self, endpoint: impl Into<String>) -> Self {
541        self.otlp_endpoint = Some(endpoint.into());
542        self
543    }
544
545    /// Set the OTLP HTTP protocol for all signals.
546    pub fn with_otlp_protocol(mut self, protocol: OtlpHttpProtocol) -> Self {
547        self.otlp_protocol = Some(protocol);
548        self
549    }
550
551    /// Set the file path for JSON-lines trace output.
552    pub fn with_file_path(mut self, path: impl Into<PathBuf>) -> Self {
553        self.file_path = Some(path.into());
554        self
555    }
556
557    /// Set the exporter backend type.
558    pub fn with_exporter_type(mut self, exporter_type: OtelExporterType) -> Self {
559        self.exporter_type = Some(exporter_type);
560        self
561    }
562
563    /// Set the instrumentation scope name. Useful for distinguishing
564    /// this embedder's traces from other Copilot-CLI consumers
565    /// exporting to the same backend.
566    pub fn with_source_name(mut self, source_name: impl Into<String>) -> Self {
567        self.source_name = Some(source_name.into());
568        self
569    }
570
571    /// Opt in or out of GenAI message content capture on emitted spans.
572    /// `true` opts in; `false` opts out. Leaving this unset preserves
573    /// the CLI default (typically off).
574    pub fn with_capture_content(mut self, capture: bool) -> Self {
575        self.capture_content = Some(capture);
576        self
577    }
578
579    /// Returns `true` if all fields are unset. Used by [`Client::start`]
580    /// to decide whether to set `COPILOT_OTEL_ENABLED`.
581    pub fn is_empty(&self) -> bool {
582        self.otlp_endpoint.is_none()
583            && self.otlp_protocol.is_none()
584            && self.file_path.is_none()
585            && self.exporter_type.is_none()
586            && self.source_name.is_none()
587            && self.capture_content.is_none()
588    }
589}
590
591impl Default for ClientOptions {
592    fn default() -> Self {
593        Self {
594            program: CliProgram::Resolve,
595            prefix_args: Vec::new(),
596            working_directory: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
597            env: Vec::new(),
598            env_remove: Vec::new(),
599            extra_args: Vec::new(),
600            transport: Transport::default(),
601            github_token: None,
602            use_logged_in_user: None,
603            log_level: None,
604            session_idle_timeout_seconds: None,
605            on_list_models: None,
606            session_fs: None,
607            request_handler: None,
608            on_github_telemetry: None,
609            on_get_trace_context: None,
610            telemetry: None,
611            base_directory: None,
612            enable_remote_sessions: false,
613            bundled_cli_extract_dir: None,
614            mode: ClientMode::default(),
615        }
616    }
617}
618
619impl ClientOptions {
620    /// Construct a new [`ClientOptions`] with default values.
621    ///
622    /// Equivalent to [`ClientOptions::default`]; provided as a documented
623    /// construction entry point for the builder chain. The struct is
624    /// `#[non_exhaustive]`, so external callers cannot use struct-literal
625    /// syntax — use this builder or [`Default::default`] plus mut-let.
626    ///
627    /// # Example
628    ///
629    /// ```
630    /// # use github_copilot_sdk::{ClientOptions, LogLevel};
631    /// let opts = ClientOptions::new()
632    ///     .with_log_level(LogLevel::Debug)
633    ///     .with_github_token("ghp_…");
634    /// ```
635    pub fn new() -> Self {
636        Self::default()
637    }
638
639    /// How to locate the CLI binary. See [`CliProgram`].
640    pub fn with_program(mut self, program: impl Into<CliProgram>) -> Self {
641        self.program = program.into();
642        self
643    }
644
645    /// Arguments prepended before `--server` (e.g. the script path for node).
646    pub fn with_prefix_args<I, S>(mut self, args: I) -> Self
647    where
648        I: IntoIterator<Item = S>,
649        S: Into<OsString>,
650    {
651        self.prefix_args = args.into_iter().map(Into::into).collect();
652        self
653    }
654
655    /// Working directory for the CLI process.
656    pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
657        self.working_directory = cwd.into();
658        self
659    }
660
661    /// Environment variables to set on the child process.
662    pub fn with_env<I, K, V>(mut self, env: I) -> Self
663    where
664        I: IntoIterator<Item = (K, V)>,
665        K: Into<OsString>,
666        V: Into<OsString>,
667    {
668        self.env = env.into_iter().map(|(k, v)| (k.into(), v.into())).collect();
669        self
670    }
671
672    /// Environment variable names to remove from the child process.
673    pub fn with_env_remove<I, S>(mut self, names: I) -> Self
674    where
675        I: IntoIterator<Item = S>,
676        S: Into<OsString>,
677    {
678        self.env_remove = names.into_iter().map(Into::into).collect();
679        self
680    }
681
682    /// Extra CLI flags appended after the transport-specific arguments.
683    pub fn with_extra_args<I, S>(mut self, args: I) -> Self
684    where
685        I: IntoIterator<Item = S>,
686        S: Into<String>,
687    {
688        self.extra_args = args.into_iter().map(Into::into).collect();
689        self
690    }
691
692    /// Transport mode used to communicate with the CLI server. See [`Transport`].
693    pub fn with_transport(mut self, transport: Transport) -> Self {
694        self.transport = transport;
695        self
696    }
697
698    /// GitHub token for authentication. The SDK passes the token to the
699    /// CLI via `--auth-token-env COPILOT_SDK_AUTH_TOKEN`.
700    pub fn with_github_token(mut self, token: impl Into<String>) -> Self {
701        self.github_token = Some(token.into());
702        self
703    }
704
705    /// Whether the CLI should fall back to the logged-in `gh` user when
706    /// no token is provided. See the field docs for default semantics.
707    pub fn with_use_logged_in_user(mut self, use_logged_in: bool) -> Self {
708        self.use_logged_in_user = Some(use_logged_in);
709        self
710    }
711
712    /// Log level passed to the CLI server via `--log-level`.
713    pub fn with_log_level(mut self, level: LogLevel) -> Self {
714        self.log_level = Some(level);
715        self
716    }
717
718    /// Server-wide idle timeout for sessions (seconds). Pass `0` to leave
719    /// sessions running indefinitely (the CLI default).
720    pub fn with_session_idle_timeout_seconds(mut self, seconds: u64) -> Self {
721        self.session_idle_timeout_seconds = Some(seconds);
722        self
723    }
724
725    /// Override [`Client::list_models`] with a caller-supplied handler.
726    /// The handler is wrapped in `Arc` internally.
727    pub fn with_list_models_handler<H>(mut self, handler: H) -> Self
728    where
729        H: ListModelsHandler + 'static,
730    {
731        self.on_list_models = Some(Arc::new(handler));
732        self
733    }
734
735    /// Custom session filesystem provider configuration.
736    pub fn with_session_fs(mut self, config: SessionFsConfig) -> Self {
737        self.session_fs = Some(config);
738        self
739    }
740
741    /// Register a connection-level Copilot request handler. The runtime will
742    /// route its model-layer HTTP and WebSocket traffic through the handler
743    /// configured here instead of issuing the calls itself. The handler is
744    /// wrapped in `Arc` internally.
745    pub fn with_request_handler<H>(mut self, handler: H) -> Self
746    where
747        H: crate::copilot_request_handler::CopilotRequestHandler,
748    {
749        self.request_handler = Some(Arc::new(handler));
750        self
751    }
752
753    /// Register a connection-level GitHub telemetry forwarding callback
754    /// (internal/experimental). Registering a callback auto-enables telemetry
755    /// forwarding on every session created or resumed on this client; the
756    /// callback fires for each forwarded `gitHubTelemetry.event` notification.
757    /// The callback is wrapped in `Arc` internally.
758    #[doc(hidden)]
759    pub fn with_on_github_telemetry<F>(mut self, callback: F) -> Self
760    where
761        F: Fn(crate::github_telemetry::GitHubTelemetryNotification) + Send + Sync + 'static,
762    {
763        self.on_github_telemetry = Some(Arc::new(callback));
764        self
765    }
766
767    /// Set the [`TraceContextProvider`] used to inject W3C Trace Context
768    /// headers on outbound `session.create` / `session.resume` /
769    /// `session.send` requests. The provider is wrapped in `Arc` internally.
770    pub fn with_trace_context_provider<P>(mut self, provider: P) -> Self
771    where
772        P: TraceContextProvider + 'static,
773    {
774        self.on_get_trace_context = Some(Arc::new(provider));
775        self
776    }
777
778    /// OpenTelemetry config forwarded to the spawned CLI process.
779    pub fn with_telemetry(mut self, config: TelemetryConfig) -> Self {
780        self.telemetry = Some(config);
781        self
782    }
783
784    /// Override the directory where the CLI persists its state. Set as
785    /// `COPILOT_HOME` on the spawned CLI process.
786    pub fn with_base_directory(mut self, dir: impl Into<PathBuf>) -> Self {
787        self.base_directory = Some(dir.into());
788        self
789    }
790
791    /// Enable remote session support (Mission Control). Passes `--remote`
792    /// to the spawned CLI process.
793    pub fn with_enable_remote_sessions(mut self, enabled: bool) -> Self {
794        self.enable_remote_sessions = enabled;
795        self
796    }
797
798    /// Override the directory where the bundled CLI binary is extracted on
799    /// first use. See [`Self::bundled_cli_extract_dir`].
800    ///
801    /// Only applies when the `bundled-cli` cargo feature is on. With
802    /// `bundled-cli` disabled (`default-features = false`), set
803    /// `COPILOT_CLI_EXTRACT_DIR` to relocate the build-time extraction
804    /// (honored symmetrically at build and runtime), or use
805    /// [`CliProgram::Path`] / `COPILOT_CLI_PATH` to point at a different
806    /// binary at runtime.
807    pub fn with_bundled_cli_extract_dir(mut self, dir: impl Into<PathBuf>) -> Self {
808        self.bundled_cli_extract_dir = Some(dir.into());
809        self
810    }
811
812    /// Set the SDK [`ClientMode`]. Use [`ClientMode::Empty`] for any
813    /// scenario where CLI-like ambient behavior is unsafe (e.g. multi-user
814    /// servers). Empty mode additionally requires [`Self::base_directory`]
815    /// or [`Self::session_fs`] to be set, validated at [`Client::start`].
816    pub fn with_mode(mut self, mode: ClientMode) -> Self {
817        self.mode = mode;
818        self
819    }
820}
821
822/// Validate a [`SessionFsConfig`] before sending `sessionFs.setProvider`.
823fn validate_session_fs_config(cfg: &SessionFsConfig) -> Result<()> {
824    if cfg.initial_cwd.trim().is_empty() {
825        return Err(Error::with_message(
826            ErrorKind::Session(SessionErrorKind::InvalidSessionFsConfig),
827            "invalid SessionFsConfig: initial_cwd must not be empty",
828        ));
829    }
830    if cfg.session_state_path.trim().is_empty() {
831        return Err(Error::with_message(
832            ErrorKind::Session(SessionErrorKind::InvalidSessionFsConfig),
833            "invalid SessionFsConfig: session_state_path must not be empty",
834        ));
835    }
836    Ok(())
837}
838
839/// Generate a fresh CSPRNG-backed token for authenticating an SDK-spawned
840/// loopback CLI server. 128 bits of entropy, lowercase-hex encoded — not
841/// a UUID (the schema-shaped IDs in this crate stay `String` per the
842/// pre-1.0 review consensus, so adopting a `Uuid` type just for SDK-
843/// generated secrets would be inconsistent and semantically misleading;
844/// this is opaque random data, not an identifier).
845fn generate_connection_token() -> String {
846    let mut bytes = [0u8; 16];
847    getrandom::getrandom(&mut bytes)
848        .expect("OS CSPRNG (getrandom) is unavailable; cannot generate connection token");
849    let mut hex = String::with_capacity(32);
850    for byte in bytes {
851        use std::fmt::Write;
852        let _ = write!(hex, "{byte:02x}");
853    }
854    hex
855}
856
857/// Connection to a GitHub Copilot CLI server (stdio, TCP, or external).
858///
859/// Cheaply cloneable — cloning shares the underlying connection.
860/// The child process (if any) is killed when the last clone drops.
861#[derive(Clone)]
862pub struct Client {
863    inner: Arc<ClientInner>,
864}
865
866impl std::fmt::Debug for Client {
867    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
868        f.debug_struct("Client")
869            .field("working_directory", &self.inner.cwd)
870            .field("pid", &self.pid())
871            .finish()
872    }
873}
874
875struct ClientInner {
876    child: parking_lot::Mutex<Option<Child>>,
877    rpc: JsonRpcClient,
878    cwd: PathBuf,
879    request_rx: parking_lot::Mutex<Option<mpsc::UnboundedReceiver<JsonRpcRequest>>>,
880    notification_tx: broadcast::Sender<JsonRpcNotification>,
881    router: router::SessionRouter,
882    negotiated_protocol_version: OnceLock<u32>,
883    state: parking_lot::Mutex<ConnectionState>,
884    lifecycle_tx: broadcast::Sender<SessionLifecycleEvent>,
885    on_list_models: Option<Arc<dyn ListModelsHandler>>,
886    models_cache: parking_lot::Mutex<Arc<tokio::sync::OnceCell<Vec<Model>>>>,
887    session_fs_configured: bool,
888    session_fs_sqlite_declared: bool,
889    /// Inbound `llmInference.*` dispatcher, installed when
890    /// [`ClientOptions::request_handler`] is set.
891    llm_inference: OnceLock<Arc<copilot_request_handler::CopilotRequestDispatcher>>,
892    /// Connection-level GitHub telemetry forwarding callback, set from
893    /// [`ClientOptions::on_github_telemetry`]. Drives the
894    /// `enableGitHubTelemetryForwarding` wire flag and the
895    /// `gitHubTelemetry.event` notification dispatch.
896    on_github_telemetry: Option<crate::github_telemetry::GitHubTelemetryCallback>,
897    on_get_trace_context: Option<Arc<dyn TraceContextProvider>>,
898    /// Token sent in the `connect` handshake. Auto-generated when the
899    /// SDK spawns its own CLI in TCP mode and no explicit token is set;
900    /// `None` for stdio and for external-server transport without an
901    /// explicit token.
902    effective_connection_token: Option<String>,
903    /// SDK [`ClientMode`] captured at start time. Drives empty-mode safe
904    /// defaults inside `create_session` / `resume_session`.
905    pub(crate) mode: ClientMode,
906}
907
908impl Client {
909    /// Start a CLI server process with the given options.
910    ///
911    /// For [`Transport::Stdio`], spawns the CLI with `--stdio` and communicates
912    /// over stdin/stdout pipes. For [`Transport::Tcp`], spawns with `--port`
913    /// and connects via TCP once the server reports it is listening. For
914    /// [`Transport::External`], connects to an already-running server.
915    ///
916    /// After establishing the connection, calls [`verify_protocol_version`](Self::verify_protocol_version)
917    /// to ensure the CLI server speaks a compatible protocol version.
918    /// When [`ClientOptions::session_fs`] is set, also calls
919    /// `sessionFs.setProvider` to register the SDK as the filesystem
920    /// backend.
921    pub async fn start(options: ClientOptions) -> Result<Self> {
922        let start_time = Instant::now();
923        if options.mode == ClientMode::Empty
924            && options.base_directory.is_none()
925            && options.session_fs.is_none()
926        {
927            return Err(Error::with_message(
928                ErrorKind::InvalidConfig,
929                "ClientMode::Empty requires either `base_directory` or \
930                 `session_fs` to be set (no implicit ~/.copilot fallback).",
931            ));
932        }
933        if let Some(cfg) = &options.session_fs {
934            validate_session_fs_config(cfg)?;
935        }
936        // Auth options only make sense when the SDK spawns the CLI; with an
937        // external server, the server manages its own auth.
938        if matches!(options.transport, Transport::External { .. }) {
939            if options.github_token.is_some() {
940                return Err(Error::with_message(
941                    ErrorKind::InvalidConfig,
942                    "invalid client configuration: github_token cannot be used with \
943                     Transport::External (external server manages its own auth)",
944                ));
945            }
946            if options.use_logged_in_user == Some(true) {
947                return Err(Error::with_message(
948                    ErrorKind::InvalidConfig,
949                    "invalid client configuration: use_logged_in_user cannot be used with \
950                     Transport::External (external server manages its own auth)",
951                ));
952            }
953        }
954        // Validate token shape. Stdio variants no longer carry a token
955        // (enforced by the type). For Tcp/External, empty-string is
956        // rejected eagerly.
957        match &options.transport {
958            Transport::Tcp {
959                connection_token: Some(t),
960                ..
961            }
962            | Transport::External {
963                connection_token: Some(t),
964                ..
965            } if t.is_empty() => {
966                return Err(Error::with_message(
967                    ErrorKind::InvalidConfig,
968                    "invalid client configuration: connection_token must be a non-empty string",
969                ));
970            }
971            _ => {}
972        }
973        // Capture (and where needed, auto-generate) the token actually sent
974        // to the server. For Tcp, the SDK auto-generates one when the
975        // caller leaves it unset so the loopback listener is safe by
976        // default.
977        let mut options = options;
978        let effective_connection_token: Option<String> = match &mut options.transport {
979            Transport::Stdio => None,
980            Transport::Tcp {
981                connection_token, ..
982            } => Some(
983                connection_token
984                    .get_or_insert_with(generate_connection_token)
985                    .clone(),
986            ),
987            Transport::External {
988                connection_token, ..
989            } => connection_token.clone(),
990        };
991        let session_fs_config = options.session_fs.clone();
992        let request_handler = options.request_handler.clone();
993        let session_fs_sqlite_declared = session_fs_config
994            .as_ref()
995            .and_then(|c| c.capabilities.as_ref())
996            .is_some_and(|caps| caps.sqlite);
997        let program = match &options.program {
998            CliProgram::Path(path) => {
999                info!(path = %path.display(), "using explicit copilot CLI path");
1000                path.clone()
1001            }
1002            CliProgram::Resolve => {
1003                let resolved = resolve::copilot_binary_with_extract_dir(
1004                    options.bundled_cli_extract_dir.as_deref(),
1005                )?;
1006                info!(path = %resolved.display(), "resolved copilot CLI");
1007                #[cfg(windows)]
1008                {
1009                    if let Some(ext) = resolved.extension().and_then(|e| e.to_str()).filter(|ext| {
1010                        ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat")
1011                    }) {
1012                        warn!(
1013                            path = %resolved.display(),
1014                            ext = %ext,
1015                            "resolved copilot CLI is a .cmd/.bat wrapper; \
1016                             this may cause console window flashes on Windows"
1017                        );
1018                    }
1019                }
1020                resolved
1021            }
1022        };
1023
1024        let client = match options.transport {
1025            Transport::External {
1026                ref host,
1027                port,
1028                connection_token: _,
1029            } => {
1030                info!(host = %host, port = %port, "connecting to external CLI server");
1031                let connect_start = Instant::now();
1032                let stream = TcpStream::connect((host.as_str(), port)).await?;
1033                debug!(
1034                    elapsed_ms = connect_start.elapsed().as_millis(),
1035                    host = %host,
1036                    port,
1037                    "Client::start TCP connect complete"
1038                );
1039                let (reader, writer) = tokio::io::split(stream);
1040                Self::from_transport(
1041                    reader,
1042                    writer,
1043                    None,
1044                    options.working_directory,
1045                    options.on_list_models,
1046                    session_fs_config.is_some(),
1047                    session_fs_sqlite_declared,
1048                    options.on_get_trace_context,
1049                    options.on_github_telemetry,
1050                    effective_connection_token.clone(),
1051                    options.mode,
1052                )?
1053            }
1054            Transport::Tcp {
1055                port,
1056                connection_token: _,
1057            } => {
1058                let (mut child, actual_port) = Self::spawn_tcp(&program, &options, port).await?;
1059                let connect_start = Instant::now();
1060                let stream = TcpStream::connect(("127.0.0.1", actual_port)).await?;
1061                debug!(
1062                    elapsed_ms = connect_start.elapsed().as_millis(),
1063                    port = actual_port,
1064                    "Client::start TCP connect complete"
1065                );
1066                let (reader, writer) = tokio::io::split(stream);
1067                Self::drain_stderr(&mut child);
1068                Self::from_transport(
1069                    reader,
1070                    writer,
1071                    Some(child),
1072                    options.working_directory,
1073                    options.on_list_models,
1074                    session_fs_config.is_some(),
1075                    session_fs_sqlite_declared,
1076                    options.on_get_trace_context,
1077                    options.on_github_telemetry,
1078                    effective_connection_token.clone(),
1079                    options.mode,
1080                )?
1081            }
1082            Transport::Stdio => {
1083                let mut child = Self::spawn_stdio(&program, &options)?;
1084                let stdin = child.stdin.take().expect("stdin is piped");
1085                let stdout = child.stdout.take().expect("stdout is piped");
1086                Self::drain_stderr(&mut child);
1087                Self::from_transport(
1088                    stdout,
1089                    stdin,
1090                    Some(child),
1091                    options.working_directory,
1092                    options.on_list_models,
1093                    session_fs_config.is_some(),
1094                    session_fs_sqlite_declared,
1095                    options.on_get_trace_context,
1096                    options.on_github_telemetry,
1097                    effective_connection_token.clone(),
1098                    options.mode,
1099                )?
1100            }
1101        };
1102
1103        debug!(
1104            elapsed_ms = start_time.elapsed().as_millis(),
1105            "Client::start transport setup complete"
1106        );
1107        client.verify_protocol_version().await?;
1108        debug!(
1109            elapsed_ms = start_time.elapsed().as_millis(),
1110            "Client::start protocol verification complete"
1111        );
1112        if let Some(cfg) = session_fs_config {
1113            let session_fs_start = Instant::now();
1114            let capabilities = cfg.capabilities.as_ref().map(|c| {
1115                crate::generated::api_types::SessionFsSetProviderCapabilities {
1116                    sqlite: Some(c.sqlite),
1117                }
1118            });
1119            let request = crate::generated::api_types::SessionFsSetProviderRequest {
1120                capabilities,
1121                conventions: cfg.conventions.into_wire(),
1122                initial_cwd: cfg.initial_cwd,
1123                session_state_path: cfg.session_state_path,
1124            };
1125            client.rpc().session_fs().set_provider(request).await?;
1126            debug!(
1127                elapsed_ms = session_fs_start.elapsed().as_millis(),
1128                "Client::start session filesystem setup complete"
1129            );
1130        }
1131        if let Some(handler) = request_handler {
1132            let llm_inference_start = Instant::now();
1133            let dispatcher = Arc::new(copilot_request_handler::CopilotRequestDispatcher::new(
1134                handler,
1135            ));
1136            dispatcher.set_client(Arc::downgrade(&client.inner));
1137            let _ = client.inner.llm_inference.set(dispatcher.clone());
1138            // Start the router early (before any session is registered) so the
1139            // startup model catalog request is dispatched to the handler.
1140            client.inner.router.ensure_started(
1141                &client.inner.notification_tx,
1142                &client.inner.request_rx,
1143                Some(dispatcher.clone()),
1144                client.inner.on_github_telemetry.clone(),
1145            );
1146            client.rpc().llm_inference().set_provider().await?;
1147            debug!(
1148                elapsed_ms = llm_inference_start.elapsed().as_millis(),
1149                "Client::start Copilot request handler registration complete"
1150            );
1151        }
1152        debug!(
1153            elapsed_ms = start_time.elapsed().as_millis(),
1154            "Client::start complete"
1155        );
1156        Ok(client)
1157    }
1158
1159    /// Create a Client from raw async streams (no child process).
1160    ///
1161    /// Useful for testing or connecting to a server over a custom transport.
1162    pub fn from_streams(
1163        reader: impl AsyncRead + Unpin + Send + 'static,
1164        writer: impl AsyncWrite + Unpin + Send + 'static,
1165        cwd: PathBuf,
1166    ) -> Result<Self> {
1167        Self::from_transport(
1168            reader,
1169            writer,
1170            None,
1171            cwd,
1172            None,
1173            false,
1174            false,
1175            None,
1176            None,
1177            None,
1178            ClientMode::default(),
1179        )
1180    }
1181
1182    /// Construct a [`Client`] from raw streams with a
1183    /// [`TraceContextProvider`] preset, for integration testing.
1184    ///
1185    /// Mirrors [`from_streams`](Self::from_streams) but exposes the
1186    /// `on_get_trace_context` plumbing so tests can verify outbound
1187    /// `traceparent` / `tracestate` injection on `session.create`,
1188    /// `session.resume`, and `session.send`.
1189    #[cfg(any(test, feature = "test-support"))]
1190    pub fn from_streams_with_trace_provider(
1191        reader: impl AsyncRead + Unpin + Send + 'static,
1192        writer: impl AsyncWrite + Unpin + Send + 'static,
1193        cwd: PathBuf,
1194        provider: Arc<dyn TraceContextProvider>,
1195    ) -> Result<Self> {
1196        Self::from_transport(
1197            reader,
1198            writer,
1199            None,
1200            cwd,
1201            None,
1202            false,
1203            false,
1204            Some(provider),
1205            None,
1206            None,
1207            ClientMode::default(),
1208        )
1209    }
1210
1211    /// Construct a [`Client`] from raw streams with a preset
1212    /// `effective_connection_token`, for integration testing the
1213    /// `connect` handshake's token-forwarding path.
1214    #[cfg(any(test, feature = "test-support"))]
1215    pub fn from_streams_with_connection_token(
1216        reader: impl AsyncRead + Unpin + Send + 'static,
1217        writer: impl AsyncWrite + Unpin + Send + 'static,
1218        cwd: PathBuf,
1219        token: Option<String>,
1220    ) -> Result<Self> {
1221        Self::from_transport(
1222            reader,
1223            writer,
1224            None,
1225            cwd,
1226            None,
1227            false,
1228            false,
1229            None,
1230            None,
1231            token,
1232            ClientMode::default(),
1233        )
1234    }
1235
1236    /// Construct a [`Client`] from raw streams with a preset GitHub telemetry
1237    /// callback, for integration testing telemetry forwarding.
1238    #[doc(hidden)]
1239    #[cfg(any(test, feature = "test-support"))]
1240    pub fn from_streams_with_github_telemetry(
1241        reader: impl AsyncRead + Unpin + Send + 'static,
1242        writer: impl AsyncWrite + Unpin + Send + 'static,
1243        cwd: PathBuf,
1244        on_github_telemetry: crate::github_telemetry::GitHubTelemetryCallback,
1245    ) -> Result<Self> {
1246        Self::from_transport(
1247            reader,
1248            writer,
1249            None,
1250            cwd,
1251            None,
1252            false,
1253            false,
1254            None,
1255            Some(on_github_telemetry),
1256            None,
1257            ClientMode::default(),
1258        )
1259    }
1260
1261    /// Public test-only wrapper around the random connection-token
1262    /// generator used by [`Client::start`] when the SDK spawns a TCP
1263    /// server without an explicit token. Lets integration tests
1264    /// validate the token shape (32-char lowercase hex, 128 bits of
1265    /// entropy) without re-implementing the helper.
1266    #[cfg(any(test, feature = "test-support"))]
1267    pub fn generate_connection_token_for_test() -> String {
1268        generate_connection_token()
1269    }
1270
1271    #[allow(clippy::too_many_arguments)]
1272    fn from_transport(
1273        reader: impl AsyncRead + Unpin + Send + 'static,
1274        writer: impl AsyncWrite + Unpin + Send + 'static,
1275        child: Option<Child>,
1276        cwd: PathBuf,
1277        on_list_models: Option<Arc<dyn ListModelsHandler>>,
1278        session_fs_configured: bool,
1279        session_fs_sqlite_declared: bool,
1280        on_get_trace_context: Option<Arc<dyn TraceContextProvider>>,
1281        on_github_telemetry: Option<crate::github_telemetry::GitHubTelemetryCallback>,
1282        effective_connection_token: Option<String>,
1283        mode: ClientMode,
1284    ) -> Result<Self> {
1285        let setup_start = Instant::now();
1286        let (request_tx, request_rx) = mpsc::unbounded_channel::<JsonRpcRequest>();
1287        let (notification_broadcast_tx, _) = broadcast::channel::<JsonRpcNotification>(1024);
1288        let rpc = JsonRpcClient::new(
1289            writer,
1290            reader,
1291            notification_broadcast_tx.clone(),
1292            request_tx,
1293        );
1294
1295        let pid = child.as_ref().and_then(|c| c.id());
1296        info!(pid = ?pid, "copilot CLI client ready");
1297
1298        let client = Self {
1299            inner: Arc::new(ClientInner {
1300                child: parking_lot::Mutex::new(child),
1301                rpc,
1302                cwd,
1303                request_rx: parking_lot::Mutex::new(Some(request_rx)),
1304                notification_tx: notification_broadcast_tx,
1305                router: router::SessionRouter::new(),
1306                negotiated_protocol_version: OnceLock::new(),
1307                state: parking_lot::Mutex::new(ConnectionState::Connected),
1308                lifecycle_tx: broadcast::channel(256).0,
1309                on_list_models,
1310                models_cache: parking_lot::Mutex::new(Arc::new(tokio::sync::OnceCell::new())),
1311                session_fs_configured,
1312                session_fs_sqlite_declared,
1313                llm_inference: OnceLock::new(),
1314                on_github_telemetry,
1315                on_get_trace_context,
1316                effective_connection_token,
1317                mode,
1318            }),
1319        };
1320        client.spawn_lifecycle_dispatcher();
1321        debug!(
1322            elapsed_ms = setup_start.elapsed().as_millis(),
1323            pid = ?pid,
1324            "Client::from_transport setup complete"
1325        );
1326        Ok(client)
1327    }
1328
1329    /// Spawn the background task that re-broadcasts `session.lifecycle`
1330    /// notifications via [`ClientInner::lifecycle_tx`] to subscribers
1331    /// returned by [`Self::subscribe_lifecycle`].
1332    fn spawn_lifecycle_dispatcher(&self) {
1333        let inner = Arc::clone(&self.inner);
1334        let mut notif_rx = inner.notification_tx.subscribe();
1335        tokio::spawn(async move {
1336            loop {
1337                match notif_rx.recv().await {
1338                    Ok(notification) => {
1339                        if notification.method != "session.lifecycle" {
1340                            continue;
1341                        }
1342                        let Some(params) = notification.params.as_ref() else {
1343                            continue;
1344                        };
1345                        let event: SessionLifecycleEvent =
1346                            match serde_json::from_value(params.clone()) {
1347                                Ok(e) => e,
1348                                Err(e) => {
1349                                    warn!(
1350                                        error = %e,
1351                                        "failed to deserialize session.lifecycle notification"
1352                                    );
1353                                    continue;
1354                                }
1355                            };
1356                        // `send` only errors when there are no subscribers — that's
1357                        // the normal case before any consumer calls subscribe_lifecycle.
1358                        let _ = inner.lifecycle_tx.send(event);
1359                    }
1360                    Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
1361                        warn!(missed = n, "lifecycle dispatcher lagged");
1362                    }
1363                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1364                }
1365            }
1366        });
1367    }
1368
1369    fn build_command(program: &Path, options: &ClientOptions) -> Command {
1370        let mut command = Command::new(program);
1371        for arg in &options.prefix_args {
1372            command.arg(arg);
1373        }
1374        // Inject the SDK auth token first so explicit `env` / `env_remove`
1375        // entries can override or strip it.
1376        if let Some(token) = &options.github_token {
1377            command.env("COPILOT_SDK_AUTH_TOKEN", token);
1378        }
1379        // Inject telemetry env vars before user env so callers can still
1380        // override individual variables via `options.env`.
1381        if let Some(telemetry) = &options.telemetry {
1382            command.env("COPILOT_OTEL_ENABLED", "true");
1383            if let Some(endpoint) = &telemetry.otlp_endpoint {
1384                command.env("OTEL_EXPORTER_OTLP_ENDPOINT", endpoint);
1385            }
1386            if let Some(protocol) = telemetry.otlp_protocol {
1387                command.env("OTEL_EXPORTER_OTLP_PROTOCOL", protocol.as_str());
1388            }
1389            if let Some(path) = &telemetry.file_path {
1390                command.env("COPILOT_OTEL_FILE_EXPORTER_PATH", path);
1391            }
1392            if let Some(exporter) = telemetry.exporter_type {
1393                command.env("COPILOT_OTEL_EXPORTER_TYPE", exporter.as_str());
1394            }
1395            if let Some(source) = &telemetry.source_name {
1396                command.env("COPILOT_OTEL_SOURCE_NAME", source);
1397            }
1398            if let Some(capture) = telemetry.capture_content {
1399                command.env(
1400                    "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT",
1401                    if capture { "true" } else { "false" },
1402                );
1403            }
1404        }
1405        if let Some(dir) = &options.base_directory {
1406            command.env("COPILOT_HOME", dir);
1407        }
1408        // Empty mode disables the process-wide system keychain so the CLI
1409        // falls back to file-based credentials scoped to COPILOT_HOME.
1410        if options.mode == ClientMode::Empty {
1411            command.env("COPILOT_DISABLE_KEYTAR", "1");
1412        }
1413        if let Transport::Tcp {
1414            connection_token: Some(token),
1415            ..
1416        } = &options.transport
1417        {
1418            command.env("COPILOT_CONNECTION_TOKEN", token);
1419        }
1420        for (key, value) in &options.env {
1421            command.env(key, value);
1422        }
1423        for key in &options.env_remove {
1424            command.env_remove(key);
1425        }
1426        command
1427            .current_dir(&options.working_directory)
1428            .stdout(Stdio::piped())
1429            .stderr(Stdio::piped());
1430
1431        #[cfg(windows)]
1432        {
1433            use std::os::windows::process::CommandExt;
1434            const CREATE_NO_WINDOW: u32 = 0x08000000;
1435            command.as_std_mut().creation_flags(CREATE_NO_WINDOW);
1436        }
1437
1438        command
1439    }
1440
1441    /// Returns the CLI auth flags derived from [`ClientOptions::github_token`]
1442    /// and [`ClientOptions::use_logged_in_user`].
1443    ///
1444    /// When a token is set, adds `--auth-token-env COPILOT_SDK_AUTH_TOKEN`.
1445    /// When the effective `use_logged_in_user` is `false` (either explicitly
1446    /// or because a token was provided without an override), adds
1447    /// `--no-auto-login`.
1448    fn auth_args(options: &ClientOptions) -> Vec<&'static str> {
1449        let mut args: Vec<&'static str> = Vec::new();
1450        if options.github_token.is_some() {
1451            args.push("--auth-token-env");
1452            args.push("COPILOT_SDK_AUTH_TOKEN");
1453        }
1454        let use_logged_in = options
1455            .use_logged_in_user
1456            .unwrap_or(options.github_token.is_none());
1457        if !use_logged_in {
1458            args.push("--no-auto-login");
1459        }
1460        args
1461    }
1462
1463    /// Returns `--session-idle-timeout <secs>` when
1464    /// [`ClientOptions::session_idle_timeout_seconds`] is `Some(n)` with
1465    /// `n > 0`. Otherwise returns an empty vector.
1466    fn session_idle_timeout_args(options: &ClientOptions) -> Vec<String> {
1467        match options.session_idle_timeout_seconds {
1468            Some(secs) if secs > 0 => {
1469                vec!["--session-idle-timeout".to_string(), secs.to_string()]
1470            }
1471            _ => Vec::new(),
1472        }
1473    }
1474
1475    fn remote_args(options: &ClientOptions) -> Vec<String> {
1476        if options.enable_remote_sessions {
1477            vec!["--remote".to_string()]
1478        } else {
1479            Vec::new()
1480        }
1481    }
1482
1483    fn log_level_args(options: &ClientOptions) -> Vec<&'static str> {
1484        match options.log_level {
1485            Some(level) => vec!["--log-level", level.as_str()],
1486            None => Vec::new(),
1487        }
1488    }
1489
1490    fn spawn_stdio(program: &Path, options: &ClientOptions) -> Result<Child> {
1491        info!(cwd = ?options.working_directory, program = %program.display(), "spawning copilot CLI (stdio)");
1492        let mut command = Self::build_command(program, options);
1493        command
1494            .args(["--server", "--stdio", "--no-auto-update"])
1495            .args(Self::log_level_args(options))
1496            .args(Self::auth_args(options))
1497            .args(Self::session_idle_timeout_args(options))
1498            .args(Self::remote_args(options))
1499            .args(&options.extra_args)
1500            .stdin(Stdio::piped());
1501        let spawn_start = Instant::now();
1502        let child = command.spawn()?;
1503        debug!(
1504            elapsed_ms = spawn_start.elapsed().as_millis(),
1505            "Client::spawn_stdio subprocess spawned"
1506        );
1507        Ok(child)
1508    }
1509
1510    async fn spawn_tcp(program: &Path, options: &ClientOptions, port: u16) -> Result<(Child, u16)> {
1511        info!(cwd = ?options.working_directory, program = %program.display(), port = %port, "spawning copilot CLI (tcp)");
1512        let mut command = Self::build_command(program, options);
1513        command
1514            .args(["--server", "--port", &port.to_string(), "--no-auto-update"])
1515            .args(Self::log_level_args(options))
1516            .args(Self::auth_args(options))
1517            .args(Self::session_idle_timeout_args(options))
1518            .args(Self::remote_args(options))
1519            .args(&options.extra_args)
1520            .stdin(Stdio::null());
1521        let spawn_start = Instant::now();
1522        let mut child = command.spawn()?;
1523        debug!(
1524            elapsed_ms = spawn_start.elapsed().as_millis(),
1525            "Client::spawn_tcp subprocess spawned"
1526        );
1527        let stdout = child.stdout.take().expect("stdout is piped");
1528
1529        let (port_tx, port_rx) = oneshot::channel::<u16>();
1530        let span = tracing::error_span!("copilot_cli_port_scan");
1531        tokio::spawn(
1532            async move {
1533                // Scan stdout for the port announcement.
1534                let port_re = regex::Regex::new(r"listening on port (\d+)").expect("valid regex");
1535                let mut lines = BufReader::new(stdout).lines();
1536                let mut port_tx = Some(port_tx);
1537                while let Ok(Some(line)) = lines.next_line().await {
1538                    debug!(line = %line, "CLI stdout");
1539                    if let Some(tx) = port_tx.take() {
1540                        if let Some(caps) = port_re.captures(&line)
1541                            && let Some(p) =
1542                                caps.get(1).and_then(|m| m.as_str().parse::<u16>().ok())
1543                        {
1544                            let _ = tx.send(p);
1545                            continue;
1546                        }
1547                        // Not the port line — put tx back
1548                        port_tx = Some(tx);
1549                    }
1550                }
1551            }
1552            .instrument(span),
1553        );
1554
1555        let port_wait_start = Instant::now();
1556        let actual_port = tokio::time::timeout(std::time::Duration::from_secs(10), port_rx)
1557            .await
1558            .map_err(|_| Error::from(ErrorKind::Protocol(ProtocolErrorKind::CliStartupTimeout)))?
1559            .map_err(|_| Error::from(ErrorKind::Protocol(ProtocolErrorKind::CliStartupFailed)))?;
1560
1561        debug!(
1562            elapsed_ms = port_wait_start.elapsed().as_millis(),
1563            port = actual_port,
1564            "Client::spawn_tcp TCP port wait complete"
1565        );
1566        info!(port = %actual_port, "CLI server listening");
1567        Ok((child, actual_port))
1568    }
1569
1570    fn drain_stderr(child: &mut Child) {
1571        if let Some(stderr) = child.stderr.take() {
1572            let span = tracing::error_span!("copilot_cli");
1573            tokio::spawn(
1574                async move {
1575                    let mut reader = BufReader::new(stderr).lines();
1576                    while let Ok(Some(line)) = reader.next_line().await {
1577                        warn!(line = %line, "CLI stderr");
1578                    }
1579                }
1580                .instrument(span),
1581            );
1582        }
1583    }
1584
1585    /// Returns the working directory of the CLI process.
1586    pub fn cwd(&self) -> &PathBuf {
1587        &self.inner.cwd
1588    }
1589
1590    /// Returns the SDK [`ClientMode`] this client was started with.
1591    pub fn mode(&self) -> ClientMode {
1592        self.inner.mode
1593    }
1594
1595    /// Typed RPC namespace for server-level methods.
1596    ///
1597    /// Every protocol method lives here under its schema-aligned path —
1598    /// e.g. `client.rpc().models().list()`. Wire method names and request/
1599    /// response types are generated from the protocol schema, so the typed
1600    /// namespace can't drift from the wire contract.
1601    ///
1602    /// The hand-authored helpers on [`Client`] delegate to this namespace
1603    /// and remain the recommended entry point for everyday use; reach for
1604    /// `rpc()` when you want a method without a hand-written wrapper.
1605    pub fn rpc(&self) -> crate::generated::rpc::ClientRpc<'_> {
1606        crate::generated::rpc::ClientRpc { client: self }
1607    }
1608
1609    /// Send a JSON-RPC request and wait for the response.
1610    #[allow(dead_code, reason = "convenience for future internal use")]
1611    pub(crate) async fn send_request(
1612        &self,
1613        method: &str,
1614        params: Option<serde_json::Value>,
1615    ) -> Result<JsonRpcResponse> {
1616        self.inner.rpc.send_request(method, params).await
1617    }
1618
1619    /// Send a JSON-RPC request, check for errors, and return the result value.
1620    ///
1621    /// This is the primary method for session-level RPC calls. It wraps
1622    /// the internal send/receive cycle with error checking so callers
1623    /// don't need to inspect the response manually.
1624    ///
1625    /// # Cancel safety
1626    ///
1627    /// **Cancel-safe.** The frame is committed to the wire via the
1628    /// writer-actor task before the future yields; cancelling the await
1629    /// (via `tokio::time::timeout`, `select!`, or dropped JoinHandle)
1630    /// drops the response oneshot but does not desync the transport.
1631    /// The pending-requests entry is cleaned up by an RAII guard.
1632    /// However, the call's *side effect* on the CLI may still occur —
1633    /// the CLI receives the request and processes it; the caller just
1634    /// won't see the response. For idempotent methods this is fine; for
1635    /// non-idempotent methods (e.g. `session.create`) the caller should
1636    /// avoid wrapping the call in a timeout shorter than the expected
1637    /// CLI processing window.
1638    pub async fn call(
1639        &self,
1640        method: &str,
1641        params: Option<serde_json::Value>,
1642    ) -> Result<serde_json::Value> {
1643        self.call_with_inline_callback(method, params, None).await
1644    }
1645
1646    /// Same as [`call`](Self::call), but installs an `inline_callback`
1647    /// that runs synchronously on the JSON-RPC read task the instant the
1648    /// successful response is parsed, before it is delivered to this
1649    /// awaiter and before the read loop dispatches the next message.
1650    ///
1651    /// This is the only way to perform client-side bookkeeping (for
1652    /// example, registering a server-assigned session id with the
1653    /// router) that must be visible to any notification or request the
1654    /// server may emit on the same connection immediately after the
1655    /// response.
1656    ///
1657    /// If the callback returns an error, that error is propagated to
1658    /// this awaiter in place of the response. The callback never causes
1659    /// the read loop to crash.
1660    pub(crate) async fn call_with_inline_callback(
1661        &self,
1662        method: &str,
1663        params: Option<serde_json::Value>,
1664        inline_callback: Option<crate::jsonrpc::InlineResponseCallback>,
1665    ) -> Result<serde_json::Value> {
1666        let session_id: Option<SessionId> = params
1667            .as_ref()
1668            .and_then(|p| p.get("sessionId"))
1669            .and_then(|v| v.as_str())
1670            .map(SessionId::from);
1671        let response = self
1672            .inner
1673            .rpc
1674            .send_request_with_inline_callback(method, params, inline_callback)
1675            .await?;
1676        if let Some(err) = response.error {
1677            if err.message.contains("Session not found") {
1678                return Err(ErrorKind::Session(SessionErrorKind::NotFound(
1679                    session_id.unwrap_or_else(|| "unknown".into()),
1680                ))
1681                .into());
1682            }
1683            return Err(Error::with_message(
1684                ErrorKind::Rpc { code: err.code },
1685                err.message,
1686            ));
1687        }
1688        Ok(response.result.unwrap_or(serde_json::Value::Null))
1689    }
1690
1691    /// Send a JSON-RPC response back to the CLI (e.g. for permission or tool call requests).
1692    pub(crate) async fn send_response(&self, response: &JsonRpcResponse) -> Result<()> {
1693        self.inner.rpc.write(response).await
1694    }
1695
1696    /// Reconstruct a [`Client`] handle from a shared inner pointer.
1697    pub(crate) fn from_inner(inner: Arc<ClientInner>) -> Self {
1698        Self { inner }
1699    }
1700
1701    /// Take the receiver for incoming JSON-RPC requests from the CLI.
1702    ///
1703    /// Can only be called once — subsequent calls return `None`.
1704    #[expect(dead_code, reason = "reserved for future pub(crate) use")]
1705    pub(crate) fn take_request_rx(&self) -> Option<mpsc::UnboundedReceiver<JsonRpcRequest>> {
1706        self.inner.request_rx.lock().take()
1707    }
1708
1709    /// Register a session to receive filtered events and requests.
1710    ///
1711    /// Returns per-session channels for notifications and requests, routed
1712    /// by `sessionId`. Starts the internal router on first call.
1713    ///
1714    /// When done, call [`unregister_session`](Self::unregister_session) to
1715    /// clean up (typically on session destroy).
1716    pub(crate) fn register_session(
1717        &self,
1718        session_id: &SessionId,
1719    ) -> crate::router::SessionChannels {
1720        self.inner.router.ensure_started(
1721            &self.inner.notification_tx,
1722            &self.inner.request_rx,
1723            self.inner.llm_inference.get().cloned(),
1724            self.inner.on_github_telemetry.clone(),
1725        );
1726        self.inner.router.register(session_id)
1727    }
1728
1729    /// Unregister a session, dropping its per-session channels.
1730    pub(crate) fn unregister_session(&self, session_id: &SessionId) {
1731        self.inner.router.unregister(session_id);
1732    }
1733
1734    /// Returns the protocol version negotiated with the CLI server, if any.
1735    ///
1736    /// Set during [`start`](Self::start). Returns `None` if the server didn't
1737    /// report a version, or if the client was created via
1738    /// [`from_streams`](Self::from_streams) without calling
1739    /// [`verify_protocol_version`](Self::verify_protocol_version).
1740    pub fn protocol_version(&self) -> Option<u32> {
1741        self.inner.negotiated_protocol_version.get().copied()
1742    }
1743
1744    /// Verify the CLI server's protocol version is within the supported range.
1745    ///
1746    /// Called automatically by [`start`](Self::start). Call manually after
1747    /// [`from_streams`](Self::from_streams) if you need version verification
1748    /// on a custom transport.
1749    ///
1750    /// # Handshake sequence
1751    ///
1752    /// 1. Sends the `connect` JSON-RPC method, forwarding the
1753    ///    [`Transport`]'s `connection_token` (or the auto-generated
1754    ///    token for SDK-spawned TCP servers) as the `token` param. This
1755    ///    is the canonical handshake used by all SDK languages and is
1756    ///    what the CLI uses to enforce loopback authentication when
1757    ///    started with `COPILOT_CONNECTION_TOKEN`.
1758    /// 2. If the server returns `-32601` (`MethodNotFound`), falls back
1759    ///    to the legacy `ping` RPC. This preserves compatibility with
1760    ///    older CLI versions that predate `connect`.
1761    ///
1762    /// # Result
1763    ///
1764    /// Returns an error if the negotiated `protocolVersion` is outside
1765    /// `MIN_PROTOCOL_VERSION`..=[`SDK_PROTOCOL_VERSION`]. If the server
1766    /// doesn't report a version, logs a warning and succeeds.
1767    pub async fn verify_protocol_version(&self) -> Result<()> {
1768        let handshake_start = Instant::now();
1769        let mut used_fallback_ping = false;
1770        // Try the new `connect` handshake first (sends the connection
1771        // token, if any). Fall back to `ping` for legacy CLI servers
1772        // that don't expose `connect` (-32601 MethodNotFound).
1773        let server_version = match self.connect_handshake().await {
1774            Ok(v) => v,
1775            Err(ref e) if e.rpc_code() == Some(error_codes::METHOD_NOT_FOUND) => {
1776                used_fallback_ping = true;
1777                self.ping(None).await?.protocol_version
1778            }
1779            Err(e) => return Err(e),
1780        };
1781
1782        match server_version {
1783            None => {
1784                warn!("CLI server did not report protocolVersion; skipping version check");
1785            }
1786            Some(v) if !(MIN_PROTOCOL_VERSION..=SDK_PROTOCOL_VERSION).contains(&v) => {
1787                return Err(ErrorKind::Protocol(ProtocolErrorKind::VersionMismatch {
1788                    server: v,
1789                    min: MIN_PROTOCOL_VERSION,
1790                    max: SDK_PROTOCOL_VERSION,
1791                })
1792                .into());
1793            }
1794            Some(v) => {
1795                if let Some(&existing) = self.inner.negotiated_protocol_version.get() {
1796                    if existing != v {
1797                        return Err(ErrorKind::Protocol(ProtocolErrorKind::VersionChanged {
1798                            previous: existing,
1799                            current: v,
1800                        })
1801                        .into());
1802                    }
1803                } else {
1804                    let _ = self.inner.negotiated_protocol_version.set(v);
1805                }
1806            }
1807        }
1808
1809        debug!(
1810            elapsed_ms = handshake_start.elapsed().as_millis(),
1811            protocol_version = ?server_version,
1812            used_fallback_ping,
1813            "Client::verify_protocol_version protocol handshake complete"
1814        );
1815        Ok(())
1816    }
1817
1818    /// Send the `connect` JSON-RPC handshake. Returns the server's
1819    /// reported protocol version, or `None` if the server omits it.
1820    /// Forwards the [`Transport`]'s `connection_token` (or the
1821    /// auto-generated token for SDK-spawned TCP servers) as the `token`
1822    /// param. Server-side, the token is required when the server was
1823    /// started with `COPILOT_CONNECTION_TOKEN`.
1824    async fn connect_handshake(&self) -> Result<Option<u32>> {
1825        let params = crate::generated::api_types::ConnectRequest {
1826            token: self.inner.effective_connection_token.clone(),
1827            enable_git_hub_telemetry_forwarding: self
1828                .inner
1829                .on_github_telemetry
1830                .is_some()
1831                .then_some(true),
1832        };
1833        let value = self
1834            .call(
1835                crate::generated::api_types::rpc_methods::CONNECT,
1836                Some(serde_json::to_value(params)?),
1837            )
1838            .await?;
1839        let result: crate::generated::api_types::ConnectResult = serde_json::from_value(value)?;
1840        Ok(Some(u32::try_from(result.protocol_version).map_err(
1841            |_| ProtocolErrorKind::InvalidProtocolVersion {
1842                server: result.protocol_version,
1843            },
1844        )?))
1845    }
1846
1847    /// Send a `ping` RPC and return the typed [`PingResponse`].
1848    ///
1849    /// Pass `Some(message)` to have the server echo it back; pass `None` for
1850    /// a bare health check. The response includes a `protocolVersion` when
1851    /// the CLI reports one.
1852    ///
1853    /// [`PingResponse`]: crate::types::PingResponse
1854    pub async fn ping(&self, message: Option<&str>) -> Result<crate::types::PingResponse> {
1855        let params = match message {
1856            Some(m) => serde_json::json!({ "message": m }),
1857            None => serde_json::json!({}),
1858        };
1859        let value = self
1860            .call(generated::api_types::rpc_methods::PING, Some(params))
1861            .await?;
1862        Ok(serde_json::from_value(value)?)
1863    }
1864
1865    /// List persisted sessions, optionally filtered by working directory,
1866    /// repository, or git context.
1867    pub async fn list_sessions(
1868        &self,
1869        filter: Option<SessionListFilter>,
1870    ) -> Result<Vec<SessionMetadata>> {
1871        let params = match filter {
1872            Some(f) => serde_json::json!({ "filter": f }),
1873            None => serde_json::json!({}),
1874        };
1875        let result = self.call("session.list", Some(params)).await?;
1876        let response: ListSessionsResponse = serde_json::from_value(result)?;
1877        Ok(response.sessions)
1878    }
1879
1880    /// Fetch metadata for a specific persisted session by ID.
1881    ///
1882    /// Returns `Ok(None)` if no session with the given ID exists. More
1883    /// efficient than calling [`list_sessions`](Self::list_sessions) and
1884    /// filtering when you only need data for a single session.
1885    ///
1886    /// # Example
1887    ///
1888    /// ```no_run
1889    /// # async fn example(client: &github_copilot_sdk::Client) -> Result<(), github_copilot_sdk::Error> {
1890    /// use github_copilot_sdk::types::SessionId;
1891    /// if let Some(metadata) = client.get_session_metadata(&SessionId::new("session-123")).await? {
1892    ///     println!("Session started at: {}", metadata.start_time);
1893    /// }
1894    /// # Ok(())
1895    /// # }
1896    /// ```
1897    pub async fn get_session_metadata(
1898        &self,
1899        session_id: &SessionId,
1900    ) -> Result<Option<SessionMetadata>> {
1901        let result = self
1902            .call(
1903                "session.getMetadata",
1904                Some(serde_json::json!({ "sessionId": session_id })),
1905            )
1906            .await?;
1907        let response: GetSessionMetadataResponse = serde_json::from_value(result)?;
1908        Ok(response.session)
1909    }
1910
1911    /// Delete a persisted session by ID.
1912    pub async fn delete_session(&self, session_id: &SessionId) -> Result<()> {
1913        self.call(
1914            "session.delete",
1915            Some(serde_json::json!({ "sessionId": session_id })),
1916        )
1917        .await?;
1918        Ok(())
1919    }
1920
1921    /// Return the ID of the most recently updated session, if any.
1922    ///
1923    /// Useful for resuming the last conversation when the session ID was
1924    /// not stored. Returns `Ok(None)` if no sessions exist.
1925    ///
1926    /// # Example
1927    ///
1928    /// ```no_run
1929    /// # async fn example(client: &github_copilot_sdk::Client) -> Result<(), github_copilot_sdk::Error> {
1930    /// if let Some(last_id) = client.get_last_session_id().await? {
1931    ///     println!("Last session: {last_id}");
1932    /// }
1933    /// # Ok(())
1934    /// # }
1935    /// ```
1936    pub async fn get_last_session_id(&self) -> Result<Option<SessionId>> {
1937        let result = self
1938            .call("session.getLastId", Some(serde_json::json!({})))
1939            .await?;
1940        let response: GetLastSessionIdResponse = serde_json::from_value(result)?;
1941        Ok(response.session_id)
1942    }
1943
1944    /// Return the ID of the session currently displayed in the TUI, if any.
1945    ///
1946    /// Only meaningful when connected to a server running in TUI+server mode
1947    /// (`--ui-server`). Returns `Ok(None)` if no foreground session is set.
1948    pub async fn get_foreground_session_id(&self) -> Result<Option<SessionId>> {
1949        let result = self
1950            .call("session.getForeground", Some(serde_json::json!({})))
1951            .await?;
1952        let response: GetForegroundSessionResponse = serde_json::from_value(result)?;
1953        Ok(response.session_id)
1954    }
1955
1956    /// Request that the TUI switch to displaying the specified session.
1957    ///
1958    /// Only meaningful when connected to a server running in TUI+server mode
1959    /// (`--ui-server`).
1960    pub async fn set_foreground_session_id(&self, session_id: &SessionId) -> Result<()> {
1961        self.call(
1962            "session.setForeground",
1963            Some(serde_json::json!({ "sessionId": session_id })),
1964        )
1965        .await?;
1966        Ok(())
1967    }
1968
1969    /// Get the CLI server status.
1970    pub async fn get_status(&self) -> Result<GetStatusResponse> {
1971        let result = self.call("status.get", Some(serde_json::json!({}))).await?;
1972        Ok(serde_json::from_value(result)?)
1973    }
1974
1975    /// Get authentication status.
1976    pub async fn get_auth_status(&self) -> Result<GetAuthStatusResponse> {
1977        let result = self
1978            .call("auth.getStatus", Some(serde_json::json!({})))
1979            .await?;
1980        Ok(serde_json::from_value(result)?)
1981    }
1982
1983    /// List available models.
1984    ///
1985    /// When [`ClientOptions::on_list_models`] is set, returns the handler's
1986    /// result without making a `models.list` RPC. Otherwise queries the CLI.
1987    pub async fn list_models(&self) -> Result<Vec<Model>> {
1988        let cache = self.inner.models_cache.lock().clone();
1989        let models = cache
1990            .get_or_try_init(|| async {
1991                if let Some(handler) = &self.inner.on_list_models {
1992                    handler.list_models().await
1993                } else {
1994                    Ok(self.rpc().models().list().await?.models)
1995                }
1996            })
1997            .await?;
1998        Ok(models.clone())
1999    }
2000
2001    /// Invoke [`ClientOptions::on_get_trace_context`] when configured,
2002    /// otherwise return [`TraceContext::default()`].
2003    pub(crate) async fn resolve_trace_context(&self) -> TraceContext {
2004        if let Some(provider) = &self.inner.on_get_trace_context {
2005            provider.get_trace_context().await
2006        } else {
2007            TraceContext::default()
2008        }
2009    }
2010
2011    /// Return the OS process ID of the CLI child process, if one was spawned.
2012    pub fn pid(&self) -> Option<u32> {
2013        self.inner.child.lock().as_ref().and_then(|c| c.id())
2014    }
2015
2016    /// Cooperatively shut down the client and the CLI child process.
2017    ///
2018    /// Walks every still-registered session and sends `session.destroy`
2019    /// for each one, asks SDK-owned runtimes to shut down, then kills the
2020    /// CLI child. Errors from per-session destroys, runtime shutdown, and
2021    /// the final child-kill are collected into
2022    /// [`StopErrors`] rather than short-circuiting on the first failure
2023    /// — so callers see the full picture of teardown.
2024    ///
2025    /// If you have already called [`Session::disconnect`] on every
2026    /// session this client created, the per-session destroy step is a
2027    /// no-op (the router map is empty); only the child-kill remains.
2028    ///
2029    /// [`Session::disconnect`]: crate::session::Session::disconnect
2030    ///
2031    /// # Cancel safety
2032    ///
2033    /// **Cancel-unsafe but recoverable.** The body sequentially destroys
2034    /// every registered session (each via [`Client::call`](Self::call),
2035    /// individually cancel-safe) before killing the child. Cancelling
2036    /// `stop()` mid-loop leaves some sessions still in the router map
2037    /// and the child still running. Recovery: call [`force_stop`](Self::force_stop)
2038    /// (sync, kills the child unconditionally and clears router state)
2039    /// or call `stop()` again with a fresh future. The documented
2040    /// `tokio::time::timeout(..., client.stop())` pattern in the example
2041    /// below uses `force_stop` as the fallback for exactly this case.
2042    pub async fn stop(&self) -> std::result::Result<(), StopErrors> {
2043        let pid = self.pid();
2044        info!(pid = ?pid, "stopping CLI process");
2045        let mut errors: Vec<Error> = Vec::new();
2046
2047        // Snapshot the registered session IDs without holding the router
2048        // lock across the destroy RPCs.
2049        for session_id in self.inner.router.session_ids() {
2050            match self
2051                .call(
2052                    "session.destroy",
2053                    Some(serde_json::json!({ "sessionId": session_id })),
2054                )
2055                .await
2056            {
2057                Ok(_) => {}
2058                Err(e) => {
2059                    warn!(
2060                        session_id = %session_id,
2061                        error = %e,
2062                        "session.destroy failed during Client::stop",
2063                    );
2064                    errors.push(e);
2065                }
2066            }
2067            self.inner.router.unregister(&session_id);
2068        }
2069
2070        let should_shutdown_runtime = self.inner.child.lock().is_some();
2071        if should_shutdown_runtime {
2072            let runtime_shutdown_start = Instant::now();
2073            match tokio::time::timeout(RUNTIME_SHUTDOWN_TIMEOUT, self.rpc().runtime().shutdown())
2074                .await
2075            {
2076                Ok(Ok(())) => {
2077                    debug!(
2078                        elapsed_ms = runtime_shutdown_start.elapsed().as_millis(),
2079                        "Client::stop runtime shutdown complete"
2080                    );
2081                }
2082                Ok(Err(e)) => {
2083                    warn!(
2084                        elapsed_ms = runtime_shutdown_start.elapsed().as_millis(),
2085                        error = %e,
2086                        "runtime.shutdown failed during Client::stop",
2087                    );
2088                    errors.push(e);
2089                }
2090                Err(_) => {
2091                    let e = std::io::Error::new(
2092                        std::io::ErrorKind::TimedOut,
2093                        "runtime.shutdown timed out during Client::stop",
2094                    );
2095                    warn!(
2096                        elapsed_ms = runtime_shutdown_start.elapsed().as_millis(),
2097                        timeout = ?RUNTIME_SHUTDOWN_TIMEOUT,
2098                        error = %e,
2099                        "runtime.shutdown timed out during Client::stop",
2100                    );
2101                    errors.push(e.into());
2102                }
2103            }
2104        }
2105
2106        let child = self.inner.child.lock().take();
2107        *self.inner.state.lock() = ConnectionState::Disconnected;
2108        *self.inner.models_cache.lock() = Arc::new(tokio::sync::OnceCell::new());
2109        if let Some(mut child) = child {
2110            match child.try_wait() {
2111                Ok(Some(_status)) => {}
2112                Ok(None) => {
2113                    // The runtime completes all cleanup before responding to
2114                    // runtime.shutdown and then leaves termination to us; it
2115                    // deliberately keeps its JSON-RPC server alive to send the
2116                    // response and never self-exits. Waiting for a self-exit
2117                    // that will never come just wastes time, so terminate the
2118                    // child immediately.
2119                    if let Err(e) = child.kill().await {
2120                        errors.push(e.into());
2121                    }
2122                }
2123                Err(e) => errors.push(e.into()),
2124            }
2125        }
2126
2127        info!(pid = ?pid, errors = errors.len(), "CLI process stopped");
2128        if errors.is_empty() {
2129            Ok(())
2130        } else {
2131            Err(StopErrors(errors))
2132        }
2133    }
2134
2135    /// Forcibly stop the CLI process without waiting for it to exit.
2136    ///
2137    /// Synchronous fallback when [`stop`](Self::stop) is unsuitable — for
2138    /// example when the awaiting tokio runtime is shutting down or the
2139    /// process is wedged on I/O. Sends a kill signal without awaiting
2140    /// reaper completion and immediately drops all per-session router
2141    /// state so dependent tasks observe a closed channel rather than a
2142    /// hang.
2143    ///
2144    /// # Cancel safety
2145    ///
2146    /// **Synchronous and infallible by construction.** Not async; cannot
2147    /// be cancelled. Designed as the recovery path when [`stop`](Self::stop)
2148    /// is wrapped in a timeout that elapses.
2149    ///
2150    /// # Example
2151    ///
2152    /// ```no_run
2153    /// # async fn example(client: github_copilot_sdk::Client) {
2154    /// // Try graceful shutdown first; fall back to force_stop if hung.
2155    /// match tokio::time::timeout(
2156    ///     std::time::Duration::from_secs(5),
2157    ///     client.stop(),
2158    /// ).await {
2159    ///     Ok(_) => {}
2160    ///     Err(_) => client.force_stop(),
2161    /// }
2162    /// # }
2163    /// ```
2164    pub fn force_stop(&self) {
2165        let pid = self.pid();
2166        info!(pid = ?pid, "force-stopping CLI process");
2167        if let Some(mut child) = self.inner.child.lock().take()
2168            && let Err(e) = child.start_kill()
2169        {
2170            error!(pid = ?pid, error = %e, "failed to send kill signal");
2171        }
2172        self.inner.rpc.force_close();
2173        // Drop all session channels so any awaiters see a closed channel
2174        // instead of waiting for responses that will never arrive.
2175        self.inner.router.clear();
2176        *self.inner.state.lock() = ConnectionState::Disconnected;
2177        *self.inner.models_cache.lock() = Arc::new(tokio::sync::OnceCell::new());
2178    }
2179
2180    /// Subscribe to lifecycle events.
2181    ///
2182    /// Returns a [`LifecycleSubscription`] that yields every
2183    /// [`SessionLifecycleEvent`] sent by the CLI. Drop the value to
2184    /// unsubscribe; there is no separate cancel handle.
2185    ///
2186    /// The returned handle implements both an inherent
2187    /// [`recv`](LifecycleSubscription::recv) method and [`Stream`](tokio_stream::Stream),
2188    /// so callers can use a `while let` loop or any combinator from
2189    /// `tokio_stream::StreamExt` / `futures::StreamExt`.
2190    ///
2191    /// Each subscriber maintains its own queue. If a consumer cannot keep
2192    /// up, the oldest events are dropped and `recv` returns
2193    /// [`RecvErrorKind::Lagged`](crate::subscription::RecvErrorKind::Lagged)
2194    /// with the count of skipped events; consumers
2195    /// should match on it and continue. Slow consumers do not block the
2196    /// producer.
2197    ///
2198    /// To filter by event type, match on `event.event_type` in the
2199    /// consumer task. There is no built-in typed filter — `match` is more
2200    /// flexible and keeps the API surface small.
2201    ///
2202    /// # Example
2203    ///
2204    /// ```no_run
2205    /// # async fn example(client: github_copilot_sdk::Client) {
2206    /// let mut events = client.subscribe_lifecycle();
2207    /// tokio::spawn(async move {
2208    ///     while let Ok(event) = events.recv().await {
2209    ///         println!("session {} -> {:?}", event.session_id, event.event_type);
2210    ///     }
2211    /// });
2212    /// # }
2213    /// ```
2214    pub fn subscribe_lifecycle(&self) -> LifecycleSubscription {
2215        LifecycleSubscription::new(self.inner.lifecycle_tx.subscribe())
2216    }
2217}
2218
2219impl Drop for ClientInner {
2220    fn drop(&mut self) {
2221        if let Some(ref mut child) = *self.child.lock() {
2222            let pid = child.id();
2223            if let Err(e) = child.start_kill() {
2224                error!(pid = ?pid, error = %e, "failed to kill CLI process on drop");
2225            } else {
2226                info!(pid = ?pid, "kill signal sent for CLI process on drop");
2227            }
2228        }
2229    }
2230}
2231
2232#[cfg(test)]
2233mod tests {
2234    use super::*;
2235
2236    #[test]
2237    fn is_transport_failure_matches_request_cancelled() {
2238        let err = Error::from(ErrorKind::Protocol(ProtocolErrorKind::RequestCancelled));
2239        assert!(err.is_transport_failure());
2240    }
2241
2242    #[test]
2243    fn is_transport_failure_matches_io_error() {
2244        let err = Error::from(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "gone"));
2245        assert!(err.is_transport_failure());
2246    }
2247
2248    #[test]
2249    fn is_transport_failure_rejects_rpc_error() {
2250        let err = Error::with_message(ErrorKind::Rpc { code: -1 }, "bad");
2251        assert!(!err.is_transport_failure());
2252    }
2253
2254    #[test]
2255    fn is_transport_failure_rejects_session_error() {
2256        let err = Error::from(ErrorKind::Session(SessionErrorKind::NotFound("s1".into())));
2257        assert!(!err.is_transport_failure());
2258    }
2259
2260    #[test]
2261    fn client_options_builder_composes() {
2262        let opts = ClientOptions::new()
2263            .with_program(CliProgram::Path(PathBuf::from("/usr/local/bin/copilot")))
2264            .with_prefix_args(["node"])
2265            .with_cwd(PathBuf::from("/tmp"))
2266            .with_env([("KEY", "value")])
2267            .with_env_remove(["UNWANTED"])
2268            .with_extra_args(["--quiet"])
2269            .with_github_token("ghp_test")
2270            .with_use_logged_in_user(false)
2271            .with_log_level(LogLevel::Debug)
2272            .with_session_idle_timeout_seconds(120)
2273            .with_enable_remote_sessions(true);
2274        assert!(matches!(opts.program, CliProgram::Path(_)));
2275        assert_eq!(opts.prefix_args, vec![std::ffi::OsString::from("node")]);
2276        assert_eq!(opts.working_directory, PathBuf::from("/tmp"));
2277        assert_eq!(
2278            opts.env,
2279            vec![(
2280                std::ffi::OsString::from("KEY"),
2281                std::ffi::OsString::from("value")
2282            )]
2283        );
2284        assert_eq!(opts.env_remove, vec![std::ffi::OsString::from("UNWANTED")]);
2285        assert_eq!(opts.extra_args, vec!["--quiet".to_string()]);
2286        assert_eq!(opts.github_token.as_deref(), Some("ghp_test"));
2287        assert_eq!(opts.use_logged_in_user, Some(false));
2288        assert!(matches!(opts.log_level, Some(LogLevel::Debug)));
2289        assert_eq!(opts.session_idle_timeout_seconds, Some(120));
2290        assert!(opts.enable_remote_sessions);
2291    }
2292
2293    #[test]
2294    fn is_transport_failure_rejects_other_protocol_errors() {
2295        let err = Error::from(ErrorKind::Protocol(ProtocolErrorKind::CliStartupTimeout));
2296        assert!(!err.is_transport_failure());
2297    }
2298
2299    #[test]
2300    fn build_command_lets_env_remove_strip_injected_token() {
2301        let opts = ClientOptions {
2302            github_token: Some("secret".to_string()),
2303            env_remove: vec![std::ffi::OsString::from("COPILOT_SDK_AUTH_TOKEN")],
2304            ..Default::default()
2305        };
2306        let cmd = Client::build_command(Path::new("/bin/echo"), &opts);
2307        // get_envs() iter yields the latest action per key — None means removed.
2308        let action = cmd
2309            .as_std()
2310            .get_envs()
2311            .find(|(k, _)| *k == std::ffi::OsStr::new("COPILOT_SDK_AUTH_TOKEN"))
2312            .map(|(_, v)| v);
2313        assert_eq!(
2314            action,
2315            Some(None),
2316            "env_remove should win over github_token"
2317        );
2318    }
2319
2320    #[test]
2321    fn build_command_lets_env_override_injected_token() {
2322        let opts = ClientOptions {
2323            github_token: Some("from-options".to_string()),
2324            env: vec![(
2325                std::ffi::OsString::from("COPILOT_SDK_AUTH_TOKEN"),
2326                std::ffi::OsString::from("from-env"),
2327            )],
2328            ..Default::default()
2329        };
2330        let cmd = Client::build_command(Path::new("/bin/echo"), &opts);
2331        let value = cmd
2332            .as_std()
2333            .get_envs()
2334            .find(|(k, _)| *k == std::ffi::OsStr::new("COPILOT_SDK_AUTH_TOKEN"))
2335            .and_then(|(_, v)| v);
2336        assert_eq!(value, Some(std::ffi::OsStr::new("from-env")));
2337    }
2338
2339    #[test]
2340    fn build_command_injects_github_token_by_default() {
2341        let opts = ClientOptions {
2342            github_token: Some("just-the-token".to_string()),
2343            ..Default::default()
2344        };
2345        let cmd = Client::build_command(Path::new("/bin/echo"), &opts);
2346        let value = cmd
2347            .as_std()
2348            .get_envs()
2349            .find(|(k, _)| *k == std::ffi::OsStr::new("COPILOT_SDK_AUTH_TOKEN"))
2350            .and_then(|(_, v)| v);
2351        assert_eq!(value, Some(std::ffi::OsStr::new("just-the-token")));
2352    }
2353
2354    fn env_value<'a>(cmd: &'a tokio::process::Command, key: &str) -> Option<&'a std::ffi::OsStr> {
2355        cmd.as_std()
2356            .get_envs()
2357            .find(|(k, _)| *k == std::ffi::OsStr::new(key))
2358            .and_then(|(_, v)| v)
2359    }
2360
2361    #[test]
2362    fn telemetry_config_builder_composes() {
2363        let cfg = TelemetryConfig::new()
2364            .with_otlp_endpoint("http://collector:4318")
2365            .with_otlp_protocol(OtlpHttpProtocol::HttpProtobuf)
2366            .with_file_path(PathBuf::from("/var/log/copilot.jsonl"))
2367            .with_exporter_type(OtelExporterType::OtlpHttp)
2368            .with_source_name("my-app")
2369            .with_capture_content(true);
2370
2371        assert_eq!(cfg.otlp_endpoint.as_deref(), Some("http://collector:4318"));
2372        assert_eq!(cfg.otlp_protocol, Some(OtlpHttpProtocol::HttpProtobuf));
2373        assert_eq!(
2374            cfg.file_path.as_deref(),
2375            Some(Path::new("/var/log/copilot.jsonl")),
2376        );
2377        assert_eq!(cfg.exporter_type, Some(OtelExporterType::OtlpHttp));
2378        assert_eq!(cfg.source_name.as_deref(), Some("my-app"));
2379        assert_eq!(cfg.capture_content, Some(true));
2380        assert!(!cfg.is_empty());
2381        assert!(TelemetryConfig::new().is_empty());
2382    }
2383
2384    #[test]
2385    fn otlp_http_protocol_serde_matches_env_value() {
2386        for (protocol, wire) in [
2387            (OtlpHttpProtocol::HttpJson, "http/json"),
2388            (OtlpHttpProtocol::HttpProtobuf, "http/protobuf"),
2389        ] {
2390            assert_eq!(protocol.as_str(), wire);
2391
2392            let serialized = serde_json::to_string(&protocol).unwrap();
2393            assert_eq!(serialized, format!("\"{wire}\""));
2394
2395            let deserialized: OtlpHttpProtocol = serde_json::from_str(&serialized).unwrap();
2396            assert_eq!(deserialized, protocol);
2397        }
2398    }
2399
2400    #[test]
2401    fn build_command_sets_otel_env_when_telemetry_enabled() {
2402        let opts = ClientOptions {
2403            telemetry: Some(TelemetryConfig {
2404                otlp_endpoint: Some("http://collector:4318".to_string()),
2405                otlp_protocol: Some(OtlpHttpProtocol::HttpProtobuf),
2406                file_path: Some(PathBuf::from("/var/log/copilot.jsonl")),
2407                exporter_type: Some(OtelExporterType::OtlpHttp),
2408                source_name: Some("my-app".to_string()),
2409                capture_content: Some(true),
2410            }),
2411            ..Default::default()
2412        };
2413        let cmd = Client::build_command(Path::new("/bin/echo"), &opts);
2414        assert_eq!(
2415            env_value(&cmd, "COPILOT_OTEL_ENABLED"),
2416            Some(std::ffi::OsStr::new("true")),
2417        );
2418        assert_eq!(
2419            env_value(&cmd, "OTEL_EXPORTER_OTLP_ENDPOINT"),
2420            Some(std::ffi::OsStr::new("http://collector:4318")),
2421        );
2422        assert_eq!(
2423            env_value(&cmd, "OTEL_EXPORTER_OTLP_PROTOCOL"),
2424            Some(std::ffi::OsStr::new("http/protobuf")),
2425        );
2426        assert_eq!(
2427            env_value(&cmd, "COPILOT_OTEL_FILE_EXPORTER_PATH"),
2428            Some(std::ffi::OsStr::new("/var/log/copilot.jsonl")),
2429        );
2430        assert_eq!(
2431            env_value(&cmd, "COPILOT_OTEL_EXPORTER_TYPE"),
2432            Some(std::ffi::OsStr::new("otlp-http")),
2433        );
2434        assert_eq!(
2435            env_value(&cmd, "COPILOT_OTEL_SOURCE_NAME"),
2436            Some(std::ffi::OsStr::new("my-app")),
2437        );
2438        assert_eq!(
2439            env_value(&cmd, "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"),
2440            Some(std::ffi::OsStr::new("true")),
2441        );
2442    }
2443
2444    #[test]
2445    fn build_command_omits_otel_env_when_telemetry_none() {
2446        let opts = ClientOptions::default();
2447        let cmd = Client::build_command(Path::new("/bin/echo"), &opts);
2448        for key in [
2449            "COPILOT_OTEL_ENABLED",
2450            "OTEL_EXPORTER_OTLP_ENDPOINT",
2451            "OTEL_EXPORTER_OTLP_PROTOCOL",
2452            "COPILOT_OTEL_FILE_EXPORTER_PATH",
2453            "COPILOT_OTEL_EXPORTER_TYPE",
2454            "COPILOT_OTEL_SOURCE_NAME",
2455            "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT",
2456        ] {
2457            assert!(
2458                env_value(&cmd, key).is_none(),
2459                "expected {key} to be unset when telemetry is None",
2460            );
2461        }
2462    }
2463
2464    #[test]
2465    fn build_command_omits_unset_telemetry_fields() {
2466        let opts = ClientOptions {
2467            telemetry: Some(TelemetryConfig {
2468                otlp_endpoint: Some("http://collector:4318".to_string()),
2469                ..Default::default()
2470            }),
2471            ..Default::default()
2472        };
2473        let cmd = Client::build_command(Path::new("/bin/echo"), &opts);
2474        // The one set field plus the implicit enabled flag should propagate.
2475        assert_eq!(
2476            env_value(&cmd, "COPILOT_OTEL_ENABLED"),
2477            Some(std::ffi::OsStr::new("true")),
2478        );
2479        assert_eq!(
2480            env_value(&cmd, "OTEL_EXPORTER_OTLP_ENDPOINT"),
2481            Some(std::ffi::OsStr::new("http://collector:4318")),
2482        );
2483        // None of the other fields should leak as env vars.
2484        for key in [
2485            "OTEL_EXPORTER_OTLP_PROTOCOL",
2486            "COPILOT_OTEL_FILE_EXPORTER_PATH",
2487            "COPILOT_OTEL_EXPORTER_TYPE",
2488            "COPILOT_OTEL_SOURCE_NAME",
2489            "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT",
2490        ] {
2491            assert!(env_value(&cmd, key).is_none(), "{key} should be unset");
2492        }
2493    }
2494
2495    #[test]
2496    fn build_command_lets_user_env_override_telemetry() {
2497        let opts = ClientOptions {
2498            telemetry: Some(TelemetryConfig {
2499                otlp_endpoint: Some("http://from-config:4318".to_string()),
2500                ..Default::default()
2501            }),
2502            env: vec![(
2503                std::ffi::OsString::from("OTEL_EXPORTER_OTLP_ENDPOINT"),
2504                std::ffi::OsString::from("http://from-user-env:4318"),
2505            )],
2506            ..Default::default()
2507        };
2508        let cmd = Client::build_command(Path::new("/bin/echo"), &opts);
2509        assert_eq!(
2510            env_value(&cmd, "OTEL_EXPORTER_OTLP_ENDPOINT"),
2511            Some(std::ffi::OsStr::new("http://from-user-env:4318")),
2512            "user-supplied options.env should override telemetry config",
2513        );
2514    }
2515
2516    #[test]
2517    fn build_command_sets_copilot_home_env_when_configured() {
2518        let opts = ClientOptions::new().with_base_directory(PathBuf::from("/custom/copilot"));
2519        let cmd = Client::build_command(Path::new("/bin/echo"), &opts);
2520        assert_eq!(
2521            env_value(&cmd, "COPILOT_HOME"),
2522            Some(std::ffi::OsStr::new("/custom/copilot")),
2523        );
2524
2525        let opts = ClientOptions::default();
2526        let cmd = Client::build_command(Path::new("/bin/echo"), &opts);
2527        assert!(env_value(&cmd, "COPILOT_HOME").is_none());
2528    }
2529
2530    #[test]
2531    fn build_command_sets_connection_token_env_when_configured() {
2532        let opts = ClientOptions::new().with_transport(Transport::Tcp {
2533            port: 0,
2534            connection_token: Some("secret-token".to_string()),
2535        });
2536        let cmd = Client::build_command(Path::new("/bin/echo"), &opts);
2537        assert_eq!(
2538            env_value(&cmd, "COPILOT_CONNECTION_TOKEN"),
2539            Some(std::ffi::OsStr::new("secret-token")),
2540        );
2541
2542        let opts = ClientOptions::default();
2543        let cmd = Client::build_command(Path::new("/bin/echo"), &opts);
2544        assert!(env_value(&cmd, "COPILOT_CONNECTION_TOKEN").is_none());
2545    }
2546
2547    #[tokio::test]
2548    async fn start_rejects_empty_connection_token() {
2549        let opts = ClientOptions::new()
2550            .with_transport(Transport::Tcp {
2551                port: 0,
2552                connection_token: Some(String::new()),
2553            })
2554            .with_program(CliProgram::Path(PathBuf::from("/bin/echo")));
2555        let err = Client::start(opts).await.unwrap_err();
2556        assert!(
2557            matches!(err.kind(), ErrorKind::InvalidConfig),
2558            "got {err:?}"
2559        );
2560    }
2561
2562    #[tokio::test]
2563    async fn start_rejects_empty_external_connection_token() {
2564        let opts = ClientOptions::new()
2565            .with_transport(Transport::External {
2566                host: "127.0.0.1".to_string(),
2567                port: 1,
2568                connection_token: Some(String::new()),
2569            })
2570            .with_program(CliProgram::Path(PathBuf::from("/bin/echo")));
2571        let err = Client::start(opts).await.unwrap_err();
2572        assert!(
2573            matches!(err.kind(), ErrorKind::InvalidConfig),
2574            "got {err:?}"
2575        );
2576    }
2577
2578    #[test]
2579    fn telemetry_config_capture_content_serializes_as_lowercase_bool() {
2580        let opts_true = ClientOptions {
2581            telemetry: Some(TelemetryConfig {
2582                capture_content: Some(true),
2583                ..Default::default()
2584            }),
2585            ..Default::default()
2586        };
2587        let opts_false = ClientOptions {
2588            telemetry: Some(TelemetryConfig {
2589                capture_content: Some(false),
2590                ..Default::default()
2591            }),
2592            ..Default::default()
2593        };
2594        let cmd_true = Client::build_command(Path::new("/bin/echo"), &opts_true);
2595        let cmd_false = Client::build_command(Path::new("/bin/echo"), &opts_false);
2596        assert_eq!(
2597            env_value(
2598                &cmd_true,
2599                "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"
2600            ),
2601            Some(std::ffi::OsStr::new("true")),
2602        );
2603        assert_eq!(
2604            env_value(
2605                &cmd_false,
2606                "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"
2607            ),
2608            Some(std::ffi::OsStr::new("false")),
2609        );
2610    }
2611
2612    #[test]
2613    fn session_idle_timeout_args_are_omitted_by_default() {
2614        let opts = ClientOptions::default();
2615        assert!(Client::session_idle_timeout_args(&opts).is_empty());
2616    }
2617
2618    #[test]
2619    fn session_idle_timeout_args_omitted_for_zero() {
2620        let opts = ClientOptions {
2621            session_idle_timeout_seconds: Some(0),
2622            ..Default::default()
2623        };
2624        assert!(Client::session_idle_timeout_args(&opts).is_empty());
2625    }
2626
2627    #[test]
2628    fn session_idle_timeout_args_emit_flag_for_positive_value() {
2629        let opts = ClientOptions {
2630            session_idle_timeout_seconds: Some(300),
2631            ..Default::default()
2632        };
2633        assert_eq!(
2634            Client::session_idle_timeout_args(&opts),
2635            vec!["--session-idle-timeout".to_string(), "300".to_string()]
2636        );
2637    }
2638
2639    #[test]
2640    fn remote_args_omitted_by_default() {
2641        let opts = ClientOptions::default();
2642        assert!(Client::remote_args(&opts).is_empty());
2643    }
2644
2645    #[test]
2646    fn remote_args_emit_flag_when_enabled() {
2647        let opts = ClientOptions {
2648            enable_remote_sessions: true,
2649            ..Default::default()
2650        };
2651        assert_eq!(Client::remote_args(&opts), vec!["--remote".to_string()]);
2652    }
2653
2654    #[test]
2655    fn log_level_args_omitted_when_unset() {
2656        let opts = ClientOptions::default();
2657        assert!(opts.log_level.is_none());
2658        assert!(
2659            Client::log_level_args(&opts).is_empty(),
2660            "with no caller-supplied log_level the SDK must not pass --log-level"
2661        );
2662    }
2663
2664    #[test]
2665    fn log_level_args_emit_flag_when_set() {
2666        let opts = ClientOptions::default().with_log_level(LogLevel::Debug);
2667        assert_eq!(Client::log_level_args(&opts), vec!["--log-level", "debug"]);
2668    }
2669
2670    #[test]
2671    fn log_level_str_round_trips() {
2672        for level in [
2673            LogLevel::None,
2674            LogLevel::Error,
2675            LogLevel::Warning,
2676            LogLevel::Info,
2677            LogLevel::Debug,
2678            LogLevel::All,
2679        ] {
2680            let s = level.as_str();
2681            let json = serde_json::to_string(&level).unwrap();
2682            assert_eq!(json, format!("\"{s}\""));
2683            let parsed: LogLevel = serde_json::from_str(&json).unwrap();
2684            assert_eq!(parsed, level);
2685        }
2686    }
2687
2688    #[test]
2689    fn client_options_debug_redacts_handler() {
2690        struct StubHandler;
2691        #[async_trait]
2692        impl ListModelsHandler for StubHandler {
2693            async fn list_models(&self) -> Result<Vec<Model>> {
2694                Ok(vec![])
2695            }
2696        }
2697        let opts = ClientOptions {
2698            on_list_models: Some(Arc::new(StubHandler)),
2699            github_token: Some("secret-token".into()),
2700            ..Default::default()
2701        };
2702        let debug = format!("{opts:?}");
2703        assert!(debug.contains("on_list_models: Some(\"<set>\")"));
2704        assert!(debug.contains("github_token: Some(\"<redacted>\")"));
2705        assert!(!debug.contains("secret-token"));
2706    }
2707
2708    #[tokio::test]
2709    async fn list_models_uses_on_list_models_handler_when_set() {
2710        use std::sync::atomic::{AtomicUsize, Ordering};
2711
2712        struct CountingHandler {
2713            calls: Arc<AtomicUsize>,
2714            models: Vec<Model>,
2715        }
2716        #[async_trait]
2717        impl ListModelsHandler for CountingHandler {
2718            async fn list_models(&self) -> Result<Vec<Model>> {
2719                self.calls.fetch_add(1, Ordering::SeqCst);
2720                Ok(self.models.clone())
2721            }
2722        }
2723
2724        let calls = Arc::new(AtomicUsize::new(0));
2725        let model = Model {
2726            id: "byok-gpt-4".into(),
2727            name: "BYOK GPT-4".into(),
2728            ..Default::default()
2729        };
2730        let handler: Arc<dyn ListModelsHandler> = Arc::new(CountingHandler {
2731            calls: Arc::clone(&calls),
2732            models: vec![model.clone()],
2733        });
2734
2735        let client = client_with_list_models_handler(handler);
2736
2737        let result = client.list_models().await.unwrap();
2738        assert_eq!(result.len(), 1);
2739        assert_eq!(result[0].id, "byok-gpt-4");
2740        assert_eq!(calls.load(Ordering::SeqCst), 1);
2741    }
2742
2743    #[tokio::test]
2744    async fn list_models_serializes_concurrent_cache_misses() {
2745        use std::sync::atomic::{AtomicUsize, Ordering};
2746
2747        struct SlowCountingHandler {
2748            calls: Arc<AtomicUsize>,
2749            models: Vec<Model>,
2750        }
2751        #[async_trait]
2752        impl ListModelsHandler for SlowCountingHandler {
2753            async fn list_models(&self) -> Result<Vec<Model>> {
2754                self.calls.fetch_add(1, Ordering::SeqCst);
2755                tokio::time::sleep(std::time::Duration::from_millis(25)).await;
2756                Ok(self.models.clone())
2757            }
2758        }
2759
2760        let calls = Arc::new(AtomicUsize::new(0));
2761        let model = Model {
2762            id: "single-flight-model".into(),
2763            name: "Single Flight Model".into(),
2764            ..Default::default()
2765        };
2766        let handler: Arc<dyn ListModelsHandler> = Arc::new(SlowCountingHandler {
2767            calls: Arc::clone(&calls),
2768            models: vec![model],
2769        });
2770        let client = client_with_list_models_handler(handler);
2771
2772        let (first, second) = tokio::join!(client.list_models(), client.list_models());
2773        assert_eq!(first.unwrap()[0].id, "single-flight-model");
2774        assert_eq!(second.unwrap()[0].id, "single-flight-model");
2775        assert_eq!(calls.load(Ordering::SeqCst), 1);
2776    }
2777
2778    #[tokio::test]
2779    async fn cancelled_resume_session_unregisters_pending_session() {
2780        let (client_write, _server_read) = tokio::io::duplex(8192);
2781        let (_server_write, client_read) = tokio::io::duplex(8192);
2782        let client = Client::from_streams(client_read, client_write, std::env::temp_dir()).unwrap();
2783        let session_id = SessionId::new("resume-cancel-test");
2784        let handle = tokio::spawn({
2785            let client = client.clone();
2786            async move {
2787                client
2788                    .resume_session(ResumeSessionConfig::new(session_id))
2789                    .await
2790            }
2791        });
2792
2793        wait_for_pending_session_registration(&client).await;
2794        handle.abort();
2795        let _ = handle.await;
2796
2797        assert!(client.inner.router.session_ids().is_empty());
2798        client.force_stop();
2799    }
2800
2801    fn client_with_list_models_handler(handler: Arc<dyn ListModelsHandler>) -> Client {
2802        Client {
2803            inner: Arc::new(ClientInner {
2804                child: parking_lot::Mutex::new(None),
2805                rpc: {
2806                    let (req_tx, _req_rx) = mpsc::unbounded_channel();
2807                    let (notif_tx, _notif_rx) = broadcast::channel(16);
2808                    let (read_pipe, _write_pipe) = tokio::io::duplex(64);
2809                    let (_unused_read, write_pipe) = tokio::io::duplex(64);
2810                    JsonRpcClient::new(write_pipe, read_pipe, notif_tx, req_tx)
2811                },
2812                cwd: PathBuf::from("."),
2813                request_rx: parking_lot::Mutex::new(None),
2814                notification_tx: broadcast::channel(16).0,
2815                router: router::SessionRouter::new(),
2816                negotiated_protocol_version: OnceLock::new(),
2817                state: parking_lot::Mutex::new(ConnectionState::Connected),
2818                lifecycle_tx: broadcast::channel(16).0,
2819                on_list_models: Some(handler),
2820                models_cache: parking_lot::Mutex::new(Arc::new(tokio::sync::OnceCell::new())),
2821                session_fs_configured: false,
2822                session_fs_sqlite_declared: false,
2823                llm_inference: OnceLock::new(),
2824                on_github_telemetry: None,
2825                on_get_trace_context: None,
2826                effective_connection_token: None,
2827                mode: ClientMode::default(),
2828            }),
2829        }
2830    }
2831
2832    async fn wait_for_pending_session_registration(client: &Client) {
2833        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
2834        while client.inner.router.session_ids().is_empty() {
2835            assert!(
2836                tokio::time::Instant::now() < deadline,
2837                "session was not registered"
2838            );
2839            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2840        }
2841    }
2842}