Skip to content

Commit 19b4ad5

Browse files
tclemCopilotcschleidendaviddossettdevm33
authored
Add Rust SDK (technical preview) (#1164)
* Add Rust SDK Adds the Copilot Rust SDK (`copilot-sdk` crate) under `rust/`, alongside Rust codegen plumbed into `scripts/codegen/` and CI under `.github/workflows/rust-sdk-tests.yml`. The crate ships a JSON-RPC client, session lifecycle management, system message transforms, permission policy helpers, the `define_tool` adapter, and per-event `SessionHandler`/`SessionHooks` traits. Includes: - 14 ported E2E scenarios under `rust/tests/` driving the replay-proxy harness, plus a hand-curated set of unit tests. - A rust-coding-skill (`.github/skills/rust-coding-skill/`) capturing conventions for error handling, async/concurrency, tracing, and the intentional trait exceptions in the SDK's public API. - Release tooling: `rust-publish-release.yml`, `RELEASING.md`, and protocol-version generation wired into the existing automation. - `PermissionResult` extended with `Deferred` and `Custom` variants for richer permission decisions. Public API is held at 0.1.0-pre. Marked protocol-evolving public enums `#[non_exhaustive]` so additive variants stay non-breaking. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Christopher Schleiden <cschleiden@github.com> Co-authored-by: David Dossett <25163139+daviddossett@users.noreply.github.com> Co-authored-by: Devraj Mehta <devm33@github.com> Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Co-authored-by: Evan Boyle <EvanBoyle@users.noreply.github.com> Co-authored-by: Jeremy Moseley <jemoseley@microsoft.com> Co-authored-by: Steve Sanderson <SteveSandersonMS@users.noreply.github.com> * Polish public API for 0.1.0 release - **Broadcast subscriptions for lifecycle and session events.** `Client::subscribe_lifecycle()` and `Session::subscribe()` return `tokio::sync::broadcast::Receiver`; dropping the receiver unsubscribes. Replaces the prior callback-based `Client::on`, `Client::on_event_type`, `Session::on`, and `Unsubscribe` API. Spawned consumer tasks isolate panics naturally. - **`PermissionResult` gains `Deferred` and `Custom` variants.** `Deferred` lets handlers resolve a request asynchronously via `session.permissions.handlePendingPermissionRequest` (notification path only — falls back to `Approved` on the direct RPC path). `Custom(Value)` lets handlers send arbitrary response payloads beyond the standard `approve-once` / `reject` shapes. - **`#[non_exhaustive]` on protocol-evolving public enums** (`PermissionResult`, `SessionLifecycleEventType`, `GitHubReferenceType`, others) so additive variants stay non-breaking. - **`ToolHandlerRouter` overrides per-event `SessionHandler` methods** so consumers can call `router.on_external_tool(...)` directly without unwrapping `HandlerResponse`. - **`define_tool` accepts bare `async fn` items** in addition to closures, matching `tower::service_fn` / `hyper::service::service_fn` conventions. Documented in rustdoc. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Route generated SessionId/RequestId fields through hand-authored newtypes Generated code emitted `pub session_id: String` for every schema field named `sessionId` and likewise for `requestId`, leaving consumers with mixed types: `Session::id()` returned `SessionId` but `session.events_subscribe()` events exposed `session_id: String`. Same papercut for request IDs in permission and elicitation event payloads. The newtypes are `#[serde(transparent)]` so the wire format is unchanged. This adds a property-name override map to `scripts/codegen/rust.ts` that maps `sessionId`, `remoteSessionId`, and `requestId` to the hand-authored types in `crate::types`, and emits the matching `use` statement in both generated modules. `mc_session_id` (MCP protocol metadata, not a Copilot session) stays as `String`. After regeneration: 27 fields converted to `SessionId` (including the handoff event's `remoteSessionId`) and 25 to `RequestId`. The existing `PartialEq<str>` / `PartialEq<String>` impls on both newtypes mean test code like Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Pass ToolInvocation to define_tool closures define_tool's Fn(P) -> Fut bound gave closures only the deserialized arguments, leaving session_id, tool_call_id, and tool_name unreachable. That blocked the helper for any tool that needs to scope DB lookups to a session, emit per-tool-call telemetry, or stream UI updates back to the originating session — patterns that hit dozens of sites across realistic tool suites. Change the closure bound to Fn(ToolInvocation, P) -> Fut. The arguments are moved out via mem::take before deserialization, so there is no clone cost on the hot path. Closures that don't need the metadata write |_inv, params|. Also add ToolInvocation::params<P>() so long-form impl ToolHandler blocks can deserialize without naming serde_json directly: async fn call(&self, inv: ToolInvocation) -> Result<ToolResult, Error> { let params: MyParams = inv.params()?; // …use inv.session_id alongside params… } Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Make ping message argument optional Node, Python, and .NET all expose ping with an optional message. Go requires it only because Go has no Option type — Rust has one, so the API should match the languages with the same expressive power rather than the one without. Change ping(&self, message: &str) to ping(&self, message: Option<&str>). When None, the message field is omitted from the request payload rather than sent as an empty string. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Build Rust docs with all features in CI cargo doc was running with --features test-support, which left the derive feature off and made intra-doc links to define_tool and schema_for resolve to nothing — failing under the crate's deny(rustdoc::broken_intra_doc_links). docs.rs already uses all-features (see Cargo.toml's [package.metadata.docs.rs]); align CI with that so the docs job matches what users will see on docs.rs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR #1164 review feedback emitted from the loop correlate to a session in traces. Matches the pattern documented in the rust-coding-skill. - README.md / embeddedcli.rs: correct the embedded-CLI documentation to match what build.rs and embeddedcli.rs actually do — archives come from the github/copilot-cli GitHub Releases, integrity is SHA-256 against SHA256SUMS.txt, and the runtime cache path is ~/.cache/copilot-sdk-{version}/copilot. - test/scenarios/sessions/streaming/verify.sh: drop a duplicate '# Go: build' comment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Regenerate Rust types for @github/copilot 1.0.39-0 Picks up the new model.call_failure session event (with its ModelCallFailureData payload and ModelCallFailureSource enum) and the new optional 'tip' field on session_info. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Scope codegen-check workflow changes to Rust only Removes path triggers and the regenerate step for other languages' protocol-version files. Those drift checks are a pre-existing gap on main and out of scope for the Rust SDK port. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Point rust-publish-release workflow header to RELEASING.md The 23-line setup checklist duplicated content already in rust/RELEASING.md. One-line pointer is enough. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update Rust scenario binaries for new define_tool signature Two scenarios still used the old `Fn(P) -> Fut` shape and broke when the SDK switched to `Fn(ToolInvocation, P) -> Fut`. They don't use the invocation field, so just bind it as `_inv`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Rename Session::send_message -> send and align MessageOptions Cross-SDK consistency: every other SDK (Node, Python, Go, .NET) uses `send`/`Send`/`SendAsync` plus `MessageOptions` as the public parameter type. Rust was the outlier with `send_message` and `SendOptions`, and the asymmetry with the existing `send_and_wait` method made it read awkwardly. - Rename `Session::send_message` -> `Session::send` (and the private helper `send_message_inner` -> `send_inner`). - Rename the public `SendOptions` type -> `MessageOptions`. - Delete the previous wire-level `MessageOptions` struct: it had no internal callers (the wire payload is hand-rolled in send_inner) and freeing the name was the cleanest path to parity. Pre-1.0 type rename, no protocol or behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Wrap subscribe() in EventSubscription / LifecycleSubscription newtypes Previously Session::subscribe and Client::subscribe_lifecycle returned raw tokio::sync::broadcast::Receiver<T> values. A survey of mature Rust crates (tonic, lapin, rdkafka, redis-rs, tokio-tungstenite, iroh-gossip, tokio-stream's BroadcastStream itself) found that none of them expose a raw broadcast::Receiver in their public API; the dominant pattern is a named newtype implementing futures::Stream, with overflow surfaced explicitly in the item type. Introduce a copilot::subscription module with: - EventSubscription / LifecycleSubscription newtypes - Inherent recv() returning Result<T, RecvError> for existing while-let loop ergonomics - Stream impl yielding Result<T, Lagged> so callers can use tokio_stream::StreamExt or futures::StreamExt combinators - Lagged / RecvError types owned by the SDK so consumers no longer import tokio's broadcast error types Net effect: the channel choice is now an internal implementation detail. We can swap broadcast for async-broadcast / flume / a custom backpressure policy, or convert lag into an Event::Lagged variant, without a breaking change to the public surface. Existing while-let loops in tests and examples continue to compile and behave identically: close and lag both exit the loop, matching tokio::sync::broadcast::Receiver. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply nightly rustfmt to subscription module Local cargo +nightly fmt --check passed without `--config-path .rustfmt.nightly.toml`, but CI runs with the explicit config and flagged two diffs: import group flattening and test-mod import order. Applied with the same flags CI uses. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix workspaces RPC method names (was singular `workspace.*`) `Session::list_workspace_files`, `read_workspace_file`, and `create_workspace_file` were calling the singular `session.workspace.*` RPC methods. The CLI exposes these under the plural namespace `session.workspaces.*` (see Node `session.rpc.workspaces`, Go `session.RPC.Workspaces`, .NET `session.Rpc.Workspaces`, Python `session.rpc.workspaces`, and the generated `SESSION_WORKSPACES_*` constants). Route the three calls through the typed constants in `generated::api_types::rpc_methods` so the bug class is structurally gone — a stale string literal can no longer drift from the schema. Add three integration tests against the mock server in `session_test.rs` asserting the wire method name and request shape for each helper. This mirrors the `session_rpc_methods_send_correct_method_names` table-driven test that already covers the rest of the session RPCs. Also fold the missing `create_workspace_file` into the workspace helpers bullet in CHANGELOG.md. The bug never shipped — the SDK is pre-publish — so no migration notes. Heads-up filed to the github-app sister copy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * rust: add typed RPC namespace, route helpers through it Phase 4 § 4.6.A.5 — generate a fully-typed `client.rpc.*` / `session.rpc.*` namespace from the Copilot CLI schema, mirroring the Node/Python/Go/.NET SDKs. All hand-authored ergonomic helpers (`list_workspace_files`, `read_plan`, `set_mode`, `list_models`, `get_quota`, etc.) become one-line delegations over the namespace — their public signatures are unchanged, but wire-method strings now exist in exactly one place (`generated/rpc.rs`). This makes the `session.workspace.*` → `session.workspaces.*` typo bug class structurally impossible: helpers can't drift from the schema because they no longer reference wire strings, and new RPCs land in the namespace immediately as the schema regenerates. - scripts/codegen/rust.ts: emit rust/src/generated/rpc.rs alongside api_types.rs. Builds a namespace tree from `rpcMethod` paths, resolves $ref/title/inline schemas for params + results, injects sessionId for session methods, dispatches via rpc_methods constants. - rust/src/generated/rpc.rs: new ~1370 LOC generated file. - rust/src/lib.rs: Client::rpc() accessor; ping/list_models/get_quota rewritten as delegations. - rust/src/session.rs: Session::rpc() accessor; workspaces/plan/mode/ model/name/log/fleet/permissions helpers rewritten as delegations. - rust/tests/session_test.rs: add 3 namespace-only tests (session.rpc().agent().list, session.rpc().tasks().list, client.rpc().models().list); update session.log mock response to match typed LogResult shape (eventId required). - rust/CHANGELOG.md, rust/README.md: document the new namespace. cargo +nightly-2026-04-14 fmt --check, cargo clippy --all-features --all-targets -- -D warnings, and cargo test --all-features all green. No breaking changes for SDK or github-app consumers — public helper signatures are preserved by construction. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Rename crate to `github-copilot-sdk` Per formal naming decision, the published crate name on crates.io will be `github-copilot-sdk` and the import path will be `use github_copilot_sdk::...`. Changes: - `Cargo.toml`: `name = "github-copilot-sdk"`, `[lib] name = "github_copilot_sdk"`, `documentation = "https://docs.rs/github-copilot-sdk"`. Repository and homepage URLs continue to point at `github/copilot-sdk` (the repo name is unchanged). - All `use copilot::` / `copilot::` references migrated to `use github_copilot_sdk::` / `github_copilot_sdk::` across `src/`, `tests/`, `examples/`, README, CHANGELOG, RELEASING.md, and codegen scripts. - Embedded-CLI cache directory renamed from `~/.cache/copilot-sdk-*` to `~/.cache/github-copilot-sdk-*` for naming consistency. Pre-release caches will be orphaned; acceptable given the crate is unpublished. - `release-plz.toml` package name updated. Verified: `cargo check`, `cargo clippy -D warnings`, `cargo test`, nightly `cargo fmt --check` all green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add typed wrappers for filter/MCP/permission shapes (Bucket A.1, A.3, A.4) Replaces the remaining `serde_json::Value` blobs on the public surface with typed structs/enums, mirroring the wire shapes documented in Node/Go/Python/.NET. Helper signatures already changed from `Value` to typed equivalents in the same call-sites; this lands the type definitions and constructor-site updates. A.3 — typed wrappers * `SessionListFilter { cwd, git_root, repository, branch }` for `Client::list_sessions`. Was `Option<serde_json::Value>`. * `McpServerConfig` tagged enum (Stdio/Http/Sse) with `McpStdioServerConfig` / `McpHttpServerConfig` payloads. `SessionConfig::mcp_servers`, `ResumeSessionConfig::mcp_servers`, and `CustomAgentConfig::mcp_servers` are now `Option<HashMap<String, McpServerConfig>>`. Accepts `type: "local"` alias on deserialize for back-compat with the legacy CLI shape. * `PermissionRequestData` gains `kind: Option<PermissionRequestKind>` and `tool_call_id: Option<String>` fields. The eight CLI permission categories (shell/write/read/url/mcp/custom-tool/memory/hook) are enumerated; unknown kinds fall through to `Unknown`. Existing `extra: Value` flatten is preserved so `data.extra["command"]` etc. keep working. A.4 — PermissionResult variants * Adds `UserNotAvailable` (encodes as `{kind: "user-not-available"}`) and `NoResult` (encodes as `{kind: "no-result"}`). Both paths — notification (`handlePendingPermissionRequest`) and direct RPC (`permission.request`) — emit the correct kind string. * `pending_permission_result_kind` updated to handle `NoResult` explicitly; `direct_permission_payload` updated to bypass ApproveOnce/Reject conversion for these new variants. A.1 — disable_resume * `ResumeSessionConfig::disable_resume: Option<bool>`. Mirrors Node's `ResumeSessionConfig.disableResume` and Go's `*bool DisableResume`. Forces resume to fail if the session is missing on disk rather than silently starting a new one. Tests * `list_sessions_serializes_typed_filter` — confirms the typed filter serializes camelCase, omits None fields, and dispatches via `session.list`. * `mcp_server_config_roundtrips_through_tagged_enum` — confirms `type: "stdio"` serialize, `type: "local"` alias on deserialize, and HashMap roundtrip. * `permission_request_data_extracts_typed_kind` — confirms typed kind extraction including the kebab-case `custom-tool` rename and the forward-compatible `Unknown` fallthrough. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Document infinite_sessions parity + Client::stop deferral (Bucket A.2/A.6) Notes-only CHANGELOG entries closing out the remaining Bucket A items: * A.2 (infinite_sessions) — already wired on both SessionConfig and ResumeSessionConfig as `Option<InfiniteSessionConfig>` with default-omit-on-the-wire semantics. Matches Node/Go (CLI-applied defaults when the field is absent). No code change; document the parity decision so it doesn't get re-flagged. * A.6 (Client::stop error aggregation) — defer behind a Client-level session registry. Real aggregation requires iterating Session handles to disconnect-then-kill, but the Rust Client only tracks per-session channel senders today (router::SessionSenders), not Session instances. A cosmetic Result<(), Vec<Error>> change with no behavior shift would be pure pass-through wrapping. Tracked for Bucket B alongside the registry refactor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Aggregate Client::stop errors across active sessions (Bucket B / A.6) Rewrite `Client::stop` to cooperatively shut down every still-registered session before terminating the CLI child: 1. Snapshot active session IDs from the router (no lock held across the destroy RPCs). 2. For each, send `session.destroy` and unregister. 3. Take + terminate the child. 4. Collect any errors from steps 2 and 3 into a new `StopErrors` aggregate and return `Result<(), StopErrors>`. `StopErrors` is a public newtype around `Vec<Error>` implementing `std::error::Error`; `errors()` and `into_errors()` expose the underlying errors. Mirrors Node's `Error[]` shutdown return shape and closes Bucket A.6 (deferred from the prior batch since true aggregation required iterating active sessions, not just changing the signature). Implementation detail: rather than introducing a parallel `Weak<Session>` registry on the Client, this leans on the router's existing session-ID HashMap. The router already tracks every session the client has registered, so a `session_ids()` snapshot helper is the only new state. No new Arc<Session> ceremony, no Drop-impl back-pointer, no public API change to Session. This is a breaking change to `Client::stop`'s return type. README's quickstart example switches from `client.stop().await?` to `client.stop().await.ok();` (best-effort shutdown) — callers that care about per-session destroy errors can match on `StopErrors` and inspect `errors()`. Tests: - `client_stop_sends_session_destroy_for_each_active_session` — two registered sessions, both destroys observed on the wire, returns Ok. - `client_stop_aggregates_session_destroy_errors` — destroy returns a JSON-RPC error; `StopErrors` carries it and Display includes the underlying message. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Bucket B.1 SessionConfig fields Mirrors the six knobs the Node SDK exposes on `SessionConfig` / `ResumeSessionConfig` that the Rust SDK was missing: - session_id: Option<SessionId> on SessionConfig (custom session ID; remains required on ResumeSessionConfig as the existing field). - working_directory: Option<PathBuf> (per-session cwd override, independent of ClientOptions::cwd). - config_dir: Option<PathBuf> (override default config dir for this session). - model_capabilities: Option<ModelCapabilitiesOverride> (per-property capability overrides, deep-merged at session create / resume time; the type was already used by SetModelOptions). - github_token: Option<String> (per-session GitHub token, distinct from ClientOptions::github_token which authenticates the CLI process itself). Redacted from Debug output. - include_sub_agent_streaming_events: Option<bool>. Wire format: snake_case round-trips to camelCase via #[serde(rename_all = "camelCase")] except gitHubToken, which uses an explicit #[serde(rename = "gitHubToken")] to match Node's mixed-case spelling. Adds two regression tests in tests/session_test.rs verifying serde output for the wire field names + Debug-redaction of the token. CHANGELOG: documents the six new fields under "Configuration parity". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Bucket B.2 ClientOptions fields (log_level + idle timeout) Mirrors two of the five Node CopilotClientOptions knobs the Rust SDK was missing: - log_level: Option<LogLevel> with a new typed enum (None / Error / Warning / Info / Debug / All), serialized lowercase to match the CLI's --log-level argument values. Replaces the previously hardcoded --log-level info in spawn_stdio / spawn_tcp; when unset the SDK still passes "info" for parity with prior behavior. - session_idle_timeout_seconds: Option<u64>. When Some(n) with n > 0, the SDK passes --session-idle-timeout <n>. None or Some(0) leaves sessions running indefinitely (CLI default). Implementation lives in a new Client::session_idle_timeout_args helper, mirroring auth_args. Adds four lib unit tests covering the helper plus LogLevel serde round-trip. Two of the five Node knobs are intentionally not ported and are documented in the CHANGELOG as N/A: - isChildProcess: requires a parent-stdio transport variant the Rust SDK does not yet support; tracked as a future addition rather than a Bucket B item. - autoStart: does not apply to the Rust SDK's API shape — Client::start is a single explicit constructor rather than the deferred-init pattern Node uses. The remaining onListModels (BYOK callback) is tracked separately; its callback type design is non-trivial and warrants its own commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Bucket B.2 on_list_models BYOK callback override Mirrors Node's `CopilotClientOptions.onListModels`: when set, `Client::list_models` returns the handler's result without making a `models.list` RPC. This is the BYOK escape hatch for environments where the model catalog is provisioned separately from the CLI (e.g. external inference servers selected via Transport::External). API additions: - `ListModelsHandler` async_trait with a single `async fn list_models(&self) -> Result<Vec<Model>, Error>` method. Mirrors the shape of `SessionHandler` / `SessionHooks` for consistency. - `ClientOptions::on_list_models: Option<Arc<dyn ListModelsHandler>>`. - `ClientOptions` switches from `#[derive(Debug)]` to a manual Debug impl that prints the handler as `<set>` / `None`. Same precedent as `SessionConfig::handler` and the redacted `github_token` field. Plumbing: - `ClientInner` gains an `on_list_models` field carrying the handler. - `Client::from_transport` takes the handler as a new parameter; threaded through all 3 transport call sites in `Client::start` (External / Tcp / Stdio). `Client::from_streams` (no ClientOptions) passes None. - `Client::list_models` consults the handler before falling back to the RPC. Tests: - `client_options_debug_redacts_handler`: confirms manual Debug prints `<set>` for the handler and continues redacting `github_token`. - `list_models_uses_on_list_models_handler_when_set`: end-to-end override path with a counting handler. Bucket B.2 is now complete: log_level, session_idle_timeout_seconds, and on_list_models all ported. is_child_process and auto_start remain documented as N/A (transport variant Rust doesn't have / API shape mismatch). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add MessageOptions.request_headers (Phase 4 § 4.5) Mirrors Node's `MessageOptions.requestHeaders` and Go's `MessageOptions.RequestHeaders`: custom per-turn HTTP headers forwarded to the CLI on the `session.send` request, used by the CLI to inject headers into outbound model API calls for that turn. API additions: - `MessageOptions::request_headers: Option<HashMap<String, String>>` (already `#[non_exhaustive]` with builder pattern, so the new field is fully additive for existing callers). - `MessageOptions::with_request_headers(headers)` chainable builder. Wire format: - `session.send` request payload gains a `requestHeaders` object when the field is `Some(map)` and the map is non-empty. - Omitted entirely when `None` or empty — same `omitempty` semantics as Node's optional field (per Node types.ts:1521-1524). Tests: - `send_serializes_request_headers` — multi-header case verifies field name (camelCase `requestHeaders`) and value pass-through. - `send_omits_request_headers_when_unset_or_empty` — covers both the unset (None) and empty-map cases, ensuring neither sends `"requestHeaders": {}` on the wire. Bucket scope: this is the smallest of Phase 4's four 1.0-blocking gaps. 4.1 (slash commands) and 4.2 (SessionFsProvider, ADR-worthy) remain. 4.3 / 4.4 are pending team review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add slash command registration (Phase 4 § 4.1) Introduces the consumer-facing surface for `/cmdname` slash commands, matching Node's `SessionConfig.commands`, Python's, and Go's: - `CommandHandler` async trait — single `on_command(ctx)` async method. Mirrors the SDK's existing `SessionHandler` / `ToolHandler` pattern (named trait, navigable in stack traces) over a boxed-closure shape. - `CommandDefinition { name, description?, handler }` — `#[non_exhaustive]` with `new` + `with_description` builders. Manual `Serialize` emits only `{name, description?}` on the wire; handlers stay client-side. Manual `Debug` prints the handler as `<set>` (same precedent as `SessionConfig::handler`). - `CommandContext { session_id, command, command_name, args }` — `#[non_exhaustive]`, what the handler receives. - `SessionConfig::commands` and `ResumeSessionConfig::commands` — `Option<Vec<CommandDefinition>>` with `with_commands` builders. Field is `skip_deserializing` since `CommandDefinition` carries an opaque handler. Wire-up: - `Client::create_session` / `Client::resume_session` drain `config.commands` into an `Arc<HashMap<String, Arc<dyn CommandHandler>>>` via a new `build_command_handler_map` helper, threaded through `spawn_event_loop` to `handle_notification`. - New `SessionEventType::CommandExecute` arm dispatches incoming `command.execute` notifications: looks up the handler by `commandName`, invokes it on a spawned task, then acks via `session.commands.handlePendingCommand` — no error on success, `error: <handler message>` on `Err`, and `error: "Unknown command: <name>"` when the name is unregistered (matches Node's behavior verbatim). Tests (4 new in `rust/tests/session_test.rs`, all green): - `create_serializes_commands_strips_handler` — wire payload contains only `{name, description?}`, no `handler` key. - `command_execute_dispatches_to_registered_handler_and_acks_success` — handler invoked with right `CommandContext`, ack has no error. - `command_execute_unknown_command_acks_with_error` — unregistered command name produces `Unknown command: <name>` ack. - `command_execute_handler_error_propagates_to_ack` — handler `Err` is surfaced verbatim in the ack's `error` field. Schema-side types (`CommandExecuteData`, `SessionRpcCommands::handle_pending_command`, `RPC_METHOD_*`) were already in `rust/src/generated/` — no codegen changes needed. Cross-repo impact (additive): github-app's `SessionConfig { ... }` literal at `core.rs:710` will need `commands: None,` added at sync time, same mechanical pattern as Bucket B.1 fields. CHANGELOG entry added under "Configuration parity". Gates: - cargo +nightly-2026-04-14 fmt --check ✅ - cargo clippy --all-features --all-targets -- -D warnings ✅ - cargo test --all-features ✅ (84 lib + 5 + 3 + 68 + 17 = 177 tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add ADR 0001: SessionFsProvider trait and plumbing (Phase 4 § 4.2) Drafts the architecture decision record for the last 1.0 parity gap — SessionFsProvider — before any implementation lands. Per the global "ADRs for major decisions" convention and tclem's explicit guidance on §4.2. Establishes `rust/docs/adr/` as the home for Rust SDK ADRs (this is the first one) with a short index in `rust/docs/adr/README.md`. ADR scope: - **Methodology** — explicit verify-before-drafting audit, citing the consistent wins from A.2 / A.6 / 4.5 / 4.1. Captures the cross-SDK source map (Node + Python + Go + generated Rust types) so reviewers can verify the proposed shape against actual upstream code. - **Trait shape** — async_trait with 10 methods mirroring Node's provider, returning `Result<T, FsError>`. Sync alternative and trait-erased boxed-closure alternative both rejected with rationale. - **Method signatures** — Rust-idiomatic `Result<T, FsError>` with the SDK adapting to the schema's `{ ..., error: Option<SessionFsError> }` payload. `FsError::NotFound` → `ENOENT`, `FsError::Other` → `UNKNOWN`. `From<io::Error>` provided so `tokio::fs`-backed handlers can `?`. - **Concurrency model** — concurrent dispatch (each `sessionFs.*` request on its own spawned task), `Send + Sync` providers must be re-entrant. Per-session sequential dispatch rejected with rationale (CLI parallelism assumption). - **Plumbing** — direct `Arc<dyn SessionFsProvider>` registration on SessionConfig, NOT a factory closure like Node/Python/Go. Sidesteps the "lambdas as fn args" rule, idiomatic Rust, callers can carry session-id refs themselves. Trade-off documented in "Differences from other SDKs" call-out planned for the README. Future factory-closure form can land additively if needed. - **Inbound dispatch** — new arms in `handle_request` for all 10 `sessionFs.*` methods, dispatched through a per-session `Arc<HashMap<SessionId, Arc<dyn SessionFsProvider>>>` map mirroring the §4.1 `command_handlers` shape. - **Naming + module layout** — `rust/src/session_fs.rs` for public surface, `rust/src/session_fs_dispatch.rs` (`pub(crate)`) for the request adapters. `SessionFsConventions { Posix, Windows }` is hand-authored to avoid the generated enum's catch-all `Unknown` variant on the consumer-input side. - **Forward compat** — `#[non_exhaustive]` on `SessionFsConfig`, `FsError`, `FileInfo`, `DirEntry`, `DirEntryKind`. Future trait methods land with default impls returning `Err(FsError::Other(...))` so existing impls continue to compile. - **Tauri-app non-impact** — audited; `src-tauri/` does not register a virtual filesystem provider today. `ClientOptions { ... }` literal in `cli.rs` will need `session_fs: None,` added at sync time, same mechanical pattern as Bucket B.2. - **Implementation order** — 11-step ordered plan, from trait authoring through CHANGELOG entry, so the actual implementation commit can follow ADR approval without sequencing surprises. This commit ships the ADR only — no implementation code, no public API changes, no test changes. The ADR is `Status: Proposed` until tclem and the Rust SDK working group sign off. ASCII-only diagrams per repo style. No external markdown rendering dependencies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * rust: implement SessionFsProvider (Phase 4 § 4.2) Adds a virtualizable filesystem provider that lets host applications sandbox sessions, project files into in-memory or remote storage, and apply permission policies before bytes move. Last 1.0-blocking parity gap before public release. Public API additions (in `crate::session_fs`, re-exported from `crate::types`): - `SessionFsProvider` async trait with 10 methods (`read_file`, `write_file`, `append_file`, `exists`, `stat`, `mkdir`, `readdir`, `readdir_with_types`, `rm`, `rename`). All methods have default impls returning `Err(FsError::Other("not supported"))` so providers only override what they need and forward-compatible schema additions land without breaking implementors. - `SessionFsConfig` (initial_cwd, session_state_path, conventions), `SessionFsConventions` (Posix/Windows), `FsError` (NotFound/Other), `FileInfo`, `DirEntry`, `DirEntryKind`. All `#[non_exhaustive]`. - `ClientOptions::session_fs: Option<SessionFsConfig>` — when set, `Client::start` calls `sessionFs.setProvider` after protocol-version verification. - `SessionConfig::with_session_fs_provider` / `ResumeSessionConfig::with_session_fs_provider` builders for registering an `Arc<dyn SessionFsProvider>` per session. - `From<std::io::Error>` on `FsError` (NotFound→NotFound, anything else→Other) so handlers backed by `std::fs` / `tokio::fs` can use `?`. Wire dispatch: - `pub(crate) crate::session_fs_dispatch` module bridges the trait to the schema. `FsError::NotFound` maps to wire `ENOENT`; all other errors map to `UNKNOWN` with the message preserved for diagnostics. - The session event loop forwards inbound `sessionFs.*` requests to the dispatch module; non-fs methods continue to the existing `unknown method` error path. - New `SessionError::SessionFsProviderRequired` and `SessionError::InvalidSessionFsConfig` variants surface configuration errors at `Client::start` / `create_session` time. Divergence from Node/Python/Go: - This SDK accepts `Arc<dyn SessionFsProvider>` directly, rather than a factory closure that builds a provider per session. There is no `Session` value to thread into a factory at config time, and the SDK already prefers traits over boxed closures for handler-shaped APIs (`SessionHandler`, `SessionHooks`, `ToolHandler`). See `rust/docs/adr/0001-session-fs-provider.md` for the rejected-factory rationale and a forward-compat escape hatch (`with_session_fs_provider_factory`) that can be added additively post-1.0 if a real factory use case emerges. ADR status flipped from Proposed to Accepted in this commit. Tests: - 7 mock-server tests in `rust/tests/session_test.rs` covering read_file dispatch, NotFound→ENOENT mapping, Other→UNKNOWN mapping, write_file with mode, readdir_with_types, rm with force, and validation rejecting empty `initial_cwd`. - Inline unit tests in `session_fs.rs` for the io::Error→FsError conversion. Docs / examples: - `rust/examples/session_fs.rs` — in-memory provider example. - `rust/CHANGELOG.md` — entry under "Configuration parity". - `rust/README.md` — new "Differences From Other SDKs" section linking to the ADR. Verified: - `cargo +nightly-2026-04-14 fmt --check` - `cargo clippy --all-features --all-targets -- -D warnings` - `cargo test --all-features` (190 tests pass; 3 ignored require CLI) Cross-repo impact (additive only): - `src-tauri/src/session/cli.rs:337` `ClientOptions { ... }` literal — adds `session_fs: None`. - `src-tauri/src/session/core.rs:710` `SessionConfig { ... }` literal — adds `session_fs_provider: None`. - `src-tauri/src/session/core.rs:749` `ResumeSessionConfig { ... }` literal — adds `session_fs_provider: None`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add W3C Trace Context propagation (Phase 4 § 4.3) Implements the cross-SDK 1.0-blocking `traceparent` / `tracestate` plumbing across the three RPC injection points and the inbound tool-invocation read path. Hybrid shape combines Node's callback ergonomics with Go's per-turn override: - New `TraceContext` struct (`#[non_exhaustive]`) and `TraceContextProvider` async trait in `crate::trace_context`, re-exported from `crate::types`. - `ClientOptions::on_get_trace_context: Option<Arc<dyn TraceContextProvider>>` supplies an ambient provider invoked on `session.create`, `session.resume`, and `session.send`. Manual `Debug` impl prints `<set>` / `None` matching the `on_list_models` precedent. - `MessageOptions` gains `traceparent` / `tracestate: Option<String>` plus three builders — `with_trace_context`, `with_traceparent`, `with_tracestate`. Per-turn values override the callback (provider is not invoked when MessageOptions already carries trace headers). - `ToolInvocation` flipped to `#[non_exhaustive]` and exposes inbound `traceparent` / `tracestate` populated from `external_tool.requested` events, plus a `trace_context()` helper. Wire fields are omitted when unset, matching the schema's `omitempty` semantics. - New test-only constructor `Client::from_streams_with_trace_provider` (gated on `cfg(test)` / `feature = "test-support"`) so integration tests can exercise the callback path. Tests: 7 new mock-server scenarios in `tests/session_test.rs` covering provider invocation on create/resume/send, MessageOptions-overrides-callback, MessageOptions-without-callback, ToolInvocation read path, and wire-omits- when-unset. Full suite: 88 lib + 83 integration green; clippy and nightly fmt clean. No formal ADR — `ListModelsHandler` and `MessageOptions` precedents already cover both shapes. CHANGELOG entry under "Configuration parity" documents the cross-SDK rationale. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Implement Default on ToolInvocation for test ergonomics Adds `#[derive(Default)]` to `ToolInvocation` (and to `SessionId`, which the derive depends on). Pure additive: production code never constructs `ToolInvocation` literals (it's a CLI-emitted read-only type), so the "meaningless empty defaults" cost is genuinely zero in practice. The win is downstream test scaffolding. With `..Default::default()` available, test sites that build `ToolInvocation` literals to drive handler tests can collapse boilerplate and absorb future `#[non_exhaustive]` field additions automatically — no need to re-touch every test scaffold every time the schema grows. Aligns with the rust-coding-skill's "use `..Default::default()` in tests to reduce boilerplate when adding fields" guidance, and mirrors `MessageOptions` which already derives `Default`. `SessionId` is a transparent newtype around `String`, so its `Default` is `SessionId(String::new())` — already the natural identity for an unset session ID. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add TelemetryConfig env-var passthrough on ClientOptions (Phase 4 § 4.4) Closes the last 1.0-blocking gap in Phase 4 of the Rust SDK release prep. Adds opt-in OpenTelemetry configuration mirroring Node/Python/Go. ## Surface - `TelemetryConfig` (`#[non_exhaustive]`, `Debug + Clone + Default`) with five `Option`-typed fields: `otlp_endpoint`, `file_path`, `exporter_type`, `source_name`, `capture_content`. No `Serialize` / `Deserialize` derives — this type is not wire-serialized; it's pure spawn-time env-var injection. - `OtelExporterType` enum (`#[non_exhaustive]`, `Serialize`/`Deserialize` with `rename_all = "kebab-case"`): `OtlpHttp`, `File`. Typed rather than `Option<String>` to match the `LogLevel` precedent (B.2) for finite, enumerated CLI knobs. - `ClientOptions::telemetry: Option<TelemetryConfig>`, defaulting to `None`. Field added to the manual `Debug` impl. ## Wire behavior When `ClientOptions::telemetry` is `Some(...)`, `Client::build_command` sets `COPILOT_OTEL_ENABLED=true` plus, for each populated field: - `otlp_endpoint` -> `OTEL_EXPORTER_OTLP_ENDPOINT` - `file_path` -> `COPILOT_OTEL_FILE_EXPORTER_PATH` - `exporter_type` -> `COPILOT_OTEL_EXPORTER_TYPE` (`"otlp-http"` / `"file"`) - `source_name` -> `COPILOT_OTEL_SOURCE_NAME` - `capture_content` -> `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` (`"true"` / `"false"`) Env-var names are byte-for-byte identical to the Node/Python/Go SDKs. `capture_content` serializes as lowercase `"true"`/`"false"` matching Node's `String(t.captureContent)` and Python's `str(...).lower()`. ## Env-var precedence `build_command` env order: auth token -> telemetry -> `options.env` (user override) -> `options.env_remove`. User-supplied env always wins over both auth and telemetry, by ordering. Tests cover this. ## Design notes - Pure env-var passthrough: zero new dependencies. The CLI itself owns the OpenTelemetry exporter; the SDK is just a config conduit. All three reference SDKs (Node, Python, Go) work this way — verified before drafting (verify-before-drafting tally now at 8 wins). - No ADR. Plan-doc explicitly endorsed skipping the ADR since this isn't an architectural decision: the shape is fully determined by cross-SDK precedent. ADR directory is reserved for non-obvious choices; precedent verification belongs in CHANGELOG + commit body. - `#[non_exhaustive]` on both `TelemetryConfig` and `OtelExporterType` to absorb future CLI-side telemetry knobs (sampling, additional exporters) without breaking changes. - `PathBuf` (not `String`) for `file_path` for type safety; `Command::env` accepts `AsRef<OsStr>` so no conversion needed. ## Tests Five new tests in `lib.rs`: - `build_command_sets_otel_env_when_telemetry_enabled` — full TelemetryConfig set; asserts all six expected env vars propagate with correct values. - `build_command_omits_otel_env_when_telemetry_none` — default `ClientOptions`; asserts none of the six env vars are present. - `build_command_omits_unset_telemetry_fields` — only `otlp_endpoint` set; asserts that single field plus the implicit enabled flag are set, and the four others are absent. - `build_command_lets_user_env_override_telemetry` — telemetry sets `OTEL_EXPORTER_OTLP_ENDPOINT="http://from-config:..."`, user env sets it to `"http://from-user-env:..."`; asserts user env wins. - `telemetry_config_capture_content_serializes_as_lowercase_bool` — asserts both `Some(true)` and `Some(false)` serialize as the bare lowercase boolean strings. All tests pass; `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test --all-features` all green. ## Cross-repo impact Existing `ClientOptions { ... }` literal in github-app `cli.rs:337` gains one new field: `telemetry: None`. No other consumer-side changes. The Tauri app's existing telemetry pipeline is for the host app and doesn't interact with the SDK config — this is purely about forwarding telemetry env vars to the spawned CLI child. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Document Rust-only API surface (Phase 4 § 4.7) Doc-only follow-up to the Phase 4 parity work. Two pieces of content, no API changes. ## Changes 1. **`Client::get_quota` rustdoc** — flags the method as Rust-only as of 0.1.0. The underlying `account.getQuota` JSON-RPC endpoint is exposed only by the Rust SDK in this release; Node, Python, Go, and .NET don't surface it. Brief, factual, no backport-issue links. 2. **"Rust-only API" subsection in `rust/README.md`** — restructures the existing "Differences From Other SDKs" section into two subsections: "Shape divergence" (the existing `SessionFsProvider` factory-vs-direct discussion) and a new "Rust-only API" listing the surface that exists only on the Rust SDK as of 0.1.0: - `Client::get_quota` - First-class `Session` convenience methods (`set_mode`/`get_mode`, `set_name`/`get_name`, `read_plan`/`update_plan`/`delete_plan`, `start_fleet`, `list_workspace_files`/`read_workspace_file`/ `create_workspace_file`) - Typed newtypes (`SessionId`, `RequestId`) - Permission policy builders (`permission::approve_all`, `permission::deny_all`, `permission::approve_if`) - `Client::from_streams` (arbitrary `AsyncRead`/`AsyncWrite`) - `enum Transport { Stdio, Tcp, External }` - Split `prefix_args` / `extra_args` Tone is "Rust gets to be Rust" — not apologetic, no backport promises. Cross-SDK parity for these is explicitly framed as a post-release conversation, not a release blocker. ## Bonus: drive-by intra-doc link fix Fixed a pre-existing broken intra-doc link in `session_fs.rs` (`[`tokio::fs`]` -> explicit URL link). Caught by `cargo doc --no-deps --all-features`, which now builds clean. Doing it here rather than as a separate commit since the testing-pass that comes next is going to require clean docs output anyway, and the fix is one line. `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test --doc`, and `cargo doc --no-deps --all-features` all green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Broaden skills discovery wording in copilot-instructions.md Skills under .github/skills/ are auto-discovered by Copilot tooling broadly (CLI, Copilot Coding Agent, etc.), not just Copilot CLI. Per stephentoub's review on PR #1164. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix ConnectionState::Errored wire form to match Go ("error" not "errored") Caught by stephentoub on PR #1164. ConnectionState's serde `rename_all = "lowercase"` was producing "errored" for the Errored variant, but Go's source-of-truth uses "error". Add explicit `#[serde(rename = "error")]` on the variant so the wire form matches Go's ConnectionState string. Variant name stays `Errored` to avoid shadowing the std `Error` trait and the crate's own `Error` type. Adds two unit tests locking in the wire format for all four variants. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Rename ConnectionState::Errored to ConnectionState::Error Per follow-up on stephentoub's review of PR #1164: prefer renaming the variant to drop the `#[serde(rename = "error")]` attribute. The variant is unused outside types.rs (the Client transitions Disconnected / Connecting / Connected today; Error is reserved for future use), so renaming has no consumer impact and produces a cleaner enum surface. `ConnectionState::Error` does not collide with anything in scope: `types.rs` does not import `crate::Error` (it uses fully-qualified `crate::Error` at use sites) and `std::error::Error` is unimported. The variant lives inside a typed enum, so no shadowing concern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR #1164 cross-SDK consistency review Three review-feedback fixes folded into a single commit: - `session.ui()` sub-API: move `elicitation`, `confirm`, `select`, `input` onto a new `SessionUi<'a>` view returned by `Session::ui()`. Mirrors .NET `session.UI`, Python `session.ui`, Go `session.UI()`. Wire-method names unchanged. Per stephentoub on https://github.com/github/copilot-sdk/pull/1164#discussion_r3161986035 and the bot duplicate at #discussion_r3163241529. - Typed `Client::get_status` and `Client::get_auth_status` returns: introduce `GetStatusResponse { version, protocol_version }` and `GetAuthStatusResponse { is_authenticated, auth_type, host, login, status_message }`, both `#[non_exhaustive]`. Matches Node / Go / Python typed shapes. Per the bot at https://github.com/github/copilot-sdk/pull/1164#discussion_r3162629019. - `Session::set_model` now returns `Result<(), Error>` instead of `Result<Option<String>, Error>`. Mirrors Node / Python / Go / .NET void-on-success semantics. Per the bot at https://github.com/github/copilot-sdk/pull/1164#discussion_r3162816817. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Type MessageOptions::mode as DeliveryMode enum Promote MessageOptions::mode from Option<String> to Option<DeliveryMode>, where DeliveryMode is a #[non_exhaustive] enum with Enqueue (default) and Immediate variants serializing to "enqueue" / "immediate". Mirrors Node types.ts:1519 (mode?: "enqueue" | "immediate") and Go types.go:858 (MessageOptions.Mode is the message delivery mode (default: "enqueue")). The prior rustdoc on MessageOptions::mode incorrectly described the field as a permission mode. The field has always controlled message delivery relative to in-flight session work; the doc lied. Strings other than "enqueue" / "immediate" were silently no-ops at the CLI, so the typed enum turns wrong-string-at-call-site from a runtime nothing-happens into a compile error. Precedent for typed enums on enumerated CLI knobs: B.2 LogLevel (c4132c2), 4.4 OtelExporterType (aefb108), SessionFsConventions, and DirEntryKind. The asymmetric request_headers (4.5) stayed HashMap<String, String> because that value space is open; mode is finite and CLI-controlled. with_mode signature shrinks from impl Into<String> to DeliveryMode and the wire injection in session.rs uses serde_json::to_value(m). Added a roundtrip test in types.rs covering both variants. CHANGELOG entry under Configuration parity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Default permission-flow flags to Some(true) SessionConfig::default() and ResumeSessionConfig::new() now set the four permission-flow flags to Some(true): - request_user_input - request_permission - request_exit_plan_mode - request_elicitation Mirrors Node's client.ts:749-751 which always sets requestPermission: true and derives requestUserInput/requestElicitation from handler presence. In Rust, SessionHandler is trait-based so a handler is always installed (DenyAllHandler is the default) — opt-in defaults of None meant a Rust caller could install a SessionHandler and forget to flip the flags, silently breaking permission flow vs. the equivalent Node code. The default DenyAllHandler refuses all permission requests so the wire surface is safe out-of-the-box. Callers that want the wire surface fully disabled set the flags explicitly to Some(false). SessionConfig drops its derived Default in favor of a manual impl that enumerates every field. ResumeSessionConfig::new() switches the four field assignments from None to Some(true). Field rustdoc updated on each of the four fields to call out the default. Two unit tests added in types.rs covering both constructors. CHANGELOG entry under Configuration parity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Mark remaining public config types non_exhaustive Adds #[non_exhaustive] to the 10 remaining public configuration types that didn't already carry the attribute: - SessionConfig - ResumeSessionConfig - ClientOptions - ProviderConfig - McpServerConfig - Tool - CustomAgentConfig - InfiniteSessionConfig - SystemMessageConfig - ConnectionState HookEvent, HookOutput, MessageOptions, TelemetryConfig, SessionFsConfig, FsError, FileInfo, DirEntry, ToolInvocation, Error, Transport, and the new DeliveryMode were already marked. Closing the asymmetry now means adding fields to any of these post-1.0 is non-breaking on consumers that construct via Default::default() plus field assignment or the with_* builders. Tradeoff: external crates can no longer use struct-literal syntax for these types -- not even with ..Default::default(), which only works inside the defining crate. Tests, examples, and the tool_parameters doctest are migrated to the let-mut + field-assignment pattern. Callers porting from 0.1.0-* will see the same compile error and apply the same mechanical transform. CHANGELOG entry under Configuration parity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix InputOptions doc-link to SessionUi::input The UI-grouping commit f4aa8d9 moved input/select/confirm/elicitation off Session into SessionUi, but the rustdoc on InputOptions still referenced the old crate::session::Session::input path. Cargo doc with -D rustdoc::broken_intra_doc_links rejected the link. Repoints the link to crate::session::SessionUi::input. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Drop cross-SDK comparisons from Rust source comments Per-symbol "Mirrors Node's Foo / Go's Bar / Python's Baz" rustdoc is unscalable and drifts as the other SDKs evolve. The Rust SDK seeks parity with Node/Python/Go/.NET; that fact is now stated once at the top of rust/README.md, and intentional divergences live in the README's "Differences From Other SDKs" section. - Strip "Mirrors X" / "Unlike Y" / cross-SDK file:line citations from rustdoc across lib.rs, session.rs, types.rs, session_fs.rs, trace_context.rs. Replace with API-shape descriptions where the dropped text carried real information (e.g. wire-string variants). - README: add a one-line parity statement up top that points to the existing Differences section. - Update .github/skills/rust-coding-skill/SKILL.md to forbid cross-SDK references in code comments and rustdoc going forward, with explicit guidance that intra-SDK self-references ("Mirrors `from_streams`") are still fine. cargo doc -D warnings clean. cargo clippy clean. cargo fmt clean. 18 doctests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Move SessionFs ADR out of public crate ADR 0001 (SessionFsProvider trait and plumbing) captured the design rationale for choosing direct Arc<dyn SessionFsProvider> registration over the factory-closure pattern that Node/Python/Go use. That rationale is internal release-engineering history, not consumer-facing API documentation; the README's "Differences From Other SDKs" section already explains what consumers need to know about the divergence. The full ADR is being relocated to the private SDK release-plan documentation in github/github-app PR #3166 (docs/copilot/2026-04-14-sdk-release/). - Delete rust/docs/ entirely (only contained ADR 0001 + index README). - Strip ADR links from rustdoc, README, CHANGELOG, and the session_fs example. The README's existing inline rationale (factory pattern doesn't cleanly express in Rust at the session-config call site, no `Session` value to thread in, SDK already prefers traits over closures for handler-shaped APIs) is sufficient on its own. cargo doc -D warnings clean. cargo clippy clean. cargo fmt clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix SessionUi::elicitation wire field name The hand-authored `SessionUi::elicitation` convenience layer was sending the JSON Schema payload as `"schema"` on the wire, but the `session.ui.elicitation` request shape expects `"requestedSchema"`. This is the field name in: - this crate's own generated UIElicitationRequest type (rust/src/generated/api_types.rs:1721, serde renames to `requestedSchema` via #[serde(rename_all = "camelCase")]) - the generated typed RPC wrapper SessionRpcUi::elicitation (rust/src/generated/rpc.rs:1245-1257), which is correct - and the same wire field used by every other SDK we ship So every elicitation call from the SessionUi convenience layer was effectively dead — the CLI saw a missing required `requestedSchema` field. The `confirm` / `select` / `input` helpers all delegate to `elicitation`, so they were dead too. The mock-server test for elicitation round-tripped through the same misnamed field on both ends, so the bug slipped past unit tests (`assert_eq!(request["params"]["schema"], schema)` matched the buggy implementation). The fix is a one-line rename in session.rs plus a test update that now asserts on `requestedSchema` and explicitly rejects a stray `schema` key, so we can't regress the same way. 207 tests pass. doc / clippy / fmt clean. Caught by the gap-analysis re-run before 0.1.0 cut. Wire-shape divergence between hand-authored and generated layers — same class of bug as the workspace RPC fix earlier in this stack. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add typed on_auto_mode_switch handler for rate-limit recovery Wires the CLI's `autoModeSwitch.request` JSON-RPC callback (added by copilot-agent-runtime PR #7024 — github/copilot-agent-runtime#7024) into the SDK's typed handler surface. When an eligible rate limit is hit, the runtime asks the SDK whether to switch the session to auto model; this commit gives consumers a typed entry point for that prompt matching the shape of the existing exit-plan-mode / user-input / elicitation handshakes. Background: the github-app's vendored copilot-sdk crate has carried a private version of this handler since Apr 27 (github-app commit 02ec73588). The plumbing was never upstreamed because no other SDK had a typed handler for it, and the wire-protocol gap on the runtime side hadn't yet closed. With #7024 merged, the wire path is well-defined (`autoModeSwitch.request` → `{ response: "yes" | "yes_always" | "no" }`) and the typed handler can ship. Cross-SDK divergence: typed handler exists only in the Rust SDK as of 0.1.0. Node, Python, Go, and .NET observe the request as a raw JSON-RPC callback today; parity ports for those SDKs are post-release follow-up work and are noted in the README's "Rust-only API" section and in the changelog. Wire shape and types: - `handler::AutoModeSwitchResponse` — typed enum (`Yes`, `YesAlways`, `No`) with `#[serde(rename_all = "snake_case")]`. Wire values are byte-identical to the runtime's schema. `#[non_exhaustive]` so future variants are additive. Ships as a typed enum rather than the `{ response: String }` shape used in the github-app vendored copy — consistent with the recently-landed `DeliveryMode` enum and the general convention that finite, enumerated wire values get typed at the API surface (LogLevel, OtelExporterType, ConnectionState, PermissionRequestKind, etc.). - `handler::HandlerEvent::AutoModeSwitch { session_id, error_code, retry_after_seconds }` — new event variant. `error_code` is the rate-limit kind (e.g. `user_weekly_rate_limited`). `retry_after_seconds` is the RFC 9110 `Retry-After` `delta-seconds` value when the runtime knows it; consumers can surface a humanized reset time alongside the prompt. - `handler::HandlerResponse::AutoModeSwitch(AutoModeSwitchResponse)` — new response variant. - `SessionHandler::on_auto_mode_switch` — new trait method with safe default (`No`). Default `on_event` dispatches to it. - `types::SessionConfig::request_auto_mode_switch: Option<bool>` and `types::ResumeSessionConfig::request_auto_mode_switch: Option<bool>` — new opt-in flags, both default to `Some(true)` via `SessionConfig::default()` and `ResumeSessionConfig::new()` to match the convention already established by the other four `request_*` flags. Without the flag the runtime doesn't dispatch the callback. - `session.rs` `handle_request` — new arm for `autoModeSwitch.request` alongside the existing `permission.request` / `userInput.request` / `exitPlanMode.request` cases. Extracts `errorCode` / `retryAfterSeconds` from params, dispatches via `handler.on_event(HandlerEvent::AutoModeSwitch { ... })`, serializes the typed response back as `{ "response": "yes" | "yes_always" | "no" }`. Falls through to `No` if the handler returns an unexpected response variant. Tests: - `auto_mode_switch_dispatches_to_handler_and_serializes_response` — asserts the inbound `autoModeSwitch.request` reaches the typed handler with the expected `error_code` / `retry_after_seconds`, and that returning `AutoModeSwitchResponse::YesAlways` serializes as `{ "response": "yes_always" }` on the wire. - `auto_mode_switch_default_handler_replies_no` — asserts the trait's default impl replies `{ "response": "no" }` when no override is provided. - Updated `session_config_default_enables_permission_flow_flags` and `resume_session_config_new_enables_permission_flow_flags` to assert the new flag is `Some(true)` by default. Documentation: - README: new bullet under "Rust-only API" section. - CHANGELOG: new entry under `### Added > Handlers + helpers` documenting the wire path, the typed enum, and the cross-SDK divergence with a note that Node / Python / Go / .NET parity is post-release work. - `request_auto_mode_switch` field rustdoc cross-references `SessionHandler::on_auto_mode_switch`. Validation: - 209 tests pass (was 207 — 2 new auto-mode-switch tests). - `cargo doc -D warnings` clean. - `cargo +nightly-2026-04-14 fmt --check` clean. - `cargo clippy --all-features --all-targets -- -D warnings` clean. Migration note for the github-app sync session: the typed `AutoModeSwitchResponse` enum replaces github-app's vendored `AutoModeSwitchResponse { response: String }`. github-app's WebSocket relay continues to receive the user's choice as a `String`; map to the typed enum at the boundary (`match s.as_str() { "yes" => Yes, "yes_always" => YesAlways, _ => No }`). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Client::list_sessions wire shape — wrap filter under params.filter The hand-authored `Client::list_sessions` was serializing the optional `SessionListFilter` directly onto the JSON-RPC `params` object, flattening fields like `repository` / `branch` / `cwd` / `gitRoot` to the top level. The `session.list` request shape that the runtime accepts puts the filter under `params.filter` — and that's what every other SDK sends: - Node `nodejs/src/client.ts:1178-1180`: `sendRequest("session.list", { filter })` - Go `go/types.go`: `listSessionsRequest { Filter *SessionListFilter }` - Python `python/copilot/client.py:1907-1911`: `payload["filter"] = ...` - .NET `dotnet/src/Client.cs`: `record ListSessionsRequest(SessionListFilter? Filter)` Because the runtime silently ignores unknown top-level keys on `session.list`, calling `list_sessions(Some(filter))` was functionally equivalent to `list_sessions(None)` in 0.0.x — every filter field was discarded by the runtime, returning an unfiltered session list. No runtime error, no log, just silently broken. Functionally dead on the wire, same class as the elicitation `requestedSchema` fix in `c58e2f2`. The mock-server test `list_sessions_serializes_typed_filter` asserted on the flat shape it observed (`request["params"]["repository"]`) rather than the schema's wrapped shape, so the bug round-tripped through both ends — the implementation produced the wrong shape, the test verified the wrong shape. Same root cause as the elicitation test gap. Fix: - `Client::list_sessions` now wraps the filter: `Some(f) -> serde_json::json!({ "filter": f })`, `None -> serde_json::json!({})`. `None` omits the filter key entirely (matches Go's `omitempty` behavior; Node's `{ filter: undefined }` also omits via JSON-stringify). - Mock-server test now asserts on the wrapped path (`params.filter.repository`, `params.filter.branch`) AND explicitly asserts the flattened fallback is gone (`params.get("repository")` must return `None`). Same regression-prevention pattern as the elicitation fix at `session_test.rs:1248-1251`. - CHANGELOG entry under `### Fixed` documenting the wire-shape fix and the test gap that masked it. Validation: - 209 tests pass (no count change — same test, stricter assertions). - `cargo doc -D warnings` clean. - `cargo +nightly-2026-04-14 fmt --check` clean. - `cargo clippy --all-features --all-targets -- -D warnings` clean. Caught by the gap-analysis structural-correctness pass walking every hand-authored `client.call("...")` site against the schema and the four other SDKs. This is the second wire-shape bug found by that pass; the first was the `SessionUi::elicitation` `schema` -> `requestedSchema` fix in `c58e2f2`. The audit confirms `session.list` is the only other new bug — three Rust-unique surfaces (`session.respondToUserInput`, `session.sendTelemetry`, top-level `sendTelemetry` / `server.sendTelemetry`) are uncheckable cross-SDK and queued as post-0.1.0 runtime-acceptance verification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump @github/copilot pin to ^1.0.39 + regen Rust types Per the auto-mode-switch CLI hunt session's verification, the `@github/copilot@1.0.39-0` pin we were on does NOT contain copilot-agent-runtime PR #7024 (auto-mode-switch wire support). The runtime PR merged after `1.0.39-0` was cut as a release-candidate and shipped in `1.0.39` final, with `1.0.40-0` after. This commit: - Bumps `nodejs/package.json` from `^1.0.39-0` to `^1.0.39` (final). `npm install` resolved to `1.0.39`. The package-lock.json is regenerated accordingly. - Regenerates Rust t…
1 parent 58cf64d commit 19b4ad5

118 files changed

Lines changed: 33780 additions & 9 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/copilot-instructions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848

4949
## Where to add new code or tests 🧭
5050

51-
- SDK code: `nodejs/src`, `python/copilot`, `go`, `dotnet/src`
52-
- Unit tests: `nodejs/test`, `python/*`, `go/*`, `dotnet/test`
51+
- SDK code: `nodejs/src`, `python/copilot`, `go`, `dotnet/src`, `rust/src`
52+
- Unit tests: `nodejs/test`, `python/*`, `go/*`, `dotnet/test`, `rust/tests`
5353
- E2E tests: `*/e2e/` folders that use the shared replay proxy and `test/snapshots/`
5454
- Generated types: update schema in `@github/copilot` then run `cd nodejs && npm run generate:session-types` and commit generated files in `src/generated` or language generated location.
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
---
2+
name: rust-coding-skill
3+
description: "Use this skill whenever editing `*.rs` files in the `rust/` SDK in order to write idiomatic, efficient, well-structured Rust code"
4+
---
5+
6+
# Rust Coding Skill
7+
8+
Opinionated Rust rules for the Copilot Rust SDK (`rust/`). Priority order:
9+
10+
1. **Readable code** — every line should earn its place
11+
2. **Correct code** — especially in concurrent/async contexts
12+
3. **Performant code** — think about allocations, data structures, hot paths
13+
14+
## Error handling
15+
16+
The SDK's public error type is `crate::Error` (`rust/src/error.rs`). Add new
17+
variants there rather than introducing parallel error enums per module — every
18+
public failure mode is part of the API contract and should be expressible in one
19+
type. Internal modules can use `thiserror` enums when a richer local taxonomy
20+
helps; convert at the boundary.
21+
22+
`anyhow` is reserved for binaries and example code. Library code never returns
23+
`anyhow::Result` — callers can't pattern-match on `anyhow::Error`, so it would
24+
prevent them from handling specific failures.
25+
26+
In production code, prefer `?`, `let-else`, and `if let`. Reach for `expect("…")`
27+
when an invariant cannot fail and the message would help debug a future
28+
regression. `unwrap()` belongs in tests only — Clippy enforces this in the SDK
29+
via `#![cfg_attr(test, allow(clippy::unwrap_used))]` in `lib.rs`.
30+
31+
When you need to log on the way through, prefer
32+
`.inspect_err(|e| warn!(error = ?e, "context"))?` over a `match` that logs and
33+
re-wraps. It reads top-to-bottom and keeps the happy path uncluttered.
34+
35+
## Async and concurrency
36+
37+
The default for request-scoped I/O is `async fn` plus `.await` — futures
38+
inherit cancellation from their parent task and can borrow local references.
39+
Reach for `tokio::spawn` only when you genuinely need background work (an event
40+
loop, a long-lived watcher) and track the `JoinHandle` so you can cancel or join
41+
it on shutdown. Fire-and-forget spawns silently swallow panics and outlive the
42+
session; don't.
43+
44+
Blocking calls (filesystem, subprocess wait) belong in
45+
`tokio::task::spawn_blocking`, *not* on the async runtime. The blocking pool is
46+
bounded, so for genuinely long-lived workers (think: file watchers that run for
47+
the lifetime of a session) prefer `std::thread::spawn` with a channel back into
48+
async land.
49+
50+
Lock choice matters. `tokio::sync::Mutex` is correct when you must hold the
51+
guard across `.await`; `parking_lot::Mutex` (or `RwLock`) is faster on hot
52+
synchronous paths and is what `session.rs` uses for capability state.
53+
`std::sync::Mutex` is rarely the right answer in this crate — its poisoning
54+
semantics buy us nothing and it's slower than `parking_lot`. Never hold a
55+
`std::sync::Mutex` guard across an `.await`; Clippy will catch this, but the
56+
fix is to move the await out, not silence the lint.
57+
58+
For lazy statics use `std::sync::LazyLock`. The `once_cell` crate is no longer
59+
needed.
60+
61+
## Traits and conversions
62+
63+
Plain functions on a type beat traits for navigability. Use them as the default.
64+
65+
**Trait-based extension points are different.** When a consumer must plug behaviour into the SDK, prefer one trait with one default-impl method per event over per-event `Box<dyn Fn>` callback fields. This is what `tower_lsp::LanguageServer`, `rmcp::ServerHandler`, and `notify::EventHandler` do — the dominant idiom in async Rust for "wire-protocol handler" traits. Callback fields fight `Send + Sync + 'static`, fragment consumer state across closures, and skip exhaustiveness checks.
66+
67+
The four extension-point traits in this crate:
68+
69+
- **`SessionHandler`** (`rust/src/handler.rs`) — per-event methods (`on_permission_request`, `on_user_input`, `on_external_tool`, `on_elicitation`, `on_exit_plan_mode`, `on_auto_mode_switch`, `on_session_event`) each with a default impl. The dispatcher `on_event(HandlerEvent)` is itself a default method that fans out to them; override per-event methods in normal use, override `on_event` only when you want a single exhaustive match. Concurrent invocations are possible (notification-triggered events run on spawned tasks), so `Send + Sync + 'static` is required on the trait.
70+
- **`SessionHooks`** (`rust/src/hooks.rs`) — optional lifecycle callbacks. The SDK auto-enables hooks when an impl is supplied to `create_session` / `resume_session`.
71+
- **`SystemMessageTransform`** (`rust/src/system_message.rs`) — declare `section_ids()` and return content from `transform_section()`.
72+
- **`ToolHandler`** (`rust/src/tool.rs`) — client-side tool implementations, dispatched by name via `ToolHandlerRouter`.
73+
74+
`ApproveAllHandler` is the standard test handler for `SessionHandler`.
75+
76+
**Don't add traits without a clear extension story.** Don't implement `From`/`Into` for SDK-internal conversions: they can't take extra parameters, can't return `Result`, and hide which conversion is happening at call sites. Prefer named methods like `to_info(&self)` or `MyType::from_record(record, ctx)`.
77+
78+
Trivial field re-shaping is best inlined. Closures should stay short (under ~10 lines); extract to named functions when they grow. Visitor patterns are a closure-fest — expose `iter()` and let the consumer drive.
79+
80+
## Concurrency primitives
81+
82+
**Channels, not callback closures, for event flow.** Closures fight `Send + Sync + 'static` and don't compose with `select!`. Channel choice by semantics:
83+
84+
| Use case | Primitive |
85+
|---|---|
86+
| One producer → one consumer with backpressure | `tokio::sync::mpsc` (cap 1) or `tokio::sync::oneshot` for single value |
87+
| Many producers → one consumer | `tokio::sync::mpsc` |
88+
| One producer → many consumers, every event delivered (pub/sub) | `tokio::sync::broadcast` |
89+
| One producer → many consumers, only the latest value matters | `tokio::sync::watch` |
90+
91+
For the **public** API, prefer returning `impl Stream<Item = Event>` (wrap a `broadcast::Receiver` in `tokio_stream::wrappers::BroadcastStream`). `Stream` composes with `select!`, `take`, `map`, `filter`, `timeout`. See `EventSubscription` and `LifecycleSubscription`.
92+
93+
**Cancellation: drop is the primitive; `tokio_util::sync::CancellationToken` for SDK-internal task coordination.**
94+
95+
- **Caller-owned futures** (`send_message`, subscription streams): drop / `select!` / `tokio::time::timeout`. Don't accept a token parameter — it duplicates what `select!` already provides. Document cancel-safety on every `.await` in the hot path.
96+
- **SDK-internal tasks** (event loops, subprocess readers, anything `tokio::spawn`ed by the SDK): use `CancellationToken` stored on the long-lived handle. `Drop` calls `cancel()`. `Session::cancellation_token()` exposes a child token so callers can bind external work to the session lifetime.
97+
98+
Refs: [`CancellationToken`][ctoken] · [`tonic` example][tonic-cancel] · [withoutboats: async clean-up][wb-cleanup] · [Cybernetist: cancellation patterns][cybernetist].
99+
100+
[ctoken]: https://docs.rs/tokio-util/latest/tokio_util/sync/struct.CancellationToken.html
101+
[tonic-cancel]: https://github.com/hyperium/tonic/blob/master/examples/src/cancellation/server.rs
102+
[wb-cleanup]: https://without.boats/blog/asynchronous-clean-up/
103+
[cybernetist]: https://cybernetist.com/2024/04/19/rust-tokio-task-cancellation-patterns/
104+
105+
## Optional fields and serde
106+
107+
Use `Option<T>` for optional fields, not nullable references or sentinel values. Defaults come from `Default` impls. Pair with `#[non_exhaustive]` on public config structs and a builder so adding fields stays non-breaking.
108+
109+
For required builder fields: prefer `build() -> Result<Self, BuildError>` over typestate unless required-field count is tiny (1-2).
110+
111+
JSON: `#[serde(rename_all = "camelCase")]` at the type level, per-field `#[serde(rename = "…")]` for outliers, `#[serde(skip_serializing_if = "Option::is_none")]` for output, `#[serde(default)]` for input tolerance. Reach for `serde_with` only for non-trivial transforms (durations, base64, numeric-as-string keys).
112+
113+
## Tracing — `#[tracing::instrument]` is banned
114+
115+
Banned via `clippy.toml`. Use manual spans with `error_span!`:
116+
117+
- **Almost always use `error_span!`**, not `info_span!`. Span level controls
118+
the *minimum* filter at which the span appears. An `info_span` disappears when
119+
the filter is `warn` or `error` — taking all child events with it, even
120+
errors. `error_span!` ensures the span is always present.
121+
- **Spawned tasks lose parent context.** Attach a span with `.instrument()` or
122+
events inside won't correlate.
123+
- **Never hold `span.enter()` guards across `.await`** — use `.instrument(span)`
124+
instead (also enforced by Clippy).
125+
126+
```rust
127+
use tracing::Instrument;
128+
129+
async fn send_message(&self, session_id: &str, prompt: &str) -> Result<(), Error> {
130+
let span = tracing::error_span!("send_message", session_id = %session_id);
131+
async { /* body */ }.instrument(span).await
132+
}
133+
134+
let span = tracing::error_span!("event_loop", session_id = %id);
135+
tokio::spawn(async move { run_loop().await }.instrument(span));
136+
```
137+
138+
Log with structured fields: `info!(session_id = %id, "Session created")`.
139+
Static messages stay greppable; dynamic data goes in named fields, not
140+
interpolated into the message string.
141+
142+
## Idioms that don't port from other languages
143+
144+
When porting from Node, Python, Go, or .NET: see the **Concurrency primitives** and **Traits and conversions** sections above. The two patterns that most reliably translate poorly are (1) per-event `Box<dyn Fn>` callback fields — use a trait with default-impl methods (the `tower_lsp::LanguageServer` / `rmcp::ServerHandler` / `notify::EventHandler` shape) — and (2) plumbing `context.Context` / `CancellationToken` through every call site — drop-cancel for caller-owned futures, `tokio_util::sync::CancellationToken` for SDK-internal tasks.
145+
146+
## Code organization
147+
148+
- **Public API:** every `pub` item in the crate is part of the SDK's contract.
149+
Adding a field to a `pub struct` is a breaking change unless the struct is
150+
`#[non_exhaustive]` or constructors hide field-by-field literals. Prefer
151+
`Default + ..Default::default()` patterns and document new fields with
152+
rustdoc.
153+
- **Generated code lives in `rust/src/generated/`** and must not be
154+
hand-edited. Regenerate with `cd scripts/codegen && npm run generate:rust`.
155+
When a generated type lacks a field the schema doesn't yet describe (e.g.
156+
`Tool::overrides_built_in_tool`), hand-author the user-facing type in
157+
`rust/src/types.rs` and stop re-exporting the generated one.
158+
- **`#[expect(dead_code)]`** instead of `#[allow(dead_code)]` on individual
159+
fields — it forces a cleanup once the field gets used.
160+
- **`..Default::default()`** — avoid in production code (be explicit about
161+
which fields you're setting); prefer it in tests and doc examples to keep
162+
the focus on the values that matter for the test.
163+
- **Import grouping** — three blocks separated by blank lines:
164+
(1) `std`/`core`/`alloc`, (2) external crates, (3)
165+
`crate::`/`super::`/`self::`. Enforced by nightly `cargo fmt` via
166+
`rust/.rustfmt.nightly.toml`.
167+
- **`pub(crate)` vs `pub`** — most modules in `lib.rs` are private (`mod`), so
168+
`pub` items inside them are already crate-private. Use `pub(crate)` only when
169+
you want to be explicit that an item must not become part of the public API.
170+
171+
## Testing
172+
173+
- **No mock testing.** Depend on real implementations, spin up lightweight
174+
versions (e.g. `MockServer` in tests), or restructure code so the logic
175+
under test takes its dependency's output as input.
176+
- `assert_eq!(actual, expected)` — actual first, for readable diffs.
177+
- Tests at end of file: `#[cfg(test)] mod tests`. Never place production code
178+
after the test module.
179+
- Keep tests concurrent-safe — unique temp dirs (`tempfile::tempdir()`),
180+
unique data, no global state.
181+
- `ApproveAllHandler` is the standard test handler for sessions that don't
182+
exercise permission logic — see `rust/src/handler.rs:174`.
183+
184+
## Cross-platform
185+
186+
The SDK ships on macOS, Windows, and Linux; CI exercises all three. Construct
187+
paths with `Path::join` rather than string concatenation — `/` and `\` are not
188+
interchangeable, and string equality breaks on Windows UNC paths. Log paths
189+
with `path.display()`; serialize with `to_string_lossy()` only when you need a
190+
`String`.
191+
192+
Process spawning needs care. The SDK applies `CREATE_NO_WINDOW` on Windows
193+
when launching the CLI (see `Client::build_command`); preserve that if you
194+
touch process spawning. Subprocess stdout often contains `\r` on Windows — strip
195+
or split on `\r?\n` rather than assuming `\n`.
196+
197+
Tests must use `tempfile::tempdir()`, never hardcoded `/tmp/`, and any test
198+
that asserts on a path string needs to normalize separators or use
199+
`std::path::MAIN_SEPARATOR`.
200+
201+
## Build speed
202+
203+
Specify Tokio features explicitly — never `features = ["full"]`. Iterate with
204+
`cargo check`; reach for `cargo build` only when you need the binary. Audit
205+
new dependency feature flags with `cargo tree` before committing.
206+
207+
## Comments
208+
209+
Explain **why**, never **what**. No comments that restate code. No decorative
210+
banners (`// ── Section ────────`).
211+
212+
**Never compare to other SDKs in code comments or rustdoc.** Don't write
213+
"Mirrors Node's `Foo`", "Like Go's `Bar`", "Unlike Python's `Baz`", or include
214+
file/line citations into other SDKs (`nodejs/src/types.ts:1592`, `go/types.go:14`).
215+
The Rust SDK seeks parity with the Node, Python, Go, and .NET SDKs, and that
216+
fact is stated once at the top of `rust/README.md`. Intentional divergences
217+
live in the README's "Differences From Other SDKs" section. Repeating the
218+
relationship per-symbol is unscalable, drifts as the other SDKs evolve, and
219+
adds noise to consumer-facing rustdoc — Rust users care about the Rust API,
220+
not its lineage. Self-references within the Rust crate (e.g. "Mirrors
221+
[`from_streams`] but adds…") are fine.
222+
223+
## Toolchain
224+
225+
The SDK is pinned to `rust 1.94.0` via `rust/rust-toolchain.toml`. Formatting
226+
uses nightly (`nightly-2026-04-14`) so unstable rustfmt options like grouped
227+
imports work — see `rust/.rustfmt.nightly.toml`. CI runs:
228+
229+
```bash
230+
cd rust
231+
cargo +nightly-2026-04-14 fmt --check
232+
cargo clippy --all-features --all-targets -- -D warnings
233+
cargo test --all-features
234+
```
235+
236+
Match those exact commands locally before pushing.
237+
238+
## Codegen
239+
240+
JSON-RPC and session-event types are generated from the Copilot CLI schema:
241+
242+
| Source | Output |
243+
|---|---|
244+
| `nodejs/node_modules/@github/copilot/schemas/api.schema.json` | `rust/src/generated/api_types.rs` |
245+
| `nodejs/node_modules/@github/copilot/schemas/session-events.schema.json` | `rust/src/generated/session_events.rs` |
246+
247+
Regenerate with:
248+
249+
```bash
250+
cd scripts/codegen && npm run generate:rust
251+
```
252+
253+
Never hand-edit files under `rust/src/generated/`. If a generated type needs a
254+
field the schema lacks, hand-author the user-facing type in `rust/src/types.rs`
255+
and stop re-exporting the generated one.

0 commit comments

Comments
 (0)