///
@@ -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..4440416ab 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 its descendant and
+ /// background work.
+ ///
+ /// Use [`interrupt_main_turn`](Self::interrupt_main_turn) when background
+ /// work must survive.
///
/// # Cancel safety
///
@@ -526,6 +530,33 @@ impl Session {
Ok(())
}
+ /// Interrupt only the active main coordinator turn while preserving
+ /// background agents and other background work.
+ ///
+ /// The default `flush_queued: false` discards queued prompts. Set
+ /// `flush_queued: true` to preserve queued user prompts for the next
+ /// eligible turn while dropping hidden system prompts.
+ ///
+ /// A result with `interrupted == false` means the method is supported but
+ /// no main turn was active. Older CLIs and unsupported remote sessions
+ /// return JSON-RPC `-32601`; that error is propagated without falling back
+ /// to recursive [`abort`](Self::abort).
+ ///
+ /// # Cancel safety
+ ///
+ /// **Cancel-safe.** A single `session.interruptMainTurn` RPC is dispatched
+ /// through 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.
diff --git a/rust/src/types.rs b/rust/src/types.rs
index b34d8fff4..c346d6837 100644
--- a/rust/src/types.rs
+++ b/rust/src/types.rs
@@ -5235,12 +5235,37 @@ 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 the host supports scoped interruption via
+ /// [`Session::interrupt_main_turn`](crate::session::Session::interrupt_main_turn).
+ ///
+ /// 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 while dropping
+ /// hidden system prompts. The default (`false`) discards queued prompts.
+ 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..82be2e709 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),
@@ -394,6 +393,7 @@ async fn session_capabilities_types_are_properly_structured() {
);
let empty = github_copilot_sdk::SessionCapabilities::default();
+ assert!(empty.interrupt_main_turn.is_none());
assert!(empty.ui.is_none());
}
diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs
index 122ec475d..03c725c63 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,82 @@ async fn session_rpc_methods_send_correct_method_names() {
}
}
+#[tokio::test]
+async fn interrupt_main_turn_sends_exact_options_and_returns_status() {
+ let (session, mut server) = create_session_pair().await;
+ let session = Arc::new(session);
+
+ for (flush_queued, interrupted) in [(None, true), (Some(false), false), (Some(true), true)] {
+ let handle = match flush_queued {
+ Some(value) => {
+ let session = session.clone();
+ tokio::spawn(async move {
+ session
+ .interrupt_main_turn(
+ InterruptMainTurnOptions::default().with_flush_queued(value),
+ )
+ .await
+ })
+ }
+ None => {
+ let session = session.clone();
+ tokio::spawn(async move {
+ session
+ .rpc()
+ .interrupt_main_turn(InterruptMainTurnRequest::default())
+ .await
+ })
+ }
+ };
+
+ let request = server.read_request().await;
+ assert_eq!(request["method"], "session.interruptMainTurn");
+ let expected_params = match flush_queued {
+ Some(value) => serde_json::json!({
+ "sessionId": server.session_id.clone(),
+ "flushQueued": value,
+ }),
+ None => serde_json::json!({ "sessionId": server.session_id.clone() }),
+ };
+ assert_eq!(request["params"], expected_params);
+ 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(50), 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 +3220,7 @@ async fn capabilities_captured_from_create_response() {
"result": {
"sessionId": session_id,
"capabilities": {
+ "interruptMainTurn": true,
"ui": { "elicitation": true }
}
},
@@ -3142,40 +3229,67 @@ 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() {
+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;
+
+ assert_eq!(session.capabilities().interrupt_main_turn, None);
+}
+
+#[tokio::test]
+async fn capabilities_changed_event_replaces_session_snapshot() {
let (session, mut server) = create_session_pair().await;
+ let mut events = session.subscribe();
- // Initially no capabilities (create_session_pair doesn't send them)
+ assert_eq!(session.capabilities().interrupt_main_turn, None);
assert!(session.capabilities().ui.is_none());
- // CLI sends capabilities.changed event
server
.send_event(
"capabilities.changed",
serde_json::json!({
+ "interruptMainTurn": true,
"ui": { "elicitation": true }
}),
)
.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 event should arrive within timeout")
+ .unwrap();
+ let changed = event.typed_data::().unwrap();
+ assert_eq!(changed.interrupt_main_turn, Some(true));
+ let caps = session.capabilities();
+ assert_eq!(caps.interrupt_main_turn, Some(true));
assert_eq!(caps.ui.as_ref().unwrap().elicitation, Some(true));
+
+ server
+ .send_event(
+ "capabilities.changed",
+ serde_json::json!({ "interruptMainTurn": false }),
+ )
+ .await;
+
+ let event = timeout(TIMEOUT, events.recv())
+ .await
+ .expect("replacement capabilities event should arrive within timeout")
+ .unwrap();
+ let changed = event.typed_data::().unwrap();
+ assert_eq!(changed.interrupt_main_turn, Some(false));
+ assert!(changed.ui.is_none());
+
+ let caps = session.capabilities();
+ assert_eq!(caps.interrupt_main_turn, Some(false));
+ assert!(caps.ui.is_none());
}
#[tokio::test]
@@ -3308,7 +3422,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 +3486,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 +3504,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));
}