From 9325bab9d376e8ccdd090e95fefd245832f4994c Mon Sep 17 00:00:00 2001 From: Costas Panay Date: Thu, 16 Jul 2026 18:12:37 -0700 Subject: [PATCH 1/2] SDK: Add scoped main-turn interruption Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a1e1fcdd-b581-4c40-90e3-bde51729fd00 --- rust/README.md | 11 ++- rust/src/generated/api_types.rs | 60 +++++++++++++++- rust/src/generated/rpc.rs | 39 +++++++++- rust/src/session.rs | 35 ++++++++- rust/src/types.rs | 15 ++-- rust/tests/e2e/elicitation.rs | 1 + rust/tests/session_test.rs | 122 +++++++++++++++++++++++++++++--- 7 files changed, 257 insertions(+), 26 deletions(-) diff --git a/rust/README.md b/rust/README.md index 254a2b2167..a37a4c9c99 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::{InterruptMainTurnRequest, 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 +// Stop only the foreground turn while preserving background work. +let result = session + .interrupt_main_turn(Some(InterruptMainTurnRequest { + flush_queued: Some(true), + })) + .await?; + +// Recursively abort the active turn and all descendant/background work. session.abort().await?; // Model management diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index e243ec1dc2..982184939d 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 403933a27c..333cd5d84a 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/session.rs b/rust/src/session.rs index 35656c657f..dbbbe5d739 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, @@ -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,31 @@ impl Session { Ok(()) } + /// Interrupt only the active main coordinator turn while preserving + /// background agents and other background work. + /// + /// Pass `None` or set `flush_queued` to `Some(false)` to discard queued + /// prompts. Set it to `Some(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: Option, + ) -> Result { + self.rpc() + .interrupt_main_turn(options.unwrap_or_default()) + .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 b34d8fff45..8de872d93d 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -5236,6 +5236,12 @@ pub struct ElicitationRequest { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionCapabilities { + /// Whether the host supports scoped interruption of the active 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, @@ -5313,10 +5319,11 @@ impl InputFormat { /// [`crate::rpc`]; they live here so the crate-root /// `pub use types::*` surfaces them alongside hand-written SDK types. pub use crate::generated::api_types::{ - Model, ModelBilling, ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, - ModelCapabilities, ModelCapabilitiesLimits, ModelCapabilitiesLimitsVision, - ModelCapabilitiesSupports, ModelList, ModelPolicy, PermissionDecision, - PermissionDecisionApproveOnce, PermissionDecisionReject, PermissionDecisionUserNotAvailable, + InterruptMainTurnRequest, InterruptMainTurnResult, Model, ModelBilling, + ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, ModelCapabilities, + ModelCapabilitiesLimits, ModelCapabilitiesLimitsVision, ModelCapabilitiesSupports, ModelList, + ModelPolicy, PermissionDecision, PermissionDecisionApproveOnce, PermissionDecisionReject, + PermissionDecisionUserNotAvailable, }; /// Permission categories the CLI may request approval for. diff --git a/rust/tests/e2e/elicitation.rs b/rust/tests/e2e/elicitation.rs index 5575e67f30..83cdf77082 100644 --- a/rust/tests/e2e/elicitation.rs +++ b/rust/tests/e2e/elicitation.rs @@ -381,6 +381,7 @@ async fn elicitation_returns_all_action_shapes() { #[tokio::test] async fn session_capabilities_types_are_properly_structured() { let capabilities = github_copilot_sdk::SessionCapabilities { + interrupt_main_turn: None, ui: Some(UiCapabilities { elicitation: Some(true), mcp_apps: None, diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 122ec475df..33f286b4a6 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -22,8 +22,8 @@ use github_copilot_sdk::session_events::{ 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, InterruptMainTurnRequest, 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,64 @@ 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 options = flush_queued.map(|value| InterruptMainTurnRequest { + flush_queued: Some(value), + }); + 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"); + 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(None).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 +3202,7 @@ async fn capabilities_captured_from_create_response() { "result": { "sessionId": session_id, "capabilities": { + "interruptMainTurn": true, "ui": { "elicitation": true } } }, @@ -3142,32 +3211,42 @@ 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; - // 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 { + timeout(TIMEOUT, async { loop { let caps = session.capabilities(); - if caps.ui.is_some() { - return caps; + if caps.interrupt_main_turn == Some(true) && caps.ui.is_some() { + break; } tokio::time::sleep(Duration::from_millis(5)).await; } @@ -3175,7 +3254,26 @@ async fn capabilities_changed_event_updates_session() { .await .expect("capabilities should update within timeout"); - assert_eq!(caps.ui.as_ref().unwrap().elicitation, Some(true)); + server + .send_event( + "capabilities.changed", + serde_json::json!({ "interruptMainTurn": false }), + ) + .await; + + let caps = timeout(TIMEOUT, async { + loop { + let caps = session.capabilities(); + if caps.interrupt_main_turn == Some(false) { + break caps; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("replacement capabilities should update within timeout"); + + assert!(caps.ui.is_none()); } #[tokio::test] @@ -3308,7 +3406,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 +3470,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 +3488,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)); } From 7d7327bdc1ba834bdace8c2ee7f6f1a9b1d407b0 Mon Sep 17 00:00:00 2001 From: Costas Panay Date: Thu, 16 Jul 2026 18:35:58 -0700 Subject: [PATCH 2/2] Rust: Align interrupt API with final runtime schema Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a1e1fcdd-b581-4c40-90e3-bde51729fd00 --- rust/README.md | 8 +-- rust/src/generated/session_events.rs | 23 ++++++++ rust/src/session.rs | 18 +++--- rust/src/types.rs | 30 ++++++++-- rust/tests/e2e/elicitation.rs | 15 +++-- rust/tests/session_test.rs | 82 +++++++++++++++++----------- 6 files changed, 117 insertions(+), 59 deletions(-) diff --git a/rust/README.md b/rust/README.md index a37a4c9c99..2898c03157 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::{InterruptMainTurnRequest, 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. @@ -109,9 +109,9 @@ let messages = session.get_events().await?; // Stop only the foreground turn while preserving background work. let result = session - .interrupt_main_turn(Some(InterruptMainTurnRequest { - flush_queued: Some(true), - })) + .interrupt_main_turn( + InterruptMainTurnOptions::default().with_flush_queued(true), + ) .await?; // Recursively abort the active turn and all descendant/background work. diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index cf670c4856..5b75742bd2 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 dbbbe5d739..4440416abf 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -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, }; @@ -533,9 +533,9 @@ impl Session { /// Interrupt only the active main coordinator turn while preserving /// background agents and other background work. /// - /// Pass `None` or set `flush_queued` to `Some(false)` to discard queued - /// prompts. Set it to `Some(true)` to preserve queued user prompts for the - /// next eligible turn while dropping hidden system prompts. + /// 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 @@ -548,10 +548,12 @@ impl Session { /// through the writer-actor. pub async fn interrupt_main_turn( &self, - options: Option, + options: InterruptMainTurnOptions, ) -> Result { self.rpc() - .interrupt_main_turn(options.unwrap_or_default()) + .interrupt_main_turn(InterruptMainTurnRequest { + flush_queued: Some(options.flush_queued), + }) .await } diff --git a/rust/src/types.rs b/rust/src/types.rs index 8de872d93d..c346d68377 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -5235,8 +5235,10 @@ 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 of the active main turn. + /// 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`). @@ -5247,6 +5249,23 @@ pub struct SessionCapabilities { 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")] @@ -5319,11 +5338,10 @@ impl InputFormat { /// [`crate::rpc`]; they live here so the crate-root /// `pub use types::*` surfaces them alongside hand-written SDK types. pub use crate::generated::api_types::{ - InterruptMainTurnRequest, InterruptMainTurnResult, Model, ModelBilling, - ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, ModelCapabilities, - ModelCapabilitiesLimits, ModelCapabilitiesLimitsVision, ModelCapabilitiesSupports, ModelList, - ModelPolicy, PermissionDecision, PermissionDecisionApproveOnce, PermissionDecisionReject, - PermissionDecisionUserNotAvailable, + Model, ModelBilling, ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, + ModelCapabilities, ModelCapabilitiesLimits, ModelCapabilitiesLimitsVision, + ModelCapabilitiesSupports, ModelList, ModelPolicy, PermissionDecision, + PermissionDecisionApproveOnce, PermissionDecisionReject, PermissionDecisionUserNotAvailable, }; /// Permission categories the CLI may request approval for. diff --git a/rust/tests/e2e/elicitation.rs b/rust/tests/e2e/elicitation.rs index 83cdf77082..82be2e709d 100644 --- a/rust/tests/e2e/elicitation.rs +++ b/rust/tests/e2e/elicitation.rs @@ -380,14 +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 { - interrupt_main_turn: None, - 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), @@ -395,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 33f286b4a6..03c725c637 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -14,15 +14,15 @@ 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, InterruptMainTurnRequest, MessageOptions, RequestId, + ExitPlanModeData, ExtensionInfo, InterruptMainTurnOptions, MessageOptions, RequestId, SessionConfig, SessionId, SetModelOptions, Tool, ToolInvocation, ToolResult, }; use github_copilot_sdk::{Client, ContextTier, ErrorKind, ProtocolErrorKind, tool}; @@ -1303,13 +1303,27 @@ async fn interrupt_main_turn_sends_exact_options_and_returns_status() { let session = Arc::new(session); for (flush_queued, interrupted) in [(None, true), (Some(false), false), (Some(true), true)] { - let options = flush_queued.map(|value| InterruptMainTurnRequest { - flush_queued: Some(value), - }); - let handle = tokio::spawn({ - let session = session.clone(); - async move { session.interrupt_main_turn(options).await } - }); + 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"); @@ -1333,7 +1347,11 @@ async fn interrupt_main_turn_sends_exact_options_and_returns_status() { #[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(None).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"); @@ -3228,6 +3246,7 @@ async fn missing_interrupt_capability_from_older_server_remains_unknown() { #[tokio::test] async fn capabilities_changed_event_replaces_session_snapshot() { let (session, mut server) = create_session_pair().await; + let mut events = session.subscribe(); assert_eq!(session.capabilities().interrupt_main_turn, None); assert!(session.capabilities().ui.is_none()); @@ -3242,17 +3261,16 @@ async fn capabilities_changed_event_replaces_session_snapshot() { ) .await; - timeout(TIMEOUT, async { - loop { - let caps = session.capabilities(); - if caps.interrupt_main_turn == Some(true) && caps.ui.is_some() { - break; - } - 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( @@ -3261,18 +3279,16 @@ async fn capabilities_changed_event_replaces_session_snapshot() { ) .await; - let caps = timeout(TIMEOUT, async { - loop { - let caps = session.capabilities(); - if caps.interrupt_main_turn == Some(false) { - break caps; - } - tokio::time::sleep(Duration::from_millis(5)).await; - } - }) - .await - .expect("replacement capabilities should update within timeout"); + 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()); }