From bc73ae388e8b4b6e132dcbffec0835233250f4e2 Mon Sep 17 00:00:00 2001 From: user Date: Mon, 24 Aug 2026 21:38:43 +0800 Subject: [PATCH 1/2] feat(session): add rename action to session control tool Extend the session control tool family with a rename action so a caller can rename a persisted session title through the same AgentSessionManagementPort channel used by the frontend renameChatSessionTitle. - Extend SessionControlAction with a Rename variant and as_str mapping. - Allow session_name as the required new title for rename (rejected for every other mutating action) and reject renaming the current session. - Add session_control_renamed_result_message helper for the tool result text. - Wire the Rename dispatch to reuse the upstream AgentSessionRenameRequest. - Extend the tool input schema/description and add rename validation tests. Test: cargo check -p bitfun-agent-runtime --jobs 4 EXIT 0; cargo check -p bitfun-core --jobs 4 EXIT 0; cargo test -p bitfun-agent-runtime --features agent-runtime rename --jobs 4 (7 tests pass); cargo test -p bitfun-core --features agent-runtime session_control_tool --jobs 4 (11 tests pass incl validate_rename_*). AI: lightly tested (targeted cargo check + session_control/session_control_tool unit tests). --- .../implementations/session_control_tool.rs | 155 +++++++++++++++-- .../agent-runtime/src/session_control.rs | 157 +++++++++++++++++- 2 files changed, 298 insertions(+), 14 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index 5b2237d879..566fef52fc 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs @@ -18,15 +18,16 @@ use bitfun_agent_runtime::session_control::{ session_control_agent_type_or_default, session_control_cancel_result_message, session_control_cancel_status, session_control_created_result_message, session_control_creator_marker, session_control_deleted_result_message, - session_control_session_name_or_default, validate_session_control_input, validate_session_id, - SessionControlAction, SessionControlCancelRoute, SessionControlInput, - SessionControlValidationContext, SessionControlValidationResult, + session_control_renamed_result_message, session_control_session_name_or_default, + validate_session_control_input, validate_session_id, SessionControlAction, + SessionControlCancelRoute, SessionControlInput, SessionControlValidationContext, + SessionControlValidationResult, }; use bitfun_core_types::SessionExecutionTarget; use bitfun_runtime_ports::{ AgentSessionCreateRequest, AgentSessionDeleteRequest, AgentSessionListRequest, - AgentSessionSummary, AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, - AgentSubmissionSource, AgentTurnCancellationRequest, + AgentSessionRenameRequest, AgentSessionSummary, AgentSessionWorkspaceBinding, + AgentSessionWorkspaceRequest, AgentSubmissionSource, AgentTurnCancellationRequest, }; use serde_json::{json, Value}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -101,7 +102,9 @@ impl SessionControlTool { runtime: &AgentRuntime, ) -> BitFunResult { match action { - SessionControlAction::Cancel | SessionControlAction::Delete => { + SessionControlAction::Cancel + | SessionControlAction::Delete + | SessionControlAction::Rename => { let session_id = session_id.ok_or_else(|| { BitFunError::tool(format!("session_id is required for {}", action.as_str())) })?; @@ -271,23 +274,24 @@ Actions: - "create": Create a new session. You may optionally provide session_name and agent_type. - "cancel": Cancel the target session's currently running dialog turn. This does not delete the session or clear any queued messages that may still run later. - "delete": Delete an existing session by session_id. +- "rename": Rename an existing session by session_id using session_name as the new title. - "list": List all sessions. Arguments: - "workspace": Absolute workspace path. Required for create and list. Ignored for cancel and delete. -- "session_name": Only used by create. Defaults to "New Session". +- "session_name": Used by create (defaults to "New Session") and required as the new title for rename. - "agent_type": Only used by create. Defaults to "agentic". - "agentic": Coding-focused agent for implementation, debugging, and code changes. - "Plan": Planning agent for clarifying requirements and producing an implementation plan before coding. - "Cowork": Collaborative agent for office-style work such as research, documentation, presentations, etc. - "DeepResearch": Research agent for systematic investigation and evidence-driven reports. -- "session_id": Required for cancel and delete."# +- "session_id": Required for cancel, delete, and rename."# .to_string(), ) } fn short_description(&self) -> String { - "Create, list, cancel, and delete persisted agent sessions.".to_string() + "Create, list, rename, cancel, and delete persisted agent sessions.".to_string() } fn default_exposure(&self) -> ToolExposure { @@ -300,7 +304,7 @@ Arguments: "properties": { "action": { "type": "string", - "enum": ["create", "cancel", "delete", "list"], + "enum": ["create", "cancel", "delete", "rename", "list"], "description": "The session action to perform." }, "workspace": { @@ -309,11 +313,11 @@ Arguments: }, "session_id": { "type": "string", - "description": "Required for cancel and delete." + "description": "Required for cancel, delete, and rename." }, "session_name": { "type": "string", - "description": "Optional display name when creating a session." + "description": "Optional display name when creating a session; required as the new title when renaming." }, "agent_type": { "type": "string", @@ -571,6 +575,73 @@ Arguments: image_attachments: None, }]) } + SessionControlAction::Rename => { + let session_id = params.session_id.as_deref().ok_or_else(|| { + BitFunError::tool("session_id is required for rename".to_string()) + })?; + validate_session_id(session_id).map_err(BitFunError::tool)?; + let session_name = params + .session_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + BitFunError::tool( + "session_name is required and must not be empty for rename".to_string(), + ) + })?; + let workspace = self + .resolve_effective_workspace( + SessionControlAction::Rename, + Some(session_id), + context, + &runtime, + ) + .await?; + if self.current_workspace_session(context, &workspace.display_workspace) + == Some(session_id) + { + return Err(BitFunError::tool( + "cannot rename the current session from SessionControl".to_string(), + )); + } + + // Reuse the same rename channel as the frontend + // renameChatSessionTitle (AgentSessionManagementPort::rename_session) + // so the persisted title stays consistent with the desktop/frontend. + runtime + .rename_session(AgentSessionRenameRequest { + workspace_path: workspace.display_workspace.clone(), + session_id: session_id.to_string(), + session_name: session_name.to_string(), + remote_connection_id: workspace.remote_connection_id.clone(), + remote_ssh_host: workspace.remote_ssh_host.clone(), + }) + .await + .map_err(|error| { + BitFunError::tool(format!( + "cannot rename session '{session_id}': {}", + CoreServiceAgentRuntime::runtime_error_message(error) + )) + })?; + + let result_for_assistant = session_control_renamed_result_message( + session_id, + &workspace.display_workspace, + session_name, + ); + Ok(vec![ToolResult::Result { + data: json!({ + "success": true, + "action": "rename", + "workspace": workspace.display_workspace.clone(), + "session_id": session_id, + "session_name": session_name, + }), + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]) + } SessionControlAction::List => { let workspace = self .resolve_effective_workspace( @@ -825,4 +896,64 @@ mod tests { assert_eq!(message, "Cancel active turn for session worker_1"); } + + #[tokio::test] + async fn validate_rename_requires_session_name() { + let tool = SessionControlTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "rename", + "session_id": "worker_1", + }), + Some(&empty_context()), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("session_name is required for rename") + ); + } + + #[tokio::test] + async fn validate_rename_requires_session_id() { + let tool = SessionControlTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "rename", + "session_name": "new-title", + }), + Some(&empty_context()), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("session_id is required for rename") + ); + } + + #[tokio::test] + async fn validate_rename_accepts_session_id_and_name() { + let tool = SessionControlTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "rename", + "session_id": "worker_1", + "session_name": "new-title", + }), + Some(&empty_context()), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } } diff --git a/src/crates/execution/agent-runtime/src/session_control.rs b/src/crates/execution/agent-runtime/src/session_control.rs index b660e901c7..635dd85227 100644 --- a/src/crates/execution/agent-runtime/src/session_control.rs +++ b/src/crates/execution/agent-runtime/src/session_control.rs @@ -11,6 +11,7 @@ pub enum SessionControlAction { Cancel, Delete, List, + Rename, } impl SessionControlAction { @@ -20,6 +21,7 @@ impl SessionControlAction { Self::Cancel => "cancel", Self::Delete => "delete", Self::List => "list", + Self::Rename => "rename", } } } @@ -159,9 +161,20 @@ fn validate_mutating_action_target( if input.agent_type.is_some() { return invalid("agent_type is only allowed for create"); } - if input.session_name.is_some() { + // `rename` carries the new session title via session_name; every other + // mutating action rejects it (only `create` otherwise accepts session_name). + if input.session_name.is_some() && !matches!(action, SessionControlAction::Rename) { return invalid("session_name is only allowed for create"); } + // `rename` requires a non-empty new title. + if matches!(action, SessionControlAction::Rename) { + let Some(session_name) = input.session_name.as_deref() else { + return invalid("session_name is required for rename"); + }; + if session_name.trim().is_empty() { + return invalid("session_name must not be empty for rename"); + } + } let Some(session_id) = input.session_id.as_deref() else { return invalid(format!("session_id is required for {}", action.as_str())); @@ -211,7 +224,9 @@ pub fn validate_session_control_input( return invalid("create requires a creator session in tool context"); } } - SessionControlAction::Cancel | SessionControlAction::Delete => { + SessionControlAction::Cancel + | SessionControlAction::Delete + | SessionControlAction::Rename => { return validate_mutating_action_target(&input.action, input, context); } SessionControlAction::List => { @@ -251,6 +266,7 @@ pub fn render_session_control_tool_use_message(input: &Value) -> String { "create" => format!("Create session in {workspace}"), "cancel" => format!("Cancel active turn for session {session_id}"), "delete" => format!("Delete session {session_id}"), + "rename" => format!("Rename session {session_id}"), "list" => format!("List sessions in {workspace}"), _ => format!("Manage sessions in {workspace}"), } @@ -291,3 +307,140 @@ pub fn session_control_cancel_result_message( pub fn session_control_deleted_result_message(session_id: &str, workspace: &str) -> String { format!("Deleted session '{session_id}' from workspace '{workspace}'.") } + +pub fn session_control_renamed_result_message( + session_id: &str, + workspace: &str, + session_name: &str, +) -> String { + format!("Renamed session '{session_id}' to '{session_name}' in workspace '{workspace}'.") +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn context(current: Option<&str>) -> SessionControlValidationContext<'_> { + SessionControlValidationContext { + current_session_id: current, + has_workspace_root: true, + } + } + + #[test] + fn rename_action_parses_payload_session_id_and_name() { + let input: SessionControlInput = serde_json::from_value(json!({ + "action": "rename", + "session_id": "worker_1", + "session_name": "new-title", + })) + .expect("rename payload must parse"); + assert_eq!(input.action, SessionControlAction::Rename); + assert_eq!(input.session_id.as_deref(), Some("worker_1")); + assert_eq!(input.session_name.as_deref(), Some("new-title")); + assert_eq!(SessionControlAction::Rename.as_str(), "rename"); + } + + #[test] + fn rename_validation_requires_session_id() { + let input = SessionControlInput { + action: SessionControlAction::Rename, + workspace: None, + session_id: None, + session_name: Some("new-title".to_string()), + agent_type: None, + }; + let result = validate_session_control_input(&input, context(None)); + assert!(!result.result); + assert!(result + .message + .as_deref() + .unwrap_or_default() + .contains("session_id is required")); + } + + #[test] + fn rename_validation_requires_session_name() { + let input = SessionControlInput { + action: SessionControlAction::Rename, + workspace: None, + session_id: Some("worker_1".to_string()), + session_name: None, + agent_type: None, + }; + let result = validate_session_control_input(&input, context(None)); + assert!(!result.result); + assert!(result + .message + .as_deref() + .unwrap_or_default() + .contains("session_name is required for rename")); + } + + #[test] + fn rename_validation_rejects_blank_session_name() { + let input = SessionControlInput { + action: SessionControlAction::Rename, + workspace: None, + session_id: Some("worker_1".to_string()), + session_name: Some(" ".to_string()), + agent_type: None, + }; + let result = validate_session_control_input(&input, context(None)); + assert!(!result.result); + assert_eq!( + result.message.as_deref(), + Some("session_name must not be empty for rename") + ); + } + + #[test] + fn rename_validation_accepts_valid_input() { + let input = SessionControlInput { + action: SessionControlAction::Rename, + workspace: None, + session_id: Some("worker_1".to_string()), + session_name: Some("new-title".to_string()), + agent_type: None, + }; + let result = validate_session_control_input(&input, context(None)); + assert!(result.result, "{:?}", result.message); + } + + #[test] + fn rename_validation_rejects_current_session() { + let input = SessionControlInput { + action: SessionControlAction::Rename, + workspace: None, + session_id: Some("self_1".to_string()), + session_name: Some("new-title".to_string()), + agent_type: None, + }; + let result = validate_session_control_input(&input, context(Some("self_1"))); + assert!(!result.result); + assert!(result + .message + .as_deref() + .unwrap_or_default() + .contains("cannot rename the current session")); + } + + #[test] + fn rename_render_mentions_session() { + let rendered = render_session_control_tool_use_message(&json!({ + "action": "rename", + "session_id": "worker_1", + })); + assert!(rendered.contains("Rename session")); + assert!(rendered.contains("worker_1")); + } + + #[test] + fn renamed_result_message_mentions_id_and_new_name() { + let message = session_control_renamed_result_message("worker_1", "/ws", "new-title"); + assert!(message.contains("worker_1")); + assert!(message.contains("new-title")); + assert!(message.contains("/ws")); + } +} From 53f570bb9f4a7a007b319f427d7196b326191c52 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 25 Aug 2026 07:41:29 +0800 Subject: [PATCH 2/2] feat(session): add compact action to session control tool Add the compact action to the session control tool family so a caller can compact a session context to reduce its token footprint and surface the applied compaction statistics (tokens/ratio/summary). - Thread ContextCompactionOutcome through the manual compaction task so the finished outcome is returned instead of being discarded (unit). - Add compact_session_with_outcome returning BitFunResult; compact_session_manually delegates to it for the Desktop compatibility API. - Extend SessionControlAction with a Compact variant and its as_str mapping. - Allow compacting the caller own session (the sole self-mutation exception) while keeping owner/creator/ancestor authorization inline. - Wire the Compact dispatch to the compact engine and return the applied stats. - Extend the tool input schema/description and add compact validation tests. Test: cargo check -p bitfun-core --jobs 4 EXIT 0; cargo check -p bitfun-agent-runtime --jobs 4 EXIT 0; cargo test -p bitfun-agent-runtime --features agent-runtime compact --jobs 4 (5 tests pass); cargo test -p bitfun-core --features agent-runtime session_control_tool --jobs 4 (14 tests pass incl validate_compact_*); cargo test -p bitfun-core --features agent-runtime manual_compaction --jobs 4 (6 tests pass). AI: lightly tested (targeted cargo check + compact/session_control_tool/manual_compaction unit tests). --- .../src/agentic/coordination/coordinator.rs | 18 +- .../implementations/session_control_tool.rs | 176 +++++++++++++++++- .../agent-runtime/src/session_control.rs | 86 ++++++++- 3 files changed, 272 insertions(+), 8 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 9e5cda3d2c..510803ae90 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -789,7 +789,7 @@ struct SessionExecutionLease { struct ManualCompactionTask { turn_id: String, - completion: oneshot::Receiver>, + completion: oneshot::Receiver>, } struct ManualCompactionControlGuard { @@ -5451,7 +5451,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet remote_exec_port: Option>, cancellation_token: CancellationToken, commit_gate: Arc, - ) -> BitFunResult<()> { + ) -> BitFunResult { let manual_workspace_services = Self::build_workspace_services(&manual_workspace).await; let manual_execution_context = ExecutionContext { session_id: session_id.clone(), @@ -5516,7 +5516,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet &outcome, context_window, ) - .await + .await?; + Ok(outcome) } Err(err @ BitFunError::Cancelled(_)) => { let error_text = err.to_string(); @@ -5584,6 +5585,17 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet /// task used by Agent Runtime callers, then await its terminal result for /// the existing Desktop compatibility API. pub async fn compact_session_manually(&self, session_id: String) -> BitFunResult<()> { + self.compact_session_with_outcome(session_id) + .await + .map(|_| ()) + } + + /// Compact the active session context and return the compaction outcome + /// (tokens/ratio/summary) so tool callers can surface the applied result. + pub async fn compact_session_with_outcome( + &self, + session_id: String, + ) -> BitFunResult { let task = self.start_manual_compaction_task(session_id, None).await?; task.completion.await.map_err(|_| { BitFunError::Service(format!( diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index 566fef52fc..165b9c04d2 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs @@ -104,6 +104,7 @@ impl SessionControlTool { match action { SessionControlAction::Cancel | SessionControlAction::Delete + | SessionControlAction::Compact | SessionControlAction::Rename => { let session_id = session_id.ok_or_else(|| { BitFunError::tool(format!("session_id is required for {}", action.as_str())) @@ -274,6 +275,7 @@ Actions: - "create": Create a new session. You may optionally provide session_name and agent_type. - "cancel": Cancel the target session's currently running dialog turn. This does not delete the session or clear any queued messages that may still run later. - "delete": Delete an existing session by session_id. +- "compact": Compact the target session's context to reduce its token footprint. Returns the applied compaction statistics. - "rename": Rename an existing session by session_id using session_name as the new title. - "list": List all sessions. @@ -285,13 +287,13 @@ Arguments: - "Plan": Planning agent for clarifying requirements and producing an implementation plan before coding. - "Cowork": Collaborative agent for office-style work such as research, documentation, presentations, etc. - "DeepResearch": Research agent for systematic investigation and evidence-driven reports. -- "session_id": Required for cancel, delete, and rename."# +- "session_id": Required for cancel, delete, compact, and rename."# .to_string(), ) } fn short_description(&self) -> String { - "Create, list, rename, cancel, and delete persisted agent sessions.".to_string() + "Create, list, rename, compact, cancel, and delete persisted agent sessions.".to_string() } fn default_exposure(&self) -> ToolExposure { @@ -304,7 +306,7 @@ Arguments: "properties": { "action": { "type": "string", - "enum": ["create", "cancel", "delete", "rename", "list"], + "enum": ["create", "cancel", "delete", "compact", "rename", "list"], "description": "The session action to perform." }, "workspace": { @@ -313,7 +315,7 @@ Arguments: }, "session_id": { "type": "string", - "description": "Required for cancel, delete, and rename." + "description": "Required for cancel, delete, compact, and rename." }, "session_name": { "type": "string", @@ -642,6 +644,113 @@ Arguments: image_attachments: None, }]) } + SessionControlAction::Compact => { + let session_id = params.session_id.as_deref().ok_or_else(|| { + BitFunError::tool("session_id is required for compact".to_string()) + })?; + validate_session_id(session_id).map_err(BitFunError::tool)?; + let workspace = self + .resolve_effective_workspace( + SessionControlAction::Compact, + Some(session_id), + context, + &runtime, + ) + .await?; + + // Authorization follows the owner/creator/ancestor semantics and + // additionally permits compacting the caller's own session. + let current_session_id = context.session_id.as_ref().ok_or_else(|| { + BitFunError::tool( + "cannot compact a session without a caller session in tool context" + .to_string(), + ) + })?; + let session_manager = coordinator.get_session_manager(); + let caller_is_owner = session_manager + .get_session(current_session_id) + .is_some_and(|session| session.created_by.is_none()); + let is_self = current_session_id == session_id; + let created_by_match = session_manager + .load_session_metadata( + std::path::Path::new(&workspace.project_workspace), + session_id, + ) + .await + .ok() + .flatten() + .and_then(|metadata| metadata.created_by) + .is_some_and(|creator| { + creator == session_control_creator_marker(current_session_id) + }); + if !caller_is_owner && !is_self && !created_by_match { + let mut ancestors = Vec::new(); + let mut visited = std::collections::HashSet::new(); + visited.insert(session_id.to_string()); + let mut current = session_id.to_string(); + loop { + let metadata = session_manager + .load_session_metadata( + std::path::Path::new(&workspace.project_workspace), + ¤t, + ) + .await + .ok() + .flatten(); + match metadata + .and_then(|m| m.relationship.and_then(|r| r.parent_session_id)) + { + Some(parent_id) => { + if !visited.insert(parent_id.clone()) { + break; + } + ancestors.push(parent_id.clone()); + current = parent_id; + } + None => break, + } + } + if !ancestors.is_empty() && !ancestors.contains(current_session_id) { + return Err(BitFunError::tool(format!( + "session '{current_session_id}' is not authorized to compact session '{session_id}': not a parent/ancestor and not the creator" + ))); + } + } + + let outcome = coordinator + .compact_session_with_outcome(session_id.to_string()) + .await + .map_err(|error| { + BitFunError::tool(format!( + "cannot compact session '{session_id}': {}", + error + )) + })?; + + Ok(vec![ToolResult::Result { + data: json!({ + "success": true, + "action": "compact", + "workspace": workspace.display_workspace.clone(), + "session_id": session_id, + "applied": outcome.applied, + "tokens_before": outcome.tokens_before, + "tokens_after": outcome.tokens_after, + "compression_ratio": outcome.compression_ratio, + "duration": outcome.duration_ms, + "summary_source": if outcome.has_summary { + Some(outcome.summary_source) + } else { + None + }, + }), + result_for_assistant: Some(format!( + "Compacted session '{session_id}' in workspace '{}'.", + workspace.display_workspace + )), + image_attachments: None, + }]) + } SessionControlAction::List => { let workspace = self .resolve_effective_workspace( @@ -956,4 +1065,63 @@ mod tests { assert!(validation.result, "{:?}", validation.message); } + + #[tokio::test] + async fn validate_compact_requires_session_id() { + let tool = SessionControlTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "compact", + }), + Some(&empty_context()), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("session_id is required for compact") + ); + } + + #[tokio::test] + async fn validate_compact_rejects_session_name() { + let tool = SessionControlTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "compact", + "session_id": "worker_1", + "session_name": "should-not-be-here", + }), + Some(&empty_context()), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("session_name is only allowed for create") + ); + } + + #[tokio::test] + async fn validate_compact_accepts_session_id() { + let tool = SessionControlTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "compact", + "session_id": "worker_1", + }), + Some(&empty_context()), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } } diff --git a/src/crates/execution/agent-runtime/src/session_control.rs b/src/crates/execution/agent-runtime/src/session_control.rs index 635dd85227..40c16d2426 100644 --- a/src/crates/execution/agent-runtime/src/session_control.rs +++ b/src/crates/execution/agent-runtime/src/session_control.rs @@ -11,6 +11,7 @@ pub enum SessionControlAction { Cancel, Delete, List, + Compact, Rename, } @@ -21,6 +22,7 @@ impl SessionControlAction { Self::Cancel => "cancel", Self::Delete => "delete", Self::List => "list", + Self::Compact => "compact", Self::Rename => "rename", } } @@ -183,7 +185,13 @@ fn validate_mutating_action_target( return invalid(message); } - if context.current_session_id == Some(session_id) && context.has_workspace_root { + // Guard only depends on session-binding equivalence: if the target is the + // current session it is refused. `compact` is the sole exception (it may + // compress the current session and resident subagent workstations). + if !matches!(action, SessionControlAction::Compact) + && context.current_session_id == Some(session_id) + && context.has_workspace_root + { return invalid(format!( "cannot {} the current session from SessionControl", action.as_str() @@ -226,6 +234,7 @@ pub fn validate_session_control_input( } SessionControlAction::Cancel | SessionControlAction::Delete + | SessionControlAction::Compact | SessionControlAction::Rename => { return validate_mutating_action_target(&input.action, input, context); } @@ -266,6 +275,7 @@ pub fn render_session_control_tool_use_message(input: &Value) -> String { "create" => format!("Create session in {workspace}"), "cancel" => format!("Cancel active turn for session {session_id}"), "delete" => format!("Delete session {session_id}"), + "compact" => format!("Compact session {session_id}"), "rename" => format!("Rename session {session_id}"), "list" => format!("List sessions in {workspace}"), _ => format!("Manage sessions in {workspace}"), @@ -443,4 +453,78 @@ mod tests { assert!(message.contains("new-title")); assert!(message.contains("/ws")); } + + #[test] + fn compact_action_parses_payload_session_id() { + let input: SessionControlInput = serde_json::from_value(json!({ + "action": "compact", + "session_id": "worker_1", + })) + .expect("compact payload must parse"); + assert_eq!(input.action, SessionControlAction::Compact); + assert_eq!(input.session_id.as_deref(), Some("worker_1")); + assert_eq!(SessionControlAction::Compact.as_str(), "compact"); + } + + #[test] + fn compact_validation_requires_session_id() { + let input = SessionControlInput { + action: SessionControlAction::Compact, + workspace: None, + session_id: None, + session_name: None, + agent_type: None, + }; + let result = validate_session_control_input(&input, context(None)); + assert!(!result.result); + assert!(result + .message + .as_deref() + .unwrap_or_default() + .contains("session_id is required")); + } + + #[test] + fn compact_validation_rejects_non_mutating_fields() { + let input = SessionControlInput { + action: SessionControlAction::Compact, + workspace: None, + session_id: Some("worker_1".to_string()), + session_name: Some("should not be allowed".to_string()), + agent_type: None, + }; + let result = validate_session_control_input(&input, context(None)); + assert!(!result.result); + assert_eq!( + result.message.as_deref(), + Some("session_name is only allowed for create") + ); + } + + #[test] + fn compact_validation_allows_current_session() { + let input = SessionControlInput { + action: SessionControlAction::Compact, + workspace: None, + session_id: Some("self_1".to_string()), + session_name: None, + agent_type: None, + }; + let result = validate_session_control_input(&input, context(Some("self_1"))); + assert!( + result.result, + "compact of the current session must be allowed: {:?}", + result.message + ); + } + + #[test] + fn compact_render_mentions_session() { + let rendered = render_session_control_tool_use_message(&json!({ + "action": "compact", + "session_id": "worker_1", + })); + assert!(rendered.contains("Compact session")); + assert!(rendered.contains("worker_1")); + } }