Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ See [GitHub Releases](https://github.com/github/copilot-sdk/releases) for the fu

## [Unreleased]

### 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 should 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 should 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
38 changes: 37 additions & 1 deletion rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,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 +816,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
51 changes: 42 additions & 9 deletions rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1976,15 +1976,18 @@ impl Client {

/// Register a session to receive filtered events and requests.
///
/// Returns per-session channels for notifications and requests, routed
/// by `sessionId`. Starts the internal router on first call.
///
/// When done, call [`unregister_session`](Self::unregister_session) to
/// clean up (typically on session destroy).
/// Returns the per-session channels plus a
/// [`RegistrationToken`](crate::router::RegistrationToken) identifying
/// *this* registration. Registering an ID that is already registered
/// replaces the previous registration.
///
/// When done, call
/// [`unregister_session_owned`](Self::unregister_session_owned) with
/// that token to clean up (typically on session destroy).
pub(crate) fn register_session(
&self,
session_id: &SessionId,
) -> crate::router::SessionChannels {
) -> crate::router::SessionRegistration {
self.inner.router.ensure_started(
&self.inner.notification_tx,
&self.inner.request_rx,
Expand All @@ -1994,9 +1997,30 @@ impl Client {
self.inner.router.register(session_id)
}

/// Unregister a session, dropping its per-session channels.
pub(crate) fn unregister_session(&self, session_id: &SessionId) {
self.inner.router.unregister(session_id);
/// Unregister a session only if `token` still identifies the live
/// registration.
///
/// Session IDs can be reused: a caller may retry a cancelled startup
/// with the same pinned ID while the previous owner is still being torn
/// down. Compare-and-remove keeps a stale owner from unregistering the
/// live session that replaced it.
pub(crate) fn unregister_session_owned(
&self,
session_id: &SessionId,
token: crate::router::RegistrationToken,
) {
self.inner.router.unregister_owned(session_id, token);
}

/// Snapshot the session IDs currently registered on the router.
///
/// Crate-internal so in-crate unit tests can assert registration
/// lifecycle without depending on the `test-support` feature, which
/// only gates the equivalent *public* test helper. Compiled only for
/// those two configurations — a default-feature build has no caller.
#[cfg(any(test, feature = "test-support"))]
pub(crate) fn registered_session_ids(&self) -> Vec<SessionId> {
self.inner.router.session_ids()
}

/// Returns the protocol version negotiated with the CLI server, if any.
Expand Down Expand Up @@ -2209,6 +2233,15 @@ impl Client {
);
}

#[cfg(feature = "test-support")]
#[doc(hidden)]
/// Snapshot the session IDs currently registered on this client's
/// notification router. This is test-harness plumbing, not part of the
/// supported SDK API.
pub fn registered_session_ids_for_test(&self) -> Vec<SessionId> {
self.registered_session_ids()
}

#[cfg(feature = "test-support")]
#[doc(hidden)]
/// Disconnect and delete every session owned by this test client's isolated
Expand Down
Loading
Loading