Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,49 @@ See [GitHub Releases](https://github.com/github/copilot-sdk/releases) for the fu

## [Unreleased]

### Feature: confirmed CLI process termination (Rust)

`Client::force_stop` sends a kill signal and returns without waiting for the OS to release the process. That is fine for a short-lived CLI consumer, but an embedded host that shuts sessions down under a timeout keeps running, and on Unix every unreaped child stays a zombie for as long as its parent lives.

The Rust SDK now exposes termination you can await:

```rust
// Await the reap: resolves once the child is killed *and* released.
let exit_status = client.force_stop_and_wait().await?;

// Or take an owned handle and await it somewhere else.
let shutdown = client.start_force_stop();
tokio::spawn(async move { shutdown.wait().await });
```

`force_stop_and_wait` resolves to the child's `ExitStatus`, or `None` for clients that never spawned one (stream-backed and in-process transports) — `None` means "nothing to terminate", never "termination failed", which is always an `Err`. `start_force_stop` returns a `ForcedShutdown` that borrows nothing from the client and is not bound to the runtime that started termination, so it can be awaited from another task or another runtime.

Termination is now owned by the SDK rather than by the future that requests it. Previously `stop()` took the child out of its slot and then awaited the kill inline: if an outer timeout cancelled it in that window, the handle went with the cancelled future — the child was neither reaped nor recoverable, and a following `force_stop` found nothing to do. The child is now claimed synchronously and handed to a detached reaper, so cancelling `stop()`, cancelling `force_stop_and_wait()`, or dropping a `ForcedShutdown` cannot strand a signalled-but-unreaped process. Repeat and concurrent callers observe the same terminal outcome instead of racing for the handle.

`force_stop` keeps its existing synchronous, infallible signature and semantics — the kill signal is still delivered synchronously, before anything is scheduled, so it works with no tokio runtime in context at all. Reaping runs on a dedicated thread with its own runtime rather than on a caller's, because a `tokio::spawn`ed reaper is cancelled when its runtime drops, which for an embedded host is exactly the moment termination matters. If the reaper cannot run to completion anyway — its thread fails to start, or it panics — waiters get a definitive `ErrorKind::Io` rather than sitting pending.

### Feature: early session-event subscription (Rust)

The Rust SDK can now observe every event routed to a session, starting with that session's very first routed event. `Client::prepare_session` and `Client::prepare_resume_session` return an inert `PreparedSession` that owns the session's event channel, so a subscription can be installed *before* any protocol activity begins:

```rust
let prepared = client.prepare_session(
SessionConfig::default().with_event_buffer_capacity(2048),
)?;
let mut events = prepared.subscribe();
let session = prepared.start().await?;
```

Previously, `Session::subscribe` could only be called on the returned session, so events the runtime emitted while `session.create` / `session.resume` was still in flight were broadcast with no receiver installed and dropped. Ephemeral events such as `session.idle` are not persisted, so they could not be recovered with `getMessages` either.

The guarantee is scoped to *routed* events. For cloud sessions where the server assigns the session ID, the SDK cannot route notifications until the `session.create` response arrives and the ID is known, so events emitted before that point are not routable to any session. Pin `session_id` on the config to get router registration before the RPC, and with it complete pre-response coverage.

`prepare_*` is synchronous and inert: it validates the buffer capacity and allocates a local channel, and performs no router registration, task spawn, or wire activity until `start()` is first polled. `start(self)` consumes the handle and `PreparedSession` is not `Clone`, so a prepared session can never produce two event loops. Dropping an unstarted handle leaves no state behind; dropping a polled `start()` future cancels the startup and unregisters the session, so a retry with the same session ID succeeds. Session registrations now carry an ownership identity, so cleanup removes only the exact registration it owns and an abandoned startup can never evict a same-ID retry (or a session that replaced it).

Both `SessionConfig` and `ResumeSessionConfig` gained a runtime-only `event_buffer_capacity` option (default 512, `Some(0)` rejected as an invalid config). The buffer is finite, so slow subscribers observe `Lagged` rather than applying backpressure; consumers that need a lossless view of a large startup burst must size the buffer accordingly or drain concurrently with `start()`.

`create_session` and `resume_session` are unchanged wrappers over `prepare_*(...)?.start()` with identical RPC sequences and error kinds.

### Feature: host-injected managed settings permissions

Session create and resume accept a new optional `managedSettings` option that injects an enterprise permissions policy at session startup, alongside the existing `enableManagedSettings` self-fetch flag. The current contract is permissions-only: `disableBypassPermissionsMode` (the literal `"disable"`), plus `deny`, `ask`, and `allow` rule lists. The layer composes restrictively with any server- or device-level managed settings (deny/ask are unioned, every present allow list must admit a tool, and `disableBypassPermissionsMode` is deny-wins).
Expand Down
42 changes: 42 additions & 0 deletions docs/features/streaming-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,48 @@ session.on(AssistantMessageDeltaEvent.class, event ->
> [!TIP]
> **(TypeScript)** The TypeScript SDK uses a discriminated union—when you match on `event.type`, the `data` payload is automatically narrowed to the correct shape.

## Subscribing before a session starts

A session can emit events before its create or resume call returns. The agent may already be working—especially on resume with `continuePendingWork`—and ephemeral events such as `session.idle` are never written to the session log, so `getMessages` cannot recover them afterwards. A subscription installed after the session handle exists misses that startup window.

> [!TIP]
> **(Rust)** `Client::prepare_session` and `Client::prepare_resume_session` return a `PreparedSession` that owns the session's event channel before any protocol activity happens. Subscribe first, then call `start()`.

```rust
use github_copilot_sdk::{Client, SessionConfig};

async fn create_without_missing_startup_events(
client: &Client,
) -> Result<(), github_copilot_sdk::Error> {
let prepared = client.prepare_session(
SessionConfig::default().with_event_buffer_capacity(2048),
)?;

// Installed before any wire activity: nothing is dropped for lack of a receiver.
let mut events = prepared.subscribe();
tokio::spawn(async move {
while let Ok(event) = events.recv().await {
println!("{}", event.event_type);
}
});

let session = prepared.start().await?;
let _ = session;
Ok(())
}
```

`prepare_*` is synchronous and inert: it validates the buffer capacity, allocates a local channel, and does nothing else. No session is registered and nothing reaches the CLI until `start()` is first polled. Dropping a prepared session that was never started leaves no state behind and closes its subscriptions; dropping the `start()` future cancels the in-flight startup and unregisters the session, so a retry with the same session ID succeeds. Cleanup is scoped to the exact registration the abandoned startup owned, so it cannot evict a retry that has already taken over the same session ID.

Startup buffering is worth planning for:

* The event buffer is finite—512 events unless `event_buffer_capacity` overrides it. A capacity of `0` is rejected with an invalid-config error rather than clamped.
* Slow subscribers observe a `Lagged` error reporting how many events were skipped. They never apply backpressure to the session's event loop.
* Consumers that need a lossless view of a large startup burst must either configure a capacity that covers it or drain the subscription concurrently with `start()`.

> [!NOTE]
> For cloud sessions where the server assigns the session ID, the SDK cannot route notifications until the create response arrives and the ID is known. Events emitted before that point are not routable to any session. The guarantee is narrower: routed events are never dropped for lack of an installed receiver. Pin `session_id` on the config to get routing—and full pre-response coverage—from the first byte.

## Render only the parent agent response

Sub-agent events share the parent session stream and include envelope-level `agentId`. Root/main agent events and session-level events omit `agentId`, so main-chat renderers can ignore assistant events where `agentId` is set and route those events to traces or progress UI instead.
Expand Down
4 changes: 4 additions & 0 deletions rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ required-features = ["test-support"]
name = "protocol_version_test"
required-features = ["test-support"]

[[test]]
name = "prepared_session_test"
required-features = ["test-support"]

[build-dependencies]
base64 = "0.22"
dirs = "5"
Expand Down
80 changes: 79 additions & 1 deletion rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,48 @@ let pong = client.ping("hello").await?;

// Shutdown
client.stop().await?;

// Forced shutdown that confirms the OS reaped the CLI process, for hosts
// that must not leak a zombie when graceful shutdown times out.
if tokio::time::timeout(Duration::from_secs(5), client.stop()).await.is_err() {
let exit_status = client.force_stop_and_wait().await?;
}
```

### Terminating the CLI process

`stop()` is the cooperative path. When it is unsuitable — a wedged
process, or an outer timeout that has already elapsed — three forced
variants differ only in how much of the teardown you wait for:

| Method | Shape | Resolves when |
| ------ | ----- | ------------- |
| `force_stop()` | sync, infallible | the kill signal has been sent |
| `start_force_stop()` | sync, returns `ForcedShutdown` | immediately; await the handle for the reap |
| `force_stop_and_wait()` | async | the child is killed **and** reaped |

Use `force_stop_and_wait()` (or await a `ForcedShutdown`) when the caller
must know the process is really gone. Sending a kill only starts
termination; until someone waits on the child, a Unix process stays a
zombie for as long as its parent lives — which for a long-lived embedded
host means they accumulate.

Termination is owned by the SDK, not by the future that asks for it. The
kill is delivered synchronously — it never depends on a task being
polled — and the claimed child is then reaped on a dedicated thread with
its own runtime. So cancelling `stop()` or `force_stop_and_wait()`,
dropping a `ForcedShutdown`, or tearing down the runtime that started
termination cannot strand a signalled-but-unreaped process. Repeat and
concurrent callers all observe the same terminal outcome instead of
racing for the handle, and because the reaper is not bound to a caller's
runtime, a handle can be awaited on a different one:

```rust,ignore
let shutdown = client.start_force_stop(); // teardown starts now
tokio::spawn(async move {
let exit_status = shutdown.wait().await?; // observed elsewhere
Ok::<_, github_copilot_sdk::Error>(exit_status)
});
```

After `Client::start` succeeds, inspect its startup cost without parsing logs:
Expand Down Expand Up @@ -583,6 +625,36 @@ while let Ok(event) = events.recv().await {

When streaming is off (the default), only the final `assistant.message` and `assistant.reasoning` events fire. Delta events arrive in order; concatenating their `delta` text payloads reproduces the final message.

#### Subscribing before the session starts

`session.subscribe()` can only be called once the session exists, so any event the runtime emits while `session.create` / `session.resume` is still in flight is broadcast with no receiver installed and is not delivered. Ephemeral events such as `session.idle` are not written to the session log either, so `get_messages` can't recover them afterwards.

`Client::prepare_session` / `Client::prepare_resume_session` close that window. They return a `PreparedSession` that owns the session's broadcast channel up front:

```rust,ignore
let prepared = client.prepare_session(
SessionConfig::default().with_event_buffer_capacity(2048),
)?;

// Installed before any wire activity happens.
let mut events = prepared.subscribe();
tokio::spawn(async move {
while let Ok(event) = events.recv().await {
println!("{}", event.event_type);
}
});

let session = prepared.start().await?;
```

`prepare_*` is synchronous and inert — it validates the buffer capacity, allocates a local channel and cancellation token, and touches neither the router nor the transport until `start()` is first polled. `start(self)` consumes the handle and `PreparedSession` is deliberately not `Clone`, so a prepared session can never spawn two event loops. Dropping an unstarted handle leaves no state and closes its subscriptions; dropping the `start()` future cancels the startup, unregisters the session, and lets a same-ID retry succeed. Cleanup removes only the exact registration that startup owned, so a retry started while an abandoned attempt is still unwinding is never evicted by it.

The buffer is finite — `session::DEFAULT_EVENT_BUFFER_CAPACITY` (512) unless `event_buffer_capacity` overrides it, and `Some(0)` is rejected as `ErrorKind::InvalidConfig` rather than clamped. Subscribers that fall behind observe `RecvErrorKind::Lagged` with the skipped count instead of applying backpressure, so a consumer that needs a lossless view of a large startup burst must configure enough capacity or drain concurrently with `start()`.

For cloud sessions where the server assigns the session ID, notifications can't be routed until the create response arrives; the guarantee is that *routed* events are never dropped for lack of a receiver. Pin `session_id` for full pre-response coverage.

`create_session` / `resume_session` are unchanged wrappers over `prepare_*(...)?.start()`, with identical RPC sequences and error kinds.

### Infinite Sessions

Enable the SDK's session-store integration so conversations persist across CLI restarts and grow beyond the model's context window via automatic compaction:
Expand Down Expand Up @@ -786,13 +858,19 @@ none of them are scheduled for removal.
arg vectors for "prepend before subcommand" vs "append after the
built-in flags", giving precise control over CLI invocation order
without string-splicing.
- **`Client::prepare_session` / `prepare_resume_session`** — return an inert
`PreparedSession` whose `subscribe()` installs an event receiver before any
protocol activity, so startup events (including ephemeral `session.idle`)
aren't dropped. Other SDKs register callbacks on a config object instead,
which sidesteps the problem in a way Rust's broadcast-based `subscribe()`
cannot.

## Layout

| File | Description |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `lib.rs` | `Client`, `ClientOptions`, `CliProgram`, `Transport`, `Error` |
| `session.rs` | `Session` struct, event loop, `send`/`send_and_wait`, `Client::create_session`/`resume_session` |
| `session.rs` | `Session` struct, `PreparedSession`, event loop, `send`/`send_and_wait`, `Client::create_session`/`resume_session`/`prepare_session`/`prepare_resume_session` |
| `subscription.rs` | `EventSubscription` / `LifecycleSubscription` (`Stream`-able observer handles for `subscribe()` / `subscribe_lifecycle()`) |
| `handler.rs` | `PermissionHandler`, `ElicitationHandler`, `UserInputHandler`, `ExitPlanModeHandler`, `AutoModeSwitchHandler` traits; `ApproveAllHandler`, `DenyAllHandler` |
| `hooks.rs` | `SessionHooks` trait, `HookEvent`/`HookOutput` enums, typed hook inputs/outputs |
Expand Down
Loading
Loading