diff --git a/rust/README.md b/rust/README.md
index 254a2b216..054eb2804 100644
--- a/rust/README.md
+++ b/rust/README.md
@@ -89,7 +89,7 @@ With the default `CliProgram::Resolve`, `Client::start()` resolves the CLI in th
Created via `Client::create_session` or `Client::resume_session`. Owns an internal event loop that dispatches CLI callbacks to the focused handler traits you install on `SessionConfig`, and broadcasts session events through `subscribe()`.
```rust,ignore
-use github_copilot_sdk::MessageOptions;
+use github_copilot_sdk::{InterruptMainTurnOptions, MessageOptions};
// Simple send — &str / String convert into MessageOptions automatically.
// Returns the assigned message ID for correlation with later events.
@@ -107,7 +107,14 @@ let _id = session
// Message history
let messages = session.get_events().await?;
-// Abort the current agent turn
+// Interrupt only the foreground turn and preserve queued user prompts.
+let result = session
+ .interrupt_main_turn(
+ InterruptMainTurnOptions::default().with_flush_queued(true),
+ )
+ .await?;
+
+// Recursively abort the active turn and all descendant/background work.
session.abort().await?;
// Model management
@@ -176,6 +183,10 @@ let forked = client
.await?;
```
+`interrupt_main_turn` preserves background agents and other independent background work. Its default `flush_queued: false` discards queued prompts; `true` preserves queued user prompts for the next eligible turn, drops hidden system prompts, and resumes normal queue delivery after the interrupted coordinator loop unwinds. A result with `interrupted == false` means the method is supported but no main turn was active. The method never falls back to `session.abort`, whose recursive cancellation also stops descendant and background work.
+
+Check `session.capabilities().interrupt_main_turn` before calling when connected targets may differ: local sessions report `Some(true)`, unsupported remote sessions report `Some(false)`, and older servers omit the capability (`None`). Each `capabilities.changed` event is a complete snapshot, so the SDK replaces the cached capability state wholesale rather than merging omitted fields.
+
New RPCs land in the namespace immediately as the schema regenerates;
helpers are added on top only when an ergonomic story is worth the
maintenance.
diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs
index e243ec1dc..982184939 100644
--- a/rust/src/generated/api_types.rs
+++ b/rust/src/generated/api_types.rs
@@ -177,6 +177,8 @@ pub mod rpc_methods {
pub const SESSION_SENDMESSAGES: &str = "session.sendMessages";
/// `session.abort`
pub const SESSION_ABORT: &str = "session.abort";
+ /// `session.interruptMainTurn`
+ pub const SESSION_INTERRUPTMAINTURN: &str = "session.interruptMainTurn";
/// `session.shutdown`
pub const SESSION_SHUTDOWN: &str = "session.shutdown";
/// `session.gitHubAuth.getStatus`
@@ -587,7 +589,7 @@ pub mod rpc_methods {
pub const CANVAS_ACTION_INVOKE: &str = "canvas.action.invoke";
}
-/// Parameters for aborting the current turn
+/// Parameters for aborting the active session turn and recursively cancelling its descendant and background work
///
///
///
@@ -603,7 +605,7 @@ pub struct AbortRequest {
pub reason: Option
,
}
-/// Result of aborting the current turn
+/// Result of recursively aborting the active session work
///
///
///
@@ -4314,6 +4316,37 @@ pub struct InstructionsGetSourcesResult {
pub sources: Vec
,
}
+/// Parameters for interrupting only the active main coordinator turn while preserving background work
+///
+///
+///
+/// **Experimental.** This type is part of an experimental wire-protocol surface
+/// and may change or be removed in future SDK or CLI releases.
+///
+///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct InterruptMainTurnRequest {
+ /// When an active main turn is interrupted, true flushes queued user prompts through to the next eligible turn: it preserves them, drops hidden system prompts, and resumes normal queue delivery after the interrupted loop unwinds (including existing deferred-idle ordering). False or omitted discards all queued prompts. When no main turn is active, this option has no effect.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub flush_queued: Option,
+}
+
+/// Result of interrupting only the active main coordinator turn
+///
+///
+///
+/// **Experimental.** This type is part of an experimental wire-protocol surface
+/// and may change or be removed in future SDK or CLI releases.
+///
+///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct InterruptMainTurnResult {
+ /// True when an active main coordinator turn was interrupted; false only when the method is supported but no main turn was active
+ pub interrupted: bool,
+}
+
/// A request body chunk or cancellation signal.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -15100,6 +15133,9 @@ pub struct UsageMetricsModelMetricUsage {
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageMetricsModelMetric {
+ /// Latest known prompt-cache expiration for this model. A timestamp in the past indicates that the observed cache has expired.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub cache_expires_at: Option,
/// Request count and cost metrics for this model
pub requests: UsageMetricsModelMetricRequests,
/// Token count details per type
@@ -16278,7 +16314,7 @@ pub struct SessionSendMessagesResult {
pub message_ids: Vec,
}
-/// Result of aborting the current turn
+/// Result of recursively aborting the active session work
///
///
///
@@ -16296,6 +16332,21 @@ pub struct SessionAbortResult {
pub success: bool,
}
+/// Result of interrupting only the active main coordinator turn
+///
+///
+///
+/// **Experimental.** This type is part of an experimental wire-protocol surface
+/// and may change or be removed in future SDK or CLI releases.
+///
+///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SessionInterruptMainTurnResult {
+ /// True when an active main coordinator turn was interrupted; false only when the method is supported but no main turn was active
+ pub interrupted: bool,
+}
+
/// Identifies the target session.
///
///
@@ -20866,6 +20917,9 @@ pub enum HookType {
/// Runs after the user submits a prompt.
#[serde(rename = "userPromptSubmitted")]
UserPromptSubmitted,
+ /// Runs after the runtime transforms the submitted prompt for the model, before it is added to session history.
+ #[serde(rename = "userPromptTransformed")]
+ UserPromptTransformed,
/// Runs when a session starts.
#[serde(rename = "sessionStart")]
SessionStart,
diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs
index 403933a27..333cd5d84 100644
--- a/rust/src/generated/rpc.rs
+++ b/rust/src/generated/rpc.rs
@@ -2920,17 +2920,17 @@ impl<'a> SessionRpc<'a> {
Ok(serde_json::from_value(_value)?)
}
- /// Aborts the current agent turn.
+ /// Aborts the active session turn and recursively cancels its descendant and background work. Use session.interruptMainTurn when background work must survive.
///
/// Wire method: `session.abort`.
///
/// # Parameters
///
- /// * `params` - Parameters for aborting the current turn
+ /// * `params` - Parameters for aborting the active session turn and recursively cancelling its descendant and background work
///
/// # Returns
///
- /// Result of aborting the current turn
+ /// Result of recursively aborting the active session work
///
///
///
@@ -2950,6 +2950,39 @@ impl<'a> SessionRpc<'a> {
Ok(serde_json::from_value(_value)?)
}
+ /// Interrupts only the active main coordinator turn while preserving background agents and other background work. Returns interrupted=false only when the method is supported but no main turn is active. Unsupported or older targets return JSON-RPC MethodNotFound; callers must not fall back to session.abort unless recursive cancellation is intended.
+ ///
+ /// Wire method: `session.interruptMainTurn`.
+ ///
+ /// # Parameters
+ ///
+ /// * `params` - Parameters for interrupting only the active main coordinator turn while preserving background work
+ ///
+ /// # Returns
+ ///
+ /// Result of interrupting only the active main coordinator turn
+ ///
+ ///
+ ///
+ /// **Experimental.** This API is part of an experimental wire-protocol surface
+ /// and may change or be removed in future SDK or CLI releases. Pin both the
+ /// SDK and CLI versions if your code depends on it.
+ ///
+ ///
+ pub async fn interrupt_main_turn(
+ &self,
+ params: InterruptMainTurnRequest,
+ ) -> Result
{
+ let mut wire_params = serde_json::to_value(params)?;
+ wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string());
+ let _value = self
+ .session
+ .client()
+ .call(rpc_methods::SESSION_INTERRUPTMAINTURN, Some(wire_params))
+ .await?;
+ Ok(serde_json::from_value(_value)?)
+ }
+
/// Shuts down the session and persists its final state. Awaits any deferred sessionEnd hooks before resolving so user-supplied hook scripts complete before the runtime tears down.
///
/// Wire method: `session.shutdown`.
diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs
index cf670c485..5b75742bd 100644
--- a/rust/src/generated/session_events.rs
+++ b/rust/src/generated/session_events.rs
@@ -1209,10 +1209,27 @@ pub struct SessionShutdownData {
pub(crate) total_premium_requests: Option,
}
+/// Internal prompt-cache expiration state for one model
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct UsageCheckpointModelCacheState {
+ /// Latest known prompt-cache expiration
+ pub cache_expires_at: String,
+ /// Retained cache lifetime in seconds, used to refresh expiration after a cache read
+ #[doc(hidden)]
+ pub(crate) cache_ttl_seconds: i64,
+ /// Model identifier associated with this cache state
+ pub model_id: String,
+}
+
/// Session event "session.usage_checkpoint". Durable session usage checkpoint for reconstructing aggregate accounting on resume
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionUsageCheckpointData {
+ /// Internal per-model prompt-cache state used to restore expiration tracking on resume
+ #[doc(hidden)]
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) model_cache_state: Option>,
/// Session-wide accumulated nano-AI units cost at checkpoint time
pub total_nano_aiu: f64,
/// Total number of premium API requests used at checkpoint time
@@ -1870,6 +1887,9 @@ pub struct AssistantUsageData {
/// API endpoint used for this model call, matching CAPI supported_endpoints vocabulary
#[serde(skip_serializing_if = "Option::is_none")]
pub api_endpoint: Option,
+ /// Updated prompt-cache expiration for this model call. Present only when the call establishes or refreshes known cache state.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub cache_expires_at: Option,
/// Number of tokens read from prompt cache
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_read_tokens: Option,
@@ -4042,6 +4062,9 @@ pub struct CapabilitiesChangedUI {
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CapabilitiesChangedData {
+ /// Whether scoped main-turn interruption via `session.interruptMainTurn` is supported
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub interrupt_main_turn: Option,
/// UI capability changes
#[serde(skip_serializing_if = "Option::is_none")]
pub ui: Option,
diff --git a/rust/src/session.rs b/rust/src/session.rs
index 35656c657..6af97d09c 100644
--- a/rust/src/session.rs
+++ b/rust/src/session.rs
@@ -12,8 +12,8 @@ use tracing::{Instrument, warn};
use crate::canvas::CanvasHandler;
use crate::generated::api_types::{
- LogRequest, ModelSwitchToRequest, OpenCanvasInstance, RegisterEventInterestParams,
- ToolsGetCurrentMetadataResult, rpc_methods,
+ InterruptMainTurnRequest, InterruptMainTurnResult, LogRequest, ModelSwitchToRequest,
+ OpenCanvasInstance, RegisterEventInterestParams, ToolsGetCurrentMetadataResult, rpc_methods,
};
use crate::generated::session_events::{
CommandExecuteData, ElicitationRequestedData, ExternalToolRequestedData, McpOauthRequiredData,
@@ -31,9 +31,9 @@ use crate::trace_context::inject_trace_context;
use crate::transforms::SystemMessageTransform;
use crate::types::{
CommandContext, CommandDefinition, CommandHandler, CreateSessionResult, ElicitationRequest,
- ElicitationResult, ExitPlanModeData, GetMessagesResponse, MessageOptions,
- PermissionRequestData, RequestId, ResumeSessionConfig, ResumeSessionResult, SectionOverride,
- SessionCapabilities, SessionConfig, SessionEvent, SessionId, SetModelOptions,
+ ElicitationResult, ExitPlanModeData, GetMessagesResponse, InterruptMainTurnOptions,
+ MessageOptions, PermissionRequestData, RequestId, ResumeSessionConfig, ResumeSessionResult,
+ SectionOverride, SessionCapabilities, SessionConfig, SessionEvent, SessionId, SetModelOptions,
SystemMessageConfig, ToolInvocation, ToolResult, ToolResultExpanded, TraceContext,
UiInputOptions, ensure_attachment_display_names,
};
@@ -509,7 +509,11 @@ impl Session {
self.get_events().await
}
- /// Abort the current agent turn.
+ /// Abort the active session turn and recursively cancel descendant and
+ /// background work.
+ ///
+ /// Use [`interrupt_main_turn`](Self::interrupt_main_turn) when independent
+ /// background work must survive.
///
/// # Cancel safety
///
@@ -526,6 +530,35 @@ impl Session {
Ok(())
}
+ /// Interrupt only the active foreground/main turn while preserving
+ /// independent background agents and descendants.
+ ///
+ /// This never falls back to [`abort`](Self::abort), which is the recursive
+ /// destructive cancellation API. Check
+ /// [`SessionCapabilities::interrupt_main_turn`] before calling when the
+ /// client may connect to mixed CLI versions; unsupported runtimes return
+ /// JSON-RPC `MethodNotFound`.
+ ///
+ /// [`InterruptMainTurnOptions::default`] sends `flush_queued: false` and
+ /// discards queued prompts. Setting it to `true` preserves queued user
+ /// prompts for the next eligible turn, drops hidden system prompts, and
+ /// resumes normal delivery after the interrupted loop unwinds.
+ ///
+ /// # Cancel safety
+ ///
+ /// **Cancel-safe.** Single `session.interruptMainTurn` RPC; the underlying
+ /// [`Client::call`](crate::Client::call) is cancel-safe via the writer actor.
+ pub async fn interrupt_main_turn(
+ &self,
+ options: InterruptMainTurnOptions,
+ ) -> Result {
+ self.rpc()
+ .interrupt_main_turn(InterruptMainTurnRequest {
+ flush_queued: Some(options.flush_queued),
+ })
+ .await
+ }
+
/// Switch to a different model.
///
/// Pass `None` for `opts` if no extra configuration is needed.
@@ -1610,7 +1643,9 @@ async fn handle_notification(
// response to the event observe the new state.
if event_type == SessionEventType::CapabilitiesChanged {
match serde_json::from_value::(notification.event.data.clone()) {
- Ok(changed) => *capabilities.write() = changed,
+ // Every capabilities.changed payload is a complete snapshot, so
+ // omitted fields intentionally clear values from the prior one.
+ Ok(snapshot) => *capabilities.write() = snapshot,
Err(e) => warn!(error = %e, "failed to deserialize capabilities.changed payload"),
}
}
diff --git a/rust/src/types.rs b/rust/src/types.rs
index b34d8fff4..447757b3b 100644
--- a/rust/src/types.rs
+++ b/rust/src/types.rs
@@ -5235,12 +5235,38 @@ pub struct ElicitationRequest {
/// Updated at runtime via `capabilities.changed` events.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
+#[non_exhaustive]
pub struct SessionCapabilities {
+ /// Whether scoped foreground interruption via
+ /// [`Session::interrupt_main_turn`](crate::session::Session::interrupt_main_turn)
+ /// is supported by the connected CLI runtime.
+ ///
+ /// Local sessions report `Some(true)`, unsupported remote sessions report
+ /// `Some(false)`, and older servers omit the field (`None`).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub interrupt_main_turn: Option,
/// UI capabilities (elicitation support, etc.).
#[serde(skip_serializing_if = "Option::is_none")]
pub ui: Option,
}
+/// Options for [`Session::interrupt_main_turn`](crate::session::Session::interrupt_main_turn).
+#[derive(Debug, Clone, Copy, Default)]
+#[non_exhaustive]
+pub struct InterruptMainTurnOptions {
+ /// Preserve queued user prompts for the next eligible turn. The default
+ /// (`false`) discards queued prompts while interrupting the foreground turn.
+ pub flush_queued: bool,
+}
+
+impl InterruptMainTurnOptions {
+ /// Set whether queued user prompts should be preserved.
+ pub fn with_flush_queued(mut self, flush_queued: bool) -> Self {
+ self.flush_queued = flush_queued;
+ self
+ }
+}
+
/// UI-specific capabilities for a session.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
diff --git a/rust/tests/e2e/elicitation.rs b/rust/tests/e2e/elicitation.rs
index 5575e67f3..ac6f3bb8a 100644
--- a/rust/tests/e2e/elicitation.rs
+++ b/rust/tests/e2e/elicitation.rs
@@ -380,13 +380,12 @@ async fn elicitation_returns_all_action_shapes() {
#[tokio::test]
async fn session_capabilities_types_are_properly_structured() {
- let capabilities = github_copilot_sdk::SessionCapabilities {
- ui: Some(UiCapabilities {
- elicitation: Some(true),
- mcp_apps: None,
- canvases: None,
- }),
- };
+ let mut capabilities = github_copilot_sdk::SessionCapabilities::default();
+ capabilities.ui = Some(UiCapabilities {
+ elicitation: Some(true),
+ mcp_apps: None,
+ canvases: None,
+ });
assert_eq!(
capabilities.ui.as_ref().and_then(|ui| ui.elicitation),
diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs
index 122ec475d..6849e6004 100644
--- a/rust/tests/session_test.rs
+++ b/rust/tests/session_test.rs
@@ -14,16 +14,16 @@ use github_copilot_sdk::handler::{
};
use github_copilot_sdk::rpc::{
CanvasProviderInvokeActionRequest, CanvasProviderOpenRequest, CanvasProviderOpenResult,
- OpenCanvasInstance,
+ InterruptMainTurnRequest, OpenCanvasInstance,
};
use github_copilot_sdk::session_events::{
- McpOauthRequiredData, ReasoningSummary, SessionLimitsConfig,
+ CapabilitiesChangedData, McpOauthRequiredData, ReasoningSummary, SessionLimitsConfig,
};
use github_copilot_sdk::types::{
CanvasProviderIdentity, CloudSessionOptions, CloudSessionRepository, CommandContext,
CommandDefinition, CommandHandler, DeliveryMode, ElicitationRequest, ElicitationResult,
- ExitPlanModeData, ExtensionInfo, MessageOptions, RequestId, SessionConfig, SessionId,
- SetModelOptions, Tool, ToolInvocation, ToolResult,
+ ExitPlanModeData, ExtensionInfo, InterruptMainTurnOptions, MessageOptions, RequestId,
+ SessionConfig, SessionId, SetModelOptions, Tool, ToolInvocation, ToolResult,
};
use github_copilot_sdk::{Client, ContextTier, ErrorKind, ProtocolErrorKind, tool};
use serde_json::Value;
@@ -133,6 +133,16 @@ impl FakeServer {
write_framed(&mut self.write, &serde_json::to_vec(&response).unwrap()).await;
}
+ async fn respond_error(&mut self, request: &Value, code: i32, message: &str) {
+ let id = request["id"].as_u64().unwrap();
+ let response = serde_json::json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "error": { "code": code, "message": message },
+ });
+ write_framed(&mut self.write, &serde_json::to_vec(&response).unwrap()).await;
+ }
+
async fn send_notification(&mut self, method: &str, params: Value) {
let notification = serde_json::json!({
"jsonrpc": "2.0",
@@ -1287,6 +1297,94 @@ async fn session_rpc_methods_send_correct_method_names() {
}
}
+#[tokio::test]
+async fn interrupt_main_turn_rpc_omits_default_flush_queued() {
+ let (session, mut server) = create_session_pair().await;
+ let handle = tokio::spawn(async move {
+ session
+ .rpc()
+ .interrupt_main_turn(InterruptMainTurnRequest::default())
+ .await
+ });
+
+ let request = server.read_request().await;
+ assert_eq!(request["method"], "session.interruptMainTurn");
+ assert_eq!(
+ request["params"],
+ serde_json::json!({ "sessionId": server.session_id })
+ );
+ server
+ .respond(&request, serde_json::json!({ "interrupted": true }))
+ .await;
+
+ let result = timeout(TIMEOUT, handle).await.unwrap().unwrap().unwrap();
+ assert!(result.interrupted);
+}
+
+#[tokio::test]
+async fn interrupt_main_turn_sends_false_and_true_and_returns_status() {
+ let (session, mut server) = create_session_pair().await;
+ let session = Arc::new(session);
+
+ for (options, interrupted) in [
+ (InterruptMainTurnOptions::default(), false),
+ (
+ InterruptMainTurnOptions::default().with_flush_queued(true),
+ true,
+ ),
+ ] {
+ let handle = tokio::spawn({
+ let session = session.clone();
+ async move { session.interrupt_main_turn(options).await }
+ });
+
+ let request = server.read_request().await;
+ assert_eq!(request["method"], "session.interruptMainTurn");
+ assert_eq!(
+ request["params"],
+ serde_json::json!({
+ "sessionId": server.session_id,
+ "flushQueued": options.flush_queued,
+ })
+ );
+ server
+ .respond(&request, serde_json::json!({ "interrupted": interrupted }))
+ .await;
+
+ let result = timeout(TIMEOUT, handle).await.unwrap().unwrap().unwrap();
+ assert_eq!(result.interrupted, interrupted);
+ }
+}
+
+#[tokio::test]
+async fn interrupt_main_turn_propagates_method_not_found_without_abort_fallback() {
+ let (session, mut server) = create_session_pair().await;
+ let handle = tokio::spawn(async move {
+ session
+ .interrupt_main_turn(InterruptMainTurnOptions::default())
+ .await
+ });
+
+ let request = server.read_request().await;
+ assert_eq!(request["method"], "session.interruptMainTurn");
+ server
+ .respond_error(&request, -32601, "Method not found")
+ .await;
+
+ let error = timeout(TIMEOUT, handle)
+ .await
+ .unwrap()
+ .unwrap()
+ .unwrap_err();
+ assert_eq!(error.rpc_code(), Some(-32601));
+
+ let fallback = timeout(Duration::from_millis(100), server.read_request()).await;
+ assert!(
+ fallback.is_err(),
+ "session.interruptMainTurn must not fall back to session.abort"
+ );
+}
+
#[tokio::test]
async fn client_rpc_methods_send_correct_method_names() {
let (client, mut server_read, mut server_write) = make_client();
@@ -3134,6 +3232,7 @@ async fn capabilities_captured_from_create_response() {
"result": {
"sessionId": session_id,
"capabilities": {
+ "interruptMainTurn": true,
"ui": { "elicitation": true }
}
},
@@ -3142,40 +3241,52 @@ async fn capabilities_captured_from_create_response() {
let session = timeout(TIMEOUT, create_handle).await.unwrap().unwrap();
let caps = session.capabilities();
+ assert_eq!(caps.interrupt_main_turn, Some(true));
assert_eq!(caps.ui.as_ref().unwrap().elicitation, Some(true));
}
#[tokio::test]
-async fn capabilities_changed_event_updates_session() {
- let (session, mut server) = create_session_pair().await;
+async fn missing_interrupt_capability_from_older_server_remains_unknown() {
+ let (session, _server) = create_session_pair_with_capabilities(serde_json::json!({
+ "ui": { "elicitation": true }
+ }))
+ .await;
- // Initially no capabilities (create_session_pair doesn't send them)
- assert!(session.capabilities().ui.is_none());
+ assert_eq!(session.capabilities().interrupt_main_turn, None);
+}
+
+#[tokio::test]
+async fn capabilities_changed_event_replaces_complete_snapshot_and_is_typed() {
+ let (session, mut server) = create_session_pair_with_capabilities(serde_json::json!({
+ "interruptMainTurn": true,
+ "ui": { "elicitation": true }
+ }))
+ .await;
+ let mut events = session.subscribe();
- // CLI sends capabilities.changed event
server
.send_event(
"capabilities.changed",
- serde_json::json!({
- "ui": { "elicitation": true }
- }),
+ serde_json::json!({ "interruptMainTurn": false }),
)
.await;
- // Poll until the event loop processes the notification
- let caps = timeout(TIMEOUT, async {
- loop {
- let caps = session.capabilities();
- if caps.ui.is_some() {
- return caps;
- }
- tokio::time::sleep(Duration::from_millis(5)).await;
- }
- })
- .await
- .expect("capabilities should update within timeout");
+ let event = timeout(TIMEOUT, events.recv())
+ .await
+ .expect("capabilities.changed should arrive within timeout")
+ .unwrap();
+ let changed = event
+ .typed_data::()
+ .expect("capabilities.changed should expose typed data");
+ assert_eq!(changed.interrupt_main_turn, Some(false));
+ assert!(changed.ui.is_none());
- assert_eq!(caps.ui.as_ref().unwrap().elicitation, Some(true));
+ let caps = session.capabilities();
+ assert_eq!(caps.interrupt_main_turn, Some(false));
+ assert!(
+ caps.ui.is_none(),
+ "each capabilities.changed payload is a complete replacement snapshot"
+ );
}
#[tokio::test]
@@ -3308,7 +3419,7 @@ async fn env_value_mode_hardcoded_direct_on_create_and_resume() {
}
#[tokio::test]
-async fn resume_session_sends_canvas_fields_and_captures_open_canvases() {
+async fn resume_session_sends_canvas_fields_and_captures_snapshots() {
use github_copilot_sdk::types::ResumeSessionConfig;
let (client, mut server_read, mut server_write) = make_client();
@@ -3372,6 +3483,7 @@ async fn resume_session_sends_canvas_fields_and_captures_open_canvases() {
"url": "https://example.test/counter"
}],
"capabilities": {
+ "interruptMainTurn": false,
"ui": { "canvases": true }
}
},
@@ -3389,6 +3501,7 @@ async fn resume_session_sends_canvas_fields_and_captures_open_canvases() {
assert_eq!(open.len(), 1);
assert_eq!(open[0].instance_id, "counter-1");
let caps = session.capabilities();
+ assert_eq!(caps.interrupt_main_turn, Some(false));
assert_eq!(caps.ui.unwrap().canvases, Some(true));
}