Add Rust SDK#1101
Conversation
| import { | ||
| cloneSchemaForCodegen, | ||
| getApiSchemaPath, | ||
| getRpcSchemaTypeName, | ||
| getSessionEventsSchemaPath, | ||
| hoistTitledSchemas, | ||
| isObjectSchema, | ||
| isVoidSchema, | ||
| isRpcMethod, | ||
| isNodeFullyExperimental, | ||
| isNodeFullyDeprecated, | ||
| isSchemaDeprecated, | ||
| postProcessSchema, | ||
| writeGeneratedFile, | ||
| collectDefinitionCollections, | ||
| hasSchemaPayload, | ||
| refTypeName, | ||
| resolveObjectSchema, | ||
| resolveSchema, | ||
| withSharedDefinitions, | ||
| EXCLUDED_EVENT_TYPES, | ||
| type ApiSchema, | ||
| type DefinitionCollections, | ||
| type RpcMethod, | ||
| } from "./utils.js"; |
| emitDeprecatedAttribute(lines, isSchemaDeprecated(prop), " "); | ||
|
|
||
| // Handle serde rename if snake_case differs from JSON key | ||
| const expectedSnake = toSnakeCase(propKey); |
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds a first-class Rust SDK implementation to the mono-repo (alongside TS/Go/Python/.NET), including schema-driven codegen, CI integration, and Rust implementations of shared test scenarios.
Changes:
- Introduces a new
rust/crate implementing the Copilot SDK, plus Rust unit/integration tests. - Adds a Rust schema code generator and wires it into the existing codegen/check workflows.
- Integrates Rust into repo-wide tooling (justfile) and CI (scenario builds + Rust SDK tests).
Show a summary per file
| File | Description |
|---|---|
| test/scenarios/auth/gh-app/rust/main.rs | Adds Rust scenario entrypoint for GitHub App auth flow. |
| test/scenarios/auth/gh-app/rust/Cargo.toml | Adds Cargo manifest for the GitHub App auth Rust scenario. |
| test/scenarios/auth/byok-openai/rust/main.rs | Adds Rust scenario entrypoint for BYOK OpenAI provider. |
| test/scenarios/auth/byok-openai/rust/Cargo.toml | Adds Cargo manifest for the BYOK OpenAI Rust scenario. |
| test/scenarios/auth/byok-ollama/rust/main.rs | Adds Rust scenario entrypoint for BYOK Ollama (OpenAI-compatible) provider. |
| test/scenarios/auth/byok-ollama/rust/Cargo.toml | Adds Cargo manifest for the BYOK Ollama Rust scenario. |
| test/scenarios/auth/byok-azure/rust/main.rs | Adds Rust scenario entrypoint for BYOK Azure OpenAI provider. |
| test/scenarios/auth/byok-azure/rust/Cargo.toml | Adds Cargo manifest for the BYOK Azure Rust scenario. |
| test/scenarios/auth/byok-anthropic/rust/main.rs | Adds Rust scenario entrypoint for BYOK Anthropic provider. |
| test/scenarios/auth/byok-anthropic/rust/Cargo.toml | Adds Cargo manifest for the BYOK Anthropic Rust scenario. |
| scripts/codegen/rust.ts | Adds Rust code generator for RPC + session events types. |
| scripts/codegen/package.json | Wires Rust generator into codegen scripts. |
| rust/tests/protocol_version_test.rs | Adds unit tests for protocol version negotiation behavior. |
| rust/tests/jsonrpc_test.rs | Adds unit tests for JSON-RPC framing and message routing. |
| rust/tests/integration_test.rs | Adds ignored integration tests against a real Copilot CLI. |
| rust/src/transforms.rs | Adds system message transform trait + dispatch logic with tests. |
| rust/src/tool.rs | Adds typed tool framework + router with tests. |
| rust/src/router.rs | Adds per-session routing for notifications/requests. |
| rust/src/resolve.rs | Adds CLI binary discovery logic and supporting tests. |
| rust/src/lib.rs | Adds Rust public SDK API (Client, options, core lifecycle). |
| rust/src/jsonrpc.rs | Adds JSON-RPC client implementation and message framing. |
| rust/src/hooks.rs | Adds session hooks API + dispatch logic with tests. |
| rust/src/handler.rs | Adds SessionHandler trait and built-in ApproveAllHandler. |
| rust/src/embeddedcli.rs | Adds embedded CLI extraction/installation support. |
| rust/src/duration_serde.rs | Adds serde helpers for duration fields encoded as millis. |
| rust/build.rs | Adds build-time CLI bundling (optional via env var). |
| rust/README.md | Adds Rust SDK documentation, quick start, and API overview. |
| rust/Cargo.toml | Introduces the Rust crate manifest, features, and deps. |
| rust/.gitignore | Adds Rust target directory ignore. |
| nodejs/scripts/update-protocol-version.ts | Extends existing protocol version updater to generate Rust file. |
| justfile | Adds Rust to install/format/lint/test and scenario-build. |
| README.md | Adds Rust to top-level SDK list and CLI bundling note. |
| .github/workflows/sdk-consistency-review.md | Expands consistency review workflow documentation to include Rust. |
| .github/workflows/scenario-builds.yml | Adds CI job to cargo-check all Rust scenarios. |
| .github/workflows/rust-sdk-tests.yml | Adds Rust SDK CI (fmt/clippy/test) across OS matrix. |
| .github/workflows/docs-validation.yml | Includes Rust sources in docs validation trigger paths. |
| .github/workflows/codegen-check.yml | Includes Rust generated sources in codegen check trigger paths. |
| .github/copilot-instructions.md | Updates repo guidance docs to include Rust SDK paths/workflows. |
| .gitattributes | Marks Rust generated files as generated + LF EOL. |
Copilot's findings
- Files reviewed: 42/135 changed files
- Comments generated: 7
| fn main() { | ||
| println!("cargo:rerun-if-env-changed=COPILOT_CLI_VERSION"); | ||
| println!("cargo:rerun-if-env-changed=BUNDLED_CLI_CACHE_DIR"); | ||
| println!("cargo::rustc-check-cfg=cfg(has_bundled_cli)"); |
There was a problem hiding this comment.
cargo::rustc-check-cfg is not a valid build-script instruction key (the prefix must be cargo:). This will prevent the check-cfg from being registered and can cause #[cfg(has_bundled_cli)] gating to behave unexpectedly under check-cfg. Change it to println!("cargo:rustc-check-cfg=cfg(has_bundled_cli)"); (and consider also adding cargo:rustc-cfg=has_bundled_cli only when bundling is enabled, which you already do).
| println!("cargo::rustc-check-cfg=cfg(has_bundled_cli)"); | |
| println!("cargo:rustc-check-cfg=cfg(has_bundled_cli)"); |
| // Handle oneOf / anyOf (union types) | ||
| if (s.oneOf || s.anyOf) { | ||
| const variants = (s.oneOf || s.anyOf) as JSONSchema7[]; | ||
| // If it's a nullable type (oneOf with null), unwrap | ||
| const nonNull = variants.filter( | ||
| (v) => typeof v === "object" && v.type !== "null", | ||
| ); | ||
| if (nonNull.length === 1 && variants.length === 2) { | ||
| return resolveRustType(nonNull[0], ctx, propName, parentName); | ||
| } | ||
| // For complex unions, use serde_json::Value | ||
| return "serde_json::Value"; | ||
| } |
There was a problem hiding this comment.
The generator currently “unwraps” T | null to T for oneOf/anyOf and type: ["...","null"]. That loses nullability for required-but-nullable properties (valid JSON Schema: the property is required to exist, but it may be null). In Rust this should generally become Option<T> (or a custom nullable wrapper) regardless of required, otherwise deserialization will fail when the server sends null. A concrete fix is to have resolveRustType return Option<...> when nullability is present (or return a { ty, nullable } shape and let emitStruct wrap in Option even for required fields when nullable=true).
| const nonNull = (s.type as string[]).filter((t) => t !== "null"); | ||
| if (nonNull.length === 1) { | ||
| return resolveRustPrimitive(nonNull[0], s, ctx, propName, parentName); | ||
| } | ||
| return "serde_json::Value"; |
There was a problem hiding this comment.
The generator currently “unwraps” T | null to T for oneOf/anyOf and type: ["...","null"]. That loses nullability for required-but-nullable properties (valid JSON Schema: the property is required to exist, but it may be null). In Rust this should generally become Option<T> (or a custom nullable wrapper) regardless of required, otherwise deserialization will fail when the server sends null. A concrete fix is to have resolveRustType return Option<...> when nullability is present (or return a { ty, nullable } shape and let emitStruct wrap in Option even for required fields when nullable=true).
| const nonNull = (s.type as string[]).filter((t) => t !== "null"); | |
| if (nonNull.length === 1) { | |
| return resolveRustPrimitive(nonNull[0], s, ctx, propName, parentName); | |
| } | |
| return "serde_json::Value"; | |
| const types = s.type as string[]; | |
| const isNullable = types.includes("null"); | |
| const nonNull = types.filter((t) => t !== "null"); | |
| if (nonNull.length === 1) { | |
| const innerType = resolveRustPrimitive( | |
| nonNull[0], | |
| s, | |
| ctx, | |
| propName, | |
| parentName, | |
| ); | |
| return isNullable ? `Option<${innerType}>` : innerType; | |
| } | |
| return isNullable ? "Option<serde_json::Value>" : "serde_json::Value"; |
| // Handle serde rename if snake_case differs from JSON key | ||
| const expectedSnake = toSnakeCase(propKey); | ||
| // serde(rename_all = "camelCase") handles most cases, but explicit rename | ||
| // is needed when the key doesn't round-trip through camelCase → snake_case | ||
| if (fieldName.startsWith("r#")) { |
There was a problem hiding this comment.
expectedSnake is computed but never used. With typical TS settings (and many repo lint configs), this is a hard error (noUnusedLocals) and also makes the rename logic comment misleading. Either remove expectedSnake and adjust the comment, or implement the intended behavior (e.g., emit #[serde(rename = ...)] when the derived Rust field name doesn’t match what rename_all = "camelCase" would produce).
| // Handle serde rename if snake_case differs from JSON key | |
| const expectedSnake = toSnakeCase(propKey); | |
| // serde(rename_all = "camelCase") handles most cases, but explicit rename | |
| // is needed when the key doesn't round-trip through camelCase → snake_case | |
| if (fieldName.startsWith("r#")) { | |
| // Handle serde rename if the emitted Rust field name won't map back to the | |
| // original JSON key via serde(rename_all = "camelCase"). | |
| const expectedSnake = toSnakeCase(propKey); | |
| const comparableFieldName = fieldName.startsWith("r#") | |
| ? fieldName.slice(2) | |
| : fieldName; | |
| // serde(rename_all = "camelCase") handles most cases, but explicit rename | |
| // is needed when the key doesn't round-trip through camelCase → snake_case | |
| // or when the Rust field must use a raw identifier. | |
| if ( | |
| fieldName.startsWith("r#") || | |
| comparableFieldName !== expectedSnake | |
| ) { |
| HandlerEvent::PermissionRequest { .. } => { | ||
| HandlerResponse::Permission(PermissionResult::Approved) | ||
| } | ||
| _ => HandlerResponse::Ignore, |
There was a problem hiding this comment.
The README example uses HandlerResponse::Ignore, but HandlerResponse in rust/src/handler.rs does not define an Ignore variant (the “do nothing” response is HandlerResponse::Ok). As written, this example won’t compile; update it to return HandlerResponse::Ok for the default branch.
| _ => HandlerResponse::Ignore, | |
| _ => HandlerResponse::Ok, |
| Err(Error::BinaryNotFound { | ||
| name: "copilot", | ||
| hint: "ensure the Copilot CLI is installed and on PATH, or set COPILOT_CLI_PATH. use COPILOT_CLI_NAME to override the binary name (default: copilot)", | ||
| }) |
There was a problem hiding this comment.
copilot_binary_with_source() honors COPILOT_CLI_NAME when searching, but the error hard-codes name: "copilot". If the user overrides the binary name, the displayed error (binary not found: copilot ...) becomes misleading. Consider changing BinaryNotFound.name to an owned String (or storing the resolved base name) so the error accurately reports the name that was searched (e.g., the COPILOT_CLI_NAME value when set).
| pub fn new(tools: Vec<Box<dyn ToolHandler>>, inner: Arc<dyn SessionHandler>) -> Self { | ||
| let mut handlers = HashMap::new(); | ||
| for tool in tools { | ||
| handlers.insert(tool.tool().name.clone(), tool); |
There was a problem hiding this comment.
ToolHandlerRouter::new silently overwrites a previously-registered tool when two handlers return the same tool().name. That makes misconfiguration hard to detect and can lead to surprising runtime behavior. Prefer to detect duplicates and either (a) return a Result<Self, Error> with a clear message, or (b) panic! with a clear diagnostic if this is considered programmer error.
| handlers.insert(tool.tool().name.clone(), tool); | |
| let name = tool.tool().name.clone(); | |
| if handlers.contains_key(&name) { | |
| panic!("duplicate tool handler registration for tool name: {name}"); | |
| } | |
| handlers.insert(name, tool); |
Adds a complete Rust implementation of the Copilot SDK alongside the
existing TypeScript, Go, Python, and .NET SDKs, integrated into the
mono-repo so it builds and is checked the same way as the others.
Crate layout (rust/):
- copilot-sdk crate (lib name copilot) targeting tokio/serde/async-trait
- Public client (Client, ClientOptions, Transport: Stdio/Tcp/External)
with stdio, TCP, and external transports; child process management
with stderr draining; protocol version negotiation; and graceful
Drop-time cleanup.
- Session API (Session, SessionConfig, ResumeSessionConfig) covering
create/resume, send_message, get_messages, set_model /
set_model_with_options, list-sessions, attachments, hooks, and
reload helpers.
- Handler traits: SessionHandler, SessionHooks, SystemMessageTransform,
SessionFsHandler, ListModelsHandler with idiomatic async-trait
bounds and blanket impls for closures where appropriate.
- Typed session events generated from the shared schema, with full
enum coverage and pattern-matching ergonomics.
- Custom session filesystem provider: connection-level SessionFsConfig
+ validation, sessionFs.setProvider registration after protocol
verification, and per-session dispatch of all 10 sessionFs.* RPCs.
- Custom onListModels handler with thread-safe, double-checked cache
matching the other SDKs' semantics.
- Model capability overrides via SetModelOptions / ModelCapabilitiesOverride
and extended SessionConfig/ResumeSessionConfig fields (session_id,
agent, model_capabilities, config_dir, working_directory,
available_tools, provider, custom_agents, infinite_sessions,
commands, disabled_skills, disable_resume).
- Permission, elicitation, exit-plan-mode, and user-input request
routing with strongly typed results.
- Duration serde helper and codegen support for format: "duration",
"deprecated": true, and $ref resolution.
Code generation (scripts/codegen/rust.ts):
- New TypeScript generator producing rust/src/generated/{rpc,session_events}.rs
- Mirrors the existing Go/Python/.NET generators: shared type
registration, snake_case <-> camelCase serde renames, optional
field handling, anonymous struct flattening, enum variant
generation, and special-case handling (duration, deprecated, $ref).
- Run via: cd scripts/codegen && npm run generate:rust
CI / repo integration:
- GitHub Actions workflow for the Rust SDK (build, fmt, clippy, test)
- justfile tasks updated so just install / format / lint / test cover Rust
- Consistency-agent coverage so the Rust surface is checked against the
other SDKs
Scenarios / E2E:
- Rust implementations of the shared E2E scenarios under
test/scenarios/** so the replaying CAPI proxy harness exercises the
Rust SDK alongside the others.
Docs:
- rust/README.md with quick start, API reference, and parity notes for
the new handlers and extended config fields.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
f3ca194 to
621edff
Compare
Adds a complete Rust implementation of the Copilot SDK alongside the existing TypeScript, Go, Python, and .NET SDKs. The Rust crate is integrated into the mono-repo so it builds, lints, tests, and is checked for cross-language consistency the same way as the others.
Based on a full implementation from @tclem, augmented with a code generator.
Approach
The crate (
rust/) is a tokio + serde + async-trait crate (lib namecopilot) that mirrors the public surface of the other SDKs while staying idiomatic for Rust. Where the other SDKs use callback factories on config structs, Rust exposes per-session handlers via dedicated*_with_session_fsmethods so the config types remainDebug+Serialize. Codegen mirrors the existing Go/Python/.NET generators (shared registration, snake_case <-> camelCase serde renames, anonymous struct flattening, enum generation, plus special-case handling forformat: "duration","deprecated": true, and$refresolution) and producesrust/src/generated/{rpc,session_events}.rs.What's in the crate
Client/ClientOptionswithStdio,Tcp, andExternaltransports, child process management (stderr draining, graceful Drop-time cleanup), and protocol version negotiation.Session/SessionConfig/ResumeSessionConfigcoveringcreate_session,resume_session,send_message,get_messages,set_model/set_model_with_options, list-sessions, attachments, hooks, and reload helpers.SessionHandler,SessionHooks,SystemMessageTransform,SessionFsHandler,ListModelsHandler, with idiomaticasync-traitbounds and blanket impls for closures where appropriate.SessionFsConfig+ validation,sessionFs.setProviderregistration after protocol verification, and per-session dispatch of all 10sessionFs.*RPCs.onListModelshandler with a thread-safe, double-checked cache matching the other SDKs' semantics.SetModelOptions/ModelCapabilitiesOverrideand extendedSessionConfig/ResumeSessionConfigfields (session_id,agent,model_capabilities,config_dir,working_directory,available_tools,provider,custom_agents,infinite_sessions,commands,disabled_skills,disable_resume).Repo integration
scripts/codegen/rust.tsgenerator. Run viacd scripts/codegen && npm run generate:rust.justfileupdated sojust install,just format,just lint, andjust testcover Rust.test/scenarios/**so the replaying CAPI proxy harness exercises the Rust SDK alongside the others.rust/README.mdwith quick start, API reference, and parity notes.Notes for reviewers
SessionFsHandleris wired through newcreate_session_with_session_fs/resume_session_with_session_fsmethods rather than stuffed into the serde-able config structs. The originalcreate_session/resume_sessiondelegate to them. This is the main intentional shape difference vs. the Node-style callback-on-config pattern.set_modelis preserved and now delegates toset_model_with_options(SetModelOptions)so existing call sites are unaffected.rust/src/generated/{rpc,session_events}.rsis checked in and should be regenerated rather than hand-edited.Validation:
cargo build,cargo fmt --check,cargo clippy --all-targets, andcargo testare all clean.