diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 8422d4a42..d2d045401 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -276,7 +276,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.59.0", "x11rb", ] @@ -1818,7 +1818,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2100,7 +2100,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2813,8 +2813,8 @@ dependencies = [ [[package]] name = "goose" -version = "1.42.0" -source = "git+https://github.com/aaif-goose/goose.git?rev=3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49#3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49" +version = "1.44.0" +source = "git+https://github.com/aaif-goose/goose.git?rev=c3111c71cd682ed1d115741677f0ca9946c51499#c3111c71cd682ed1d115741677f0ca9946c51499" dependencies = [ "agent-client-protocol", "agent-client-protocol-http", @@ -2899,7 +2899,6 @@ dependencies = [ "unicode-normalization", "url", "urlencoding", - "utoipa", "uuid", "v_htmlescape", "webbrowser", @@ -2909,8 +2908,8 @@ dependencies = [ [[package]] name = "goose-acp-macros" -version = "1.42.0" -source = "git+https://github.com/aaif-goose/goose.git?rev=3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49#3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49" +version = "1.44.0" +source = "git+https://github.com/aaif-goose/goose.git?rev=c3111c71cd682ed1d115741677f0ca9946c51499#c3111c71cd682ed1d115741677f0ca9946c51499" dependencies = [ "quote", "syn 2.0.108", @@ -2919,7 +2918,7 @@ dependencies = [ [[package]] name = "goose-download-manager" version = "0.1.0-alpha.0" -source = "git+https://github.com/aaif-goose/goose.git?rev=3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49#3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49" +source = "git+https://github.com/aaif-goose/goose.git?rev=c3111c71cd682ed1d115741677f0ca9946c51499#c3111c71cd682ed1d115741677f0ca9946c51499" dependencies = [ "anyhow", "once_cell", @@ -2927,13 +2926,12 @@ dependencies = [ "serde", "tokio", "tracing", - "utoipa", ] [[package]] name = "goose-provider-types" version = "0.1.0-alpha.0" -source = "git+https://github.com/aaif-goose/goose.git?rev=3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49#3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49" +source = "git+https://github.com/aaif-goose/goose.git?rev=c3111c71cd682ed1d115741677f0ca9946c51499#c3111c71cd682ed1d115741677f0ca9946c51499" dependencies = [ "anyhow", "async-stream", @@ -2953,14 +2951,13 @@ dependencies = [ "tokio", "tracing", "unicode-normalization", - "utoipa", "uuid", ] [[package]] name = "goose-providers" version = "0.1.0-alpha.0" -source = "git+https://github.com/aaif-goose/goose.git?rev=3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49#3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49" +source = "git+https://github.com/aaif-goose/goose.git?rev=c3111c71cd682ed1d115741677f0ca9946c51499#c3111c71cd682ed1d115741677f0ca9946c51499" dependencies = [ "anyhow", "async-stream", @@ -2979,13 +2976,12 @@ dependencies = [ "tracing", "url", "urlencoding", - "utoipa", ] [[package]] name = "goose-sdk-types" version = "0.1.0-alpha.0" -source = "git+https://github.com/aaif-goose/goose.git?rev=3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49#3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49" +source = "git+https://github.com/aaif-goose/goose.git?rev=c3111c71cd682ed1d115741677f0ca9946c51499#c3111c71cd682ed1d115741677f0ca9946c51499" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", @@ -3333,7 +3329,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -4359,7 +4355,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4511,7 +4507,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "chrono", "getrandom 0.2.16", "http", @@ -5593,7 +5589,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -6136,7 +6132,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6194,7 +6190,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7747,7 +7743,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8656,30 +8652,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "utoipa" -version = "4.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23" -dependencies = [ - "indexmap 2.12.0", - "serde", - "serde_json", - "utoipa-gen", -] - -[[package]] -name = "utoipa-gen" -version = "4.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392" -dependencies = [ - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.108", -] - [[package]] name = "uuid" version = "1.23.4" @@ -9087,7 +9059,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 0a79151f6..f6537badd 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -74,7 +74,7 @@ sha2 = "0.10" # Pin Goose to an exact official upstream commit. Keep this as a git dependency # instead of a submodule so ordinary Maple checkouts do not need the full Goose # history. -goose = { git = "https://github.com/aaif-goose/goose.git", rev = "3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49", package = "goose", default-features = false } +goose = { git = "https://github.com/aaif-goose/goose.git", rev = "c3111c71cd682ed1d115741677f0ca9946c51499", package = "goose", default-features = false } async-trait = "0.1" rmcp = { version = "=1.4.0", default-features = false } tauri-plugin-dialog = "2.7.1" diff --git a/frontend/src-tauri/src/agent.rs b/frontend/src-tauri/src/agent.rs index 260eca4e6..a02132562 100644 --- a/frontend/src-tauri/src/agent.rs +++ b/frontend/src-tauri/src/agent.rs @@ -2783,6 +2783,7 @@ fn skills_client_for_working_dir( skills_context.extension_manager = Some(Arc::downgrade(&agent.extension_manager)); skills_context.session = Some(Arc::new(skills_session)); SkillsClient::new(skills_context) + .map(|client| client.with_builtin_skills(false)) .map_err(|error| format!("Failed to create Maple skills tools: {error}")) } @@ -3041,7 +3042,14 @@ fn message_to_timeline_items_with_thinking( live: bool, thinking: Option<&str>, ) -> Vec { - let role = message_role(message); + // Goose persists the canonical message for provider history but projects + // content-level audience annotations before emitting live user events. + // Apply the same projection when rebuilding Maple's timeline from storage. + let message = message.user_visible_content(); + if !message.is_user_visible() || message.content.is_empty() { + return Vec::new(); + } + let role = message_role(&message); let base_id = message .id .clone() @@ -3052,25 +3060,40 @@ fn message_to_timeline_items_with_thinking( unix_ms() }; let merge = if live { "append" } else { "replace" }.to_string(); + let visible_text = message + .content + .iter() + .filter_map(|content| match content { + MessageContent::Text(text) => Some(text.text.as_str()), + _ => None, + }) + .collect::(); + let mut emitted_text = false; let mut emitted_thinking = false; message .content .iter() .enumerate() .filter_map(|(index, content)| match content { - MessageContent::Text(text) => Some(AgentTimelineItem { - id: format!("{base_id}-text"), - item_type: "message".to_string(), - role: Some(role.clone()), - title: None, - text: Some(text.text.clone()), - status: None, - input: None, - output: None, - created_ms, - merge: merge.clone(), - }), + MessageContent::Text(_) => { + if emitted_text { + return None; + } + emitted_text = true; + Some(AgentTimelineItem { + id: format!("{base_id}-text"), + item_type: "message".to_string(), + role: Some(role.clone()), + title: None, + text: Some(visible_text.clone()), + status: None, + input: None, + output: None, + created_ms, + merge: merge.clone(), + }) + } MessageContent::Thinking(_) | MessageContent::RedactedThinking(_) => { if emitted_thinking { return None; @@ -4507,6 +4530,7 @@ fn path_string(path: &Path) -> String { #[cfg(test)] mod tests { use super::*; + use rmcp::model::{AnnotateAble, RawTextContent, Role as McpRole}; fn recent_roots_test_dir(label: &str) -> PathBuf { std::env::temp_dir().join(format!( @@ -4692,11 +4716,28 @@ mod tests { #[tokio::test] async fn maple_skills_registration_is_unprefixed_transient_and_coexists_with_skills_mcp() { + use goose::agents::mcp_client::McpClientTrait; + use goose::agents::ToolCallContext; + let test_root = recent_roots_test_dir("skills-registration"); let project = test_root.join("project"); let external_root = test_root.join("external"); fs::create_dir_all(&project).unwrap(); fs::create_dir_all(&external_root).unwrap(); + let project_skill_name = format!( + "maple-registration-skill-{}-{}", + std::process::id(), + NEXT_RUN_ID.fetch_add(1, Ordering::Relaxed) + ); + let project_skill_dir = project.join(".agents/skills").join(&project_skill_name); + fs::create_dir_all(&project_skill_dir).unwrap(); + fs::write( + project_skill_dir.join("SKILL.md"), + format!( + "---\nname: {project_skill_name}\ndescription: Maple registration test skill\n---\nUse the Maple registration test instructions." + ), + ) + .unwrap(); let session_manager = Arc::new(SessionManager::new(test_root.join("sessions"))); let permission_manager = Arc::new(PermissionManager::new(test_root.join("permissions"))); @@ -4746,6 +4787,31 @@ mod tests { .await; let initial_skills = skills_client_for_working_dir(&agent, &session, project.clone()).unwrap(); + let skills_instructions = initial_skills.get_instructions().unwrap_or_default(); + assert!(skills_instructions.contains(&project_skill_name)); + assert!(!skills_instructions.contains("goose-doc-guide")); + + let builtin_result = initial_skills + .call_tool( + &ToolCallContext::new("test".to_string(), None, None), + "load_skill", + Some(serde_json::from_value(json!({"name": "goose-doc-guide"})).unwrap()), + CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(builtin_result.is_error, Some(true)); + + let project_result = initial_skills + .call_tool( + &ToolCallContext::new("test".to_string(), None, None), + "load_skill", + Some(serde_json::from_value(json!({"name": project_skill_name})).unwrap()), + CancellationToken::new(), + ) + .await + .unwrap(); + assert_ne!(project_result.is_error, Some(true)); attach_prepared_skills_client(&agent, initial_skills).await; let prompt_extensions = agent.extension_manager.get_extensions_info(&project).await; @@ -6181,6 +6247,67 @@ mod tests { assert!(message_to_timeline_items(&hidden, true).is_empty()); } + #[test] + fn persisted_timeline_enforces_content_audience_boundaries() { + let audience_text = |text: &str, audience| { + MessageContent::Text( + RawTextContent { + text: text.to_string(), + meta: None, + } + .no_annotation() + .with_audience(vec![audience]), + ) + }; + + let mixed_text = Message::assistant() + .with_id("mixed-text") + .with_text("visible response") + .with_content(audience_text("provider-private-state", McpRole::Assistant)) + .with_content(audience_text(" plus visible detail", McpRole::User)); + let persisted_items = + conversation_to_timeline_items(&Conversation::new_unvalidated( + vec![mixed_text.clone()], + )); + let live_items = message_to_timeline_items(&mixed_text.user_visible_content(), true); + assert_eq!(persisted_items.len(), 1); + assert_eq!( + persisted_items[0].text.as_deref(), + Some("visible response plus visible detail") + ); + assert!(!persisted_items[0] + .text + .as_deref() + .unwrap() + .contains("provider-private-state")); + assert!(timeline_projection_matches( + &live_items, + &persisted_items, + true + )); + + let assistant_only = Message::assistant() + .with_id("assistant-only") + .with_content(audience_text("provider-private-state", McpRole::Assistant)); + assert!(message_to_timeline_items(&assistant_only, false).is_empty()); + + let mixed_tool_result = Message::user().with_tool_response( + "mixed-tool", + Ok(rmcp::model::CallToolResult::success(vec![ + rmcp::model::Content::text("visible tool output") + .with_audience(vec![McpRole::User]), + rmcp::model::Content::text("provider-private-tool-state") + .with_audience(vec![McpRole::Assistant]), + ])), + ); + let tool_items = message_to_timeline_items(&mixed_tool_result, false); + assert_eq!(tool_items.len(), 1); + let output = tool_items[0].output.as_ref().unwrap(); + assert_eq!(output["text"], "visible tool output"); + assert_eq!(output["content"].as_array().unwrap().len(), 1); + assert!(!output.to_string().contains("provider-private-tool-state")); + } + #[test] fn hidden_usage_boundary_resets_visible_inference_state() { let first = "First visible thought."; diff --git a/frontend/src-tauri/src/agent/developer_tools.rs b/frontend/src-tauri/src/agent/developer_tools.rs index 54a6a19f1..f06e616c3 100644 --- a/frontend/src-tauri/src/agent/developer_tools.rs +++ b/frontend/src-tauri/src/agent/developer_tools.rs @@ -5,6 +5,7 @@ use goose::agents::platform_extensions::developer::shell::{ShellOutput, ShellPar use goose::agents::platform_extensions::developer::DeveloperClient; use goose::agents::platform_extensions::PlatformExtensionContext; use goose::agents::ToolCallContext; +use goose::config::{Config, DEFAULT_EXTENSION_TIMEOUT}; use goose::conversation::message::{Message, MessageUsage}; use goose::providers::base::Provider; #[cfg(unix)] @@ -168,6 +169,7 @@ impl MapleDeveloperClient { timeout_secs: Some(5), }, None, + None, CancellationToken::new(), ) .await; @@ -452,6 +454,7 @@ impl McpClientTrait for MapleDeveloperClient { params, working_dir, login_path.as_deref(), + Some(&ctx.session_id), cancel_token, ) .await); @@ -760,17 +763,20 @@ async fn run_bounded_shell( params: ShellParams, working_dir: Option<&Path>, login_path: Option<&str>, + session_id: Option<&str>, cancel_token: CancellationToken, ) -> CallToolResult { if params.command.trim().is_empty() { return shell_error_result("Command cannot be empty.", None); } + let timeout_secs = Some(resolve_bounded_shell_timeout(params.timeout_secs)); let execution = match execute_bounded_shell( ¶ms.command, - params.timeout_secs, + timeout_secs, working_dir, login_path, + session_id, cancel_token, ) .await @@ -778,7 +784,15 @@ async fn run_bounded_shell( Ok(execution) => execution, Err(error) => return shell_error_result(error, None), }; - render_bounded_shell_result(execution, params.timeout_secs) + render_bounded_shell_result(execution, timeout_secs) +} + +fn resolve_bounded_shell_timeout(timeout_secs: Option) -> u64 { + timeout_secs.unwrap_or_else(|| { + Config::global() + .get_goose_default_extension_timeout() + .unwrap_or(DEFAULT_EXTENSION_TIMEOUT) + }) } async fn execute_bounded_shell( @@ -786,9 +800,11 @@ async fn execute_bounded_shell( timeout_secs: Option, working_dir: Option<&Path>, login_path: Option<&str>, + session_id: Option<&str>, cancel_token: CancellationToken, ) -> Result { - let mut command = build_bounded_shell_command(command_line, working_dir, login_path); + let mut command = + build_bounded_shell_command(command_line, working_dir, login_path, session_id); command .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -1000,6 +1016,7 @@ fn build_bounded_shell_command( command_line: &str, working_dir: Option<&Path>, login_path: Option<&str>, + session_id: Option<&str>, ) -> tokio::process::Command { #[cfg(windows)] let mut command = { @@ -1047,6 +1064,7 @@ fn build_bounded_shell_command( if let Some(path) = login_path { command.arg(format!("--env=PATH={path}")); } + apply_flatpak_session_environment(&mut command, session_id); command.arg(shell).args(["-c", command_line]); command } else { @@ -1058,15 +1076,39 @@ fn build_bounded_shell_command( if let Some(path) = login_path { command.env("PATH", path); } + apply_session_environment(&mut command, session_id); command } }; + #[cfg(windows)] + apply_session_environment(&mut command, session_id); + #[cfg(unix)] configure_subprocess(&mut command); command } +fn apply_session_environment(command: &mut tokio::process::Command, session_id: Option<&str>) { + if let Some(session_id) = session_id.filter(|id| !id.is_empty()) { + command.env("AGENT_SESSION_ID", session_id); + } else { + command.env_remove("AGENT_SESSION_ID"); + } +} + +#[cfg(not(windows))] +fn apply_flatpak_session_environment( + command: &mut tokio::process::Command, + session_id: Option<&str>, +) { + if let Some(session_id) = session_id.filter(|id| !id.is_empty()) { + command.arg(format!("--env=AGENT_SESSION_ID={session_id}")); + } else { + command.arg("--unset-env=AGENT_SESSION_ID"); + } +} + #[cfg(not(windows))] fn executable_on_path(name: &str) -> Option { std::env::var_os("PATH") @@ -2690,6 +2732,7 @@ mod tests { }, None, std::env::var("PATH").ok().as_deref(), + None, CancellationToken::new(), ), ) @@ -2703,6 +2746,62 @@ mod tests { assert!(text(&result).contains("output exceeded")); } + #[test] + fn shell_omitted_timeout_uses_goose_default_and_zero_remains_unbounded() { + let configured_default = Config::global() + .get_goose_default_extension_timeout() + .unwrap_or(DEFAULT_EXTENSION_TIMEOUT); + assert_eq!(resolve_bounded_shell_timeout(None), configured_default); + assert_eq!(resolve_bounded_shell_timeout(Some(42)), 42); + assert_eq!(resolve_bounded_shell_timeout(Some(0)), 0); + } + + #[test] + fn shell_session_environment_is_set_and_stale_values_are_removed() { + let mut command = tokio::process::Command::new("unused"); + apply_session_environment(&mut command, Some("maple-task-123")); + let session_value = command + .as_std() + .get_envs() + .find(|(key, _)| key.to_string_lossy() == "AGENT_SESSION_ID") + .and_then(|(_, value)| value) + .and_then(|value| value.to_str()); + assert_eq!(session_value, Some("maple-task-123")); + + apply_session_environment(&mut command, None); + let cleared_value = command + .as_std() + .get_envs() + .find(|(key, _)| key.to_string_lossy() == "AGENT_SESSION_ID") + .map(|(_, value)| value); + assert_eq!(cleared_value, Some(None)); + } + + #[cfg(unix)] + #[tokio::test] + async fn shell_tool_forwards_the_current_agent_session_id() { + let temp = TestDir::new(); + let client = + MapleDeveloperClient::new(test_context(temp.path().join("sessions")), true).unwrap(); + let context = ToolCallContext::new("maple-task-456".to_string(), None, None); + let result = client + .call_tool( + &context, + "shell", + Some(object!({ + "command": "printf %s \"$AGENT_SESSION_ID\"", + "timeout_secs": 2 + })), + CancellationToken::new(), + ) + .await + .unwrap(); + let output: ShellOutput = + serde_json::from_value(result.structured_content.clone().unwrap()).unwrap(); + assert_eq!(result.is_error, Some(false)); + assert_eq!(output.stdout, "maple-task-456"); + } + #[cfg(unix)] #[tokio::test] async fn shell_returns_without_killing_a_successful_background_job() { @@ -2719,6 +2818,7 @@ mod tests { }, None, std::env::var("PATH").ok().as_deref(), + None, CancellationToken::new(), ), ) @@ -2753,6 +2853,7 @@ mod tests { }, None, std::env::var("PATH").ok().as_deref(), + None, CancellationToken::new(), ), ) @@ -2787,6 +2888,7 @@ mod tests { }, None, std::env::var("PATH").ok().as_deref(), + None, CancellationToken::new(), ) .await; @@ -2824,6 +2926,7 @@ mod tests { }, None, std::env::var("PATH").ok().as_deref(), + None, cancel_token, ) .await; @@ -2848,6 +2951,7 @@ mod tests { }, None, std::env::var("PATH").ok().as_deref(), + None, CancellationToken::new(), ) .await;