Skip to main content

github_copilot_sdk/
lib.rs

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