diff --git a/src/apps/desktop/src/api/snapshot_service.rs b/src/apps/desktop/src/api/snapshot_service.rs index e8b0593d4..576480e7b 100644 --- a/src/apps/desktop/src/api/snapshot_service.rs +++ b/src/apps/desktop/src/api/snapshot_service.rs @@ -3,8 +3,8 @@ use bitfun_core::infrastructure::try_get_path_manager_arc; use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; use bitfun_core::service::snapshot::{ - ensure_snapshot_manager_for_workspace, get_snapshot_manager_for_workspace, - initialize_snapshot_manager_for_workspace, OperationType, SnapshotConfig, SnapshotManager, + OperationType, SnapshotConfig, SnapshotManager, ensure_snapshot_manager_for_workspace, + get_snapshot_manager_for_workspace, initialize_snapshot_manager_for_workspace, }; use log::{info, warn}; use serde::{Deserialize, Serialize}; @@ -285,7 +285,7 @@ pub async fn record_file_change( return Err(format!( "Unknown operation type: {}", request.operation_type - )) + )); } }; @@ -424,7 +424,10 @@ pub async fn rollback_to_turn( deleted_turns_count = count; } Err(e) => { - warn!("Failed to delete conversation turns: session_id={}, turn_index={}, error={}", request.session_id, request.turn_index, e); + warn!( + "Failed to delete conversation turns: session_id={}, turn_index={}, error={}", + request.session_id, request.turn_index, e + ); } } } @@ -546,6 +549,10 @@ pub async fn reject_file( #[tauri::command] pub async fn get_session_files(request: GetSessionFilesRequest) -> Result, String> { + if is_remote_path(&request.workspace_path).await { + return Ok(vec![]); + } + let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; let files = manager @@ -580,7 +587,10 @@ pub async fn get_session_turns( } Ok(None) => {} Err(e) => { - warn!("Failed to load conversation metadata: session_id={}, error={}, falling back to snapshot", request.session_id, e); + warn!( + "Failed to load conversation metadata: session_id={}, error={}, falling back to snapshot", + request.session_id, e + ); } } } @@ -840,6 +850,15 @@ pub async fn reject_operation( pub async fn get_session_stats( request: GetSessionStatsRequest, ) -> Result { + if is_remote_path(&request.workspace_path).await { + return Ok(serde_json::json!({ + "session_id": request.session_id, + "total_files": 0, + "total_turns": 0, + "total_changes": 0 + })); + } + let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; let stats = manager diff --git a/src/apps/desktop/src/api/tool_api.rs b/src/apps/desktop/src/api/tool_api.rs index 0c1f61960..18fca3dc9 100644 --- a/src/apps/desktop/src/api/tool_api.rs +++ b/src/apps/desktop/src/api/tool_api.rs @@ -6,9 +6,13 @@ use std::collections::HashMap; use std::path::PathBuf; use bitfun_core::agentic::{ + WorkspaceBinding, tools::framework::ToolUseContext, tools::{get_all_tools, get_readonly_tools}, - WorkspaceBinding, + workspace::{local_workspace_services, remote_workspace_services}, +}; +use bitfun_core::service::remote_ssh::workspace_state::{ + get_remote_workspace_manager, lookup_remote_connection, resolve_workspace_session_identity, }; use bitfun_core::util::elapsed_ms_u64; @@ -82,23 +86,71 @@ pub struct ToolConfirmationResponse { pub message: String, } -fn build_tool_context(workspace_path: Option<&str>) -> ToolUseContext { +async fn build_tool_context(workspace_path: Option<&str>) -> ToolUseContext { let normalized_workspace_path = workspace_path .map(str::trim) .filter(|path| !path.is_empty()); + let workspace = match normalized_workspace_path { + Some(path) => match resolve_workspace_session_identity(path, None, None).await { + Some(identity) => { + if let Some(connection_id) = identity.remote_connection_id.as_deref() { + let connection_name = lookup_remote_connection(path) + .await + .map(|entry| entry.connection_name) + .unwrap_or_else(|| connection_id.to_string()); + Some(WorkspaceBinding::new_remote( + None, + PathBuf::from(path), + connection_id.to_string(), + connection_name, + identity, + )) + } else { + Some(WorkspaceBinding::new(None, PathBuf::from(path))) + } + } + None => Some(WorkspaceBinding::new(None, PathBuf::from(path))), + }, + None => None, + }; + + let workspace_services = match workspace.as_ref() { + Some(binding) if binding.is_remote() => { + let connection_id = binding.connection_id().map(str::to_string); + match (connection_id, get_remote_workspace_manager()) { + (Some(connection_id), Some(manager)) => { + match ( + manager.get_file_service().await, + manager.get_ssh_manager().await, + ) { + (Some(file_service), Some(ssh_manager)) => Some(remote_workspace_services( + connection_id, + file_service, + ssh_manager, + binding.root_path_string(), + )), + _ => None, + } + } + _ => None, + } + } + Some(binding) => Some(local_workspace_services(binding.root_path_string())), + None => None, + }; + ToolUseContext { tool_call_id: None, agent_type: None, session_id: None, dialog_turn_id: None, - workspace: normalized_workspace_path - .map(|path| WorkspaceBinding::new(None, PathBuf::from(path))), + workspace, custom_data: HashMap::new(), computer_use_host: None, cancellation_token: None, runtime_tool_restrictions: Default::default(), - workspace_services: None, + workspace_services, } } @@ -229,7 +281,7 @@ pub async fn validate_tool_input( request.workspace_path.as_deref(), )?; - let context = build_tool_context(request.workspace_path.as_deref()); + let context = build_tool_context(request.workspace_path.as_deref()).await; let validation_result = tool.validate_input(&request.input, Some(&context)).await; @@ -260,7 +312,7 @@ pub async fn execute_tool(request: ToolExecutionRequest) -> Result, + workspace_services: Option<&crate::agentic::workspace::WorkspaceServices>, agent_type: &str, primary_supports_image_understanding: bool, ) -> (Vec, Option>) { @@ -1893,7 +1895,7 @@ impl ExecutionEngine { computer_use_host: None, cancellation_token: None, runtime_tool_restrictions: ToolRuntimeRestrictions::default(), - workspace_services: None, + workspace_services: workspace_services.cloned(), }; for tool in &all_tools { if !tool.is_enabled().await { diff --git a/src/crates/core/src/agentic/execution/stream_processor.rs b/src/crates/core/src/agentic/execution/stream_processor.rs index 2953d9d23..f1f93cd2f 100644 --- a/src/crates/core/src/agentic/execution/stream_processor.rs +++ b/src/crates/core/src/agentic/execution/stream_processor.rs @@ -430,8 +430,7 @@ impl StreamProcessor { for tool_call in tool_calls { trace!( "Cleaning up tool: {} ({})", - tool_call.tool_name, - tool_call.tool_id + tool_call.tool_name, tool_call.tool_id ); let tool_event = if is_user_cancellation { @@ -583,8 +582,10 @@ impl StreamProcessor { /// Handle text chunk async fn handle_text_chunk(&self, ctx: &mut StreamContext, text: String) { - ctx.has_effective_output = true; - ctx.mark_first_visible_output(); + if !text.trim().is_empty() { + ctx.has_effective_output = true; + ctx.mark_first_visible_output(); + } ctx.full_text.push_str(&text); ctx.text_chunks_count += 1; @@ -976,6 +977,34 @@ mod tests { assert_eq!(result.usage.as_ref().map(|u| u.total_token_count), Some(7)); } + #[tokio::test] + async fn whitespace_only_text_is_not_effective_output() { + let processor = build_processor(); + let stream = iter(vec![Ok(UnifiedResponse { + text: Some("\n\n ".to_string()), + ..Default::default() + })]) + .boxed(); + + let result = processor + .process_stream( + stream, + None, + None, + "session_1".to_string(), + "turn_1".to_string(), + "round_1".to_string(), + None, + &CancellationToken::new(), + ) + .await + .expect("stream result"); + + assert_eq!(result.full_text, "\n\n "); + assert!(!result.has_effective_output); + assert_eq!(result.first_visible_output_ms, None); + } + #[tokio::test] async fn finalizes_tool_after_same_chunk_finish_reason() { let processor = build_processor(); diff --git a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs b/src/crates/core/src/agentic/tools/implementations/skill_tool.rs index 20d2594c7..a2d2db6d3 100644 --- a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/skill_tool.rs @@ -9,10 +9,10 @@ use crate::agentic::tools::framework::{ use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use log::debug; -use serde_json::{json, Value}; +use serde_json::{Value, json}; // Use skills module -use super::skills::{get_skill_registry, SkillLocation}; +use super::skills::{SkillLocation, get_skill_registry}; /// Skill tool pub struct SkillTool; @@ -264,3 +264,135 @@ impl Default for SkillTool { Self::new() } } + +#[cfg(test)] +mod tests { + use super::SkillTool; + use crate::agentic::WorkspaceBinding; + use crate::agentic::tools::framework::Tool; + use crate::agentic::workspace::{ + WorkspaceCommandOptions, WorkspaceCommandResult, WorkspaceDirEntry, WorkspaceFileSystem, + WorkspaceServices, WorkspaceShell, + }; + use crate::service::remote_ssh::workspace_state::workspace_session_identity; + use async_trait::async_trait; + use std::path::PathBuf; + use std::sync::Arc; + + struct FakeRemoteFs; + + #[async_trait] + impl WorkspaceFileSystem for FakeRemoteFs { + async fn read_file(&self, path: &str) -> anyhow::Result> { + Ok(self.read_file_text(path).await?.into_bytes()) + } + + async fn read_file_text(&self, path: &str) -> anyhow::Result { + if path == "/remote/project/.bitfun/skills/remote-only/SKILL.md" { + return Ok(r#"--- +name: remote-only-skill-for-test +description: Remote project skill visible only through workspace services. +--- + +Use the remote project skill. +"# + .to_string()); + } + anyhow::bail!("not found: {}", path) + } + + async fn write_file(&self, _path: &str, _contents: &[u8]) -> anyhow::Result<()> { + Ok(()) + } + + async fn exists(&self, path: &str) -> anyhow::Result { + Ok(matches!( + path, + "/remote/project/.bitfun/skills" + | "/remote/project/.bitfun/skills/remote-only" + | "/remote/project/.bitfun/skills/remote-only/SKILL.md" + )) + } + + async fn is_file(&self, path: &str) -> anyhow::Result { + Ok(path == "/remote/project/.bitfun/skills/remote-only/SKILL.md") + } + + async fn is_dir(&self, path: &str) -> anyhow::Result { + Ok(matches!( + path, + "/remote/project/.bitfun/skills" | "/remote/project/.bitfun/skills/remote-only" + )) + } + + async fn read_dir(&self, path: &str) -> anyhow::Result> { + if path == "/remote/project/.bitfun/skills" { + return Ok(vec![WorkspaceDirEntry { + name: "remote-only".to_string(), + path: "/remote/project/.bitfun/skills/remote-only".to_string(), + is_dir: true, + is_symlink: false, + }]); + } + Ok(vec![]) + } + } + + struct FakeShell; + + #[async_trait] + impl WorkspaceShell for FakeShell { + async fn exec_with_options( + &self, + _command: &str, + _options: WorkspaceCommandOptions, + ) -> anyhow::Result { + Ok(WorkspaceCommandResult { + stdout: String::new(), + stderr: String::new(), + exit_code: 0, + interrupted: false, + timed_out: false, + }) + } + } + + #[tokio::test] + async fn remote_description_indexes_project_skills_through_workspace_services() { + let identity = + workspace_session_identity("/remote/project", Some("conn-1"), Some("remote-host")) + .expect("remote identity"); + let workspace = WorkspaceBinding::new_remote( + Some("remote-workspace".to_string()), + PathBuf::from("/remote/project"), + "conn-1".to_string(), + "Remote".to_string(), + identity, + ); + let context = crate::agentic::tools::framework::ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: Some(workspace), + custom_data: Default::default(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: Default::default(), + workspace_services: Some(WorkspaceServices { + fs: Arc::new(FakeRemoteFs), + shell: Arc::new(FakeShell), + }), + }; + + let description = SkillTool::new() + .description_with_context(Some(&context)) + .await + .expect("description"); + + assert!(description.contains("remote-only-skill-for-test")); + assert!( + description.contains("Remote project skill visible only through workspace services.") + ); + } +}