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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,17 @@ When allowed and performed, `session.transcript_recovery()` returns
the planned backup path, invalid line numbers, and whether `session.start` was moved;
the backup is written on the next append rather than during resume.

Rust resumes await a best-effort `session.skills.reload` by default: returned
errors are logged, but an unanswered request delays resume completion.
`ResumeSessionConfig::with_reload_skills(false)` omits that automatic request
when the application owns required skill reconciliation before skill-dependent
work. It is SDK-local, does not disable skills, and does not prove the catalog
or disabled-skill preferences are current. Explicit
`session.rpc().skills().reload().await` remains available. This option does not
gate continued pending work or work already running on another connection;
applications must account for those paths in their own reconciliation barrier.
It does not cancel or settle a reload that was already issued.

After `Client::start` succeeds, inspect its startup cost without parsing logs:

```rust,ignore
Expand Down Expand Up @@ -1456,6 +1467,10 @@ larger results return a filesystem error before encoding or decoding.

### Rust-only API

Rust also exposes `ResumeSessionConfig::with_reload_skills` to control its
automatic post-resume skill reload; other SDKs such as Node and .NET do not
issue that automatic request. The Rust default is unchanged.

A handful of conveniences exist only on the Rust SDK as of 0.1.0. These
are surface areas where Rust idiom (newtypes, enums, trait objects)
gives a clearly nicer shape than Node/Python/Go/.NET currently expose. Rust
Expand Down
18 changes: 18 additions & 0 deletions rust/src/ahp_host/factory_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,24 @@ fn host_settings_round_trip_without_callbacks_or_losing_constraints() {
assert!(wire.get("mcpOauthTokenStorage").is_none());
}

#[test]
fn host_resume_settings_do_not_forward_local_skill_reload_policy() {
let config = crate::ResumeSessionConfig::new(crate::SessionId::from("skills"));
let expected = resume_config_for_host(&config).unwrap();
for reload in [false, true] {
assert_eq!(
resume_config_for_host(&config.clone().with_reload_skills(reload)).unwrap(),
expected
);
}
let decoded =
resume_config_from_host(&serde_json::from_value(expected.clone()).unwrap()).unwrap();
assert_eq!(decoded.reload_skills, None);
let mut unsupported = expected;
unsupported["reloadSkills"] = json!(false);
assert!(resume_config_from_host(&serde_json::from_value(unsupported).unwrap()).is_err());
}

#[tokio::test]
async fn release_before_request_poll_prevents_factory_invocation() {
let (client, mut peer) = fixture();
Expand Down
56 changes: 31 additions & 25 deletions rust/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1701,8 +1701,10 @@ impl Client {

/// Resume an existing session on the CLI.
///
/// Sends `session.resume` and `session.skills.reload`, registers the
/// session on the router, and spawns the event loop.
/// Sends `session.resume`, registers the session on the router, and
/// spawns the event loop. By default, also awaits a best-effort
/// `session.skills.reload`; [`ResumeSessionConfig::with_reload_skills(false)`](ResumeSessionConfig::with_reload_skills)
/// omits that request when the caller owns skill reconciliation.
///
/// All callbacks (event handler, hooks, transform) are configured
/// via [`ResumeSessionConfig`] using its `with_*` builder methods.
Expand Down Expand Up @@ -2084,8 +2086,10 @@ impl Client {

/// Resume an existing session on the CLI.
///
/// Sends `session.resume` and `session.skills.reload`, registers the
/// session on the router, and spawns the event loop.
/// Sends `session.resume`, registers the session on the router, and
/// spawns the event loop. By default, also awaits a best-effort
/// `session.skills.reload` unless [`ResumeSessionConfig::reload_skills`]
/// is explicitly `false`.
///
/// All callbacks (event handler, hooks, transform) are configured
/// via [`ResumeSessionConfig`] using its `with_*` builder methods.
Expand All @@ -2111,6 +2115,7 @@ impl Client {
None
};
let session_id = config.session_id.clone();
let reload_skills = config.reload_skills.unwrap_or(true);
if config.hooks_handler.is_some() && config.hooks.is_none() {
config.hooks = Some(true);
}
Expand Down Expand Up @@ -2327,27 +2332,28 @@ impl Client {
registration.cleanup(event_loop).await;
return Err(error);
}
// Reload skills after resume (best-effort).
let skills_reload_start = Instant::now();
if let Err(e) = self
.call(
"session.skills.reload",
Some(serde_json::json!({ "sessionId": session_id })),
)
.await
{
warn!(
elapsed_ms = skills_reload_start.elapsed().as_millis(),
session_id = %session_id,
error = %e,
"Client::resume_session skills reload request failed"
);
} else {
tracing::debug!(
elapsed_ms = skills_reload_start.elapsed().as_millis(),
session_id = %session_id,
"Client::resume_session skills reload request completed successfully"
);
if reload_skills {
let skills_reload_start = Instant::now();
if let Err(e) = self
.call(
"session.skills.reload",
Some(serde_json::json!({ "sessionId": session_id })),
)
.await
{
warn!(
elapsed_ms = skills_reload_start.elapsed().as_millis(),
session_id = %session_id,
error = %e,
"Client::resume_session skills reload request failed"
);
} else {
tracing::debug!(
elapsed_ms = skills_reload_start.elapsed().as_millis(),
session_id = %session_id,
"Client::resume_session skills reload request completed successfully"
);
}
}

*capabilities.write() = resume_result.capabilities.unwrap_or_default();
Expand Down
22 changes: 22 additions & 0 deletions rust/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3604,6 +3604,20 @@ impl SessionConfig {
pub struct ResumeSessionConfig {
/// ID of the session to resume.
pub session_id: SessionId,
/// Whether the SDK automatically reloads skills after the runtime resumes.
///
/// Unset or `true` preserves the awaited, best-effort
/// `session.skills.reload` call: returned errors are logged, but an
/// unanswered request still delays resume completion. Set `false` only
/// when the caller owns any required skill reconciliation before
/// skill-dependent work.
///
/// This SDK-local option is not sent to the runtime. It does not disable
/// skills or imply that the catalog or disabled-skill preferences are
/// current. It does not gate continued pending work or work already
/// running on another connection, nor cancel or settle an already-issued
/// reload.
pub reload_skills: Option<bool>,
/// Model to use for this session (e.g. `"gpt-4"`, `"claude-sonnet-4"`).
/// Can change the model when resuming.
pub model: Option<String>,
Expand Down Expand Up @@ -3900,6 +3914,7 @@ impl std::fmt::Debug for ResumeSessionConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ResumeSessionConfig")
.field("session_id", &self.session_id)
.field("reload_skills", &self.reload_skills)
.field("model", &self.model)
.field("allowed_models", &self.allowed_models)
.field("client_name", &self.client_name)
Expand Down Expand Up @@ -4206,6 +4221,7 @@ impl ResumeSessionConfig {
pub fn new(session_id: SessionId) -> Self {
Self {
session_id,
reload_skills: None,
model: None,
allowed_models: None,
client_name: None,
Expand Down Expand Up @@ -4302,6 +4318,12 @@ impl ResumeSessionConfig {
}
}

/// Set [`Self::reload_skills`], controlling the SDK's automatic skill reload.
pub fn with_reload_skills(mut self, reload_skills: bool) -> Self {
self.reload_skills = Some(reload_skills);
self
}

/// Install a [`PermissionHandler`] for the resumed session.
pub fn with_permission_handler(mut self, handler: Arc<dyn PermissionHandler>) -> Self {
self.permission_handler = Some(handler);
Expand Down
25 changes: 25 additions & 0 deletions rust/src/types/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1149,6 +1149,31 @@ fn resume_session_config_serializes_continue_pending_work_to_camel_case() {
assert!(json.get("continuePendingWork").is_none());
}

#[test]
fn resume_reload_skills_is_local_only_and_preserves_skill_configuration() {
let config = ResumeSessionConfig::new(SessionId::from("skills"))
.with_enable_skills(true)
.with_enable_config_discovery(true)
.with_skill_directories([PathBuf::from("skills")])
.with_disabled_skills(["disabled"]);
assert_eq!(config.reload_skills, None);
let (wire, _) = config.clone().into_wire().unwrap();
let expected = serde_json::to_value(wire).unwrap();
assert_eq!(expected["enableSkills"], true);
assert_eq!(expected["enableConfigDiscovery"], true);
assert_eq!(expected["skillDirectories"], json!(["skills"]));
assert_eq!(expected["disabledSkills"], json!(["disabled"]));

for reload in [false, true] {
let configured = config.clone().with_reload_skills(reload);
assert_eq!(configured.reload_skills, Some(reload));
assert_eq!(configured.clone().reload_skills, Some(reload));
assert!(format!("{configured:?}").contains(&format!("reload_skills: Some({reload})")));
let (wire, _) = configured.into_wire().unwrap();
assert_eq!(serde_json::to_value(wire).unwrap(), expected);
}
}

#[test]
fn resume_policy_and_recovery_report_round_trip() {
let config =
Expand Down
Loading
Loading