Skip to content

Add Rust SDK#1101

Closed
stephentoub wants to merge 1 commit into
mainfrom
stephentoub/add-rust-sdk
Closed

Add Rust SDK#1101
stephentoub wants to merge 1 commit into
mainfrom
stephentoub/add-rust-sdk

Conversation

@stephentoub

@stephentoub stephentoub commented Apr 17, 2026

Copy link
Copy Markdown
Collaborator

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 name copilot) 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_fs methods so the config types remain Debug + 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 for format: "duration", "deprecated": true, and $ref resolution) and produces rust/src/generated/{rpc,session_events}.rs.

What's in the crate

  • Client / ClientOptions with Stdio, Tcp, and External transports, child process management (stderr draining, graceful Drop-time cleanup), and protocol version negotiation.
  • Session / SessionConfig / ResumeSessionConfig covering create_session, resume_session, 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 for ergonomic pattern matching.
  • 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 a 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.

Repo integration

  • New scripts/codegen/rust.ts generator. Run via cd scripts/codegen && npm run generate:rust.
  • GitHub Actions workflow for the Rust SDK (build, fmt, clippy, test).
  • justfile updated so just install, just format, just lint, and just test cover Rust.
  • Consistency-agent coverage so the Rust public surface is checked against the other SDKs going forward.
  • Rust implementations of the shared E2E scenarios under test/scenarios/** so the replaying CAPI proxy harness exercises the Rust SDK alongside the others.
  • rust/README.md with quick start, API reference, and parity notes.

Notes for reviewers

  • The per-session SessionFsHandler is wired through new create_session_with_session_fs / resume_session_with_session_fs methods rather than stuffed into the serde-able config structs. The original create_session / resume_session delegate to them. This is the main intentional shape difference vs. the Node-style callback-on-config pattern.
  • set_model is preserved and now delegates to set_model_with_options(SetModelOptions) so existing call sites are unaffected.
  • The codegen output rust/src/generated/{rpc,session_events}.rs is checked in and should be regenerated rather than hand-edited.

Validation: cargo build, cargo fmt --check, cargo clippy --all-targets, and cargo test are all clean.

@stephentoub
stephentoub requested a review from a team as a code owner April 17, 2026 15:01
Copilot AI review requested due to automatic review settings April 17, 2026 15:01
Comment thread scripts/codegen/rust.ts
Comment on lines +18 to +42
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";
Comment thread scripts/codegen/rust.ts
emitDeprecatedAttribute(lines, isSchemaDeprecated(prop), " ");

// Handle serde rename if snake_case differs from JSON key
const expectedSnake = toSnakeCase(propKey);

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread rust/build.rs
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)");

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
println!("cargo::rustc-check-cfg=cfg(has_bundled_cli)");
println!("cargo:rustc-check-cfg=cfg(has_bundled_cli)");

Copilot uses AI. Check for mistakes.
Comment thread scripts/codegen/rust.ts
Comment on lines +202 to +214
// 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";
}

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread scripts/codegen/rust.ts
Comment on lines +257 to +261
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";

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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";

Copilot uses AI. Check for mistakes.
Comment thread scripts/codegen/rust.ts
Comment on lines +430 to +434
// 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#")) {

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
// 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
) {

Copilot uses AI. Check for mistakes.
Comment thread rust/README.md
HandlerEvent::PermissionRequest { .. } => {
HandlerResponse::Permission(PermissionResult::Approved)
}
_ => HandlerResponse::Ignore,

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
_ => HandlerResponse::Ignore,
_ => HandlerResponse::Ok,

Copilot uses AI. Check for mistakes.
Comment thread rust/src/resolve.rs
Comment on lines +57 to +60
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)",
})

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread rust/src/tool.rs
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);

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
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>
@stephentoub
stephentoub force-pushed the stephentoub/add-rust-sdk branch from f3ca194 to 621edff Compare April 17, 2026 15:19
@stephentoub
stephentoub marked this pull request as draft April 17, 2026 15:25
@stephentoub
stephentoub marked this pull request as ready for review April 17, 2026 15:26
@stephentoub
stephentoub deleted the stephentoub/add-rust-sdk branch May 26, 2026 16:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants