Redesign Extensions page. Remove enable toggle in UI. Treat Extension Manager as core MCP enabler per session. (#8940)

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-05-01 19:16:55 -07:00
committed by GitHub
parent c365e7b950
commit 45d8bf81d0
67 changed files with 2415 additions and 980 deletions
+149 -93
View File
@@ -12,7 +12,7 @@ use crate::config::extensions::get_enabled_extensions_with_config;
use crate::config::paths::Paths;
use crate::config::permission::PermissionManager;
use crate::config::{Config, GooseMode};
use crate::conversation::message::{ActionRequiredData, Message, MessageContent};
use crate::conversation::message::{ActionRequiredData, Message, MessageContent, ToolRequest};
use crate::mcp_utils::ToolResult;
use crate::permission::permission_confirmation::PrincipalType;
use crate::permission::{Permission, PermissionConfirmation};
@@ -560,6 +560,77 @@ fn summarize_tool_call(tool_name: &str, arguments: Option<&serde_json::Value>) -
}
}
fn tool_call_identity_meta(tool_request: &ToolRequest) -> Option<Meta> {
let tool_call = tool_request.tool_call.as_ref().ok()?;
let tool_name = tool_call.name.to_string();
let extension_name = tool_request
.tool_meta
.as_ref()
.and_then(|meta| meta.get("goose_extension"))
.and_then(serde_json::Value::as_str)
.map(ToString::to_string)
.or_else(|| {
tool_name
.split_once("__")
.map(|(extension_name, _)| extension_name.to_string())
});
let mut tool_call_meta = serde_json::Map::new();
tool_call_meta.insert("toolName".to_string(), serde_json::Value::String(tool_name));
if let Some(extension_name) = extension_name {
tool_call_meta.insert(
"extensionName".to_string(),
serde_json::Value::String(extension_name),
);
}
let mut goose_meta = serde_json::Map::new();
goose_meta.insert(
"toolCall".to_string(),
serde_json::Value::Object(tool_call_meta),
);
let mut meta = serde_json::Map::new();
meta.insert("goose".to_string(), serde_json::Value::Object(goose_meta));
Some(meta)
}
struct PendingToolCall {
tool_call: ToolCall,
identity_meta: Option<Meta>,
fallback_title: String,
}
fn pending_tool_call_from_request(tool_request: &ToolRequest) -> PendingToolCall {
let tool_name = match &tool_request.tool_call {
Ok(tool_call) => tool_call.name.to_string(),
Err(_) => "error".to_string(),
};
let args_value = tool_request
.tool_call
.as_ref()
.ok()
.and_then(|tc| tc.arguments.as_ref())
.map(|a| serde_json::Value::Object(a.clone()));
let fallback_title = summarize_tool_call(&tool_name, args_value.as_ref());
let identity_meta = tool_call_identity_meta(tool_request);
let mut tool_call = ToolCall::new(
ToolCallId::new(tool_request.id.clone()),
fallback_title.clone(),
)
.status(ToolCallStatus::Pending);
if let Some(args) = args_value {
tool_call = tool_call.raw_input(args);
}
PendingToolCall {
tool_call,
identity_meta,
fallback_title,
}
}
fn builtin_to_extension_config(name: &str) -> ExtensionConfig {
if let Some(def) = PLATFORM_EXTENSIONS.get(name) {
ExtensionConfig::Platform {
@@ -1405,27 +1476,10 @@ impl GooseAcpAgent {
.tool_requests
.insert(tool_request.id.clone(), tool_request.clone());
let tool_name = match &tool_request.tool_call {
Ok(tool_call) => tool_call.name.to_string(),
Err(_) => "error".to_string(),
};
let args_value = tool_request
let pending_tool_call = pending_tool_call_from_request(tool_request);
let initial_tool_call = pending_tool_call
.tool_call
.as_ref()
.ok()
.and_then(|tc| tc.arguments.as_ref())
.map(|a| serde_json::Value::Object(a.clone()));
let fallback_title = summarize_tool_call(&tool_name, args_value.as_ref());
let mut initial_tool_call = ToolCall::new(
ToolCallId::new(tool_request.id.clone()),
fallback_title.clone(),
)
.status(ToolCallStatus::Pending);
if let Some(args) = args_value.clone() {
initial_tool_call = initial_tool_call.raw_input(args);
}
.meta(pending_tool_call.identity_meta.clone());
cx.send_notification(SessionNotification::new(
session_id.clone(),
SessionUpdate::ToolCall(initial_tool_call),
@@ -1440,6 +1494,8 @@ impl GooseAcpAgent {
let request_id = tool_request.id.clone();
let cx = cx.clone();
let name = tool_call.name.to_string();
let identity_meta = pending_tool_call.identity_meta.clone();
let fallback_title = pending_tool_call.fallback_title.clone();
let args_json = tool_call
.arguments
.as_ref()
@@ -1454,71 +1510,56 @@ impl GooseAcpAgent {
.unwrap_or_default();
tokio::spawn(async move {
let provider: Arc<dyn Provider> = match agent.provider().await {
Ok(p) => p,
let title = match agent.provider().await {
Ok(provider) => {
if provider.manages_own_context() {
return;
}
let system =
"Summarize this tool call in a short lowercase phrase (3-8 words). \
No punctuation. No quotes. Examples: reading project configuration, \
checking network connectivity, listing files in src directory";
let user_text = format!("Tool: {name}\nArguments: {args_json}");
let message = Message::user().with_text(&user_text);
match provider
.complete_fast(&sid.0, system, &[message], &[])
.await
{
Ok((response, _)) => {
let summary: String = response
.content
.iter()
.filter_map(|c: &MessageContent| c.as_text())
.collect::<String>()
.trim()
.to_string();
if summary.is_empty() {
fallback_title.clone()
} else {
summary
}
}
Err(e) => {
warn!("tool call summary: fast_complete failed: {e}");
fallback_title.clone()
}
}
}
Err(e) => {
warn!("tool call summary: failed to get provider: {e}");
let fields = ToolCallUpdateFields::new().title(fallback_title);
let _ = cx.send_notification(SessionNotification::new(
sid,
SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
ToolCallId::new(request_id),
fields,
)),
));
return;
fallback_title.clone()
}
};
// in these case, the title summarization request would
// be added to the conversation which we don't want
if provider.manages_own_context() {
return;
}
let system = "Summarize this tool call in a short lowercase phrase (3-8 words). \
No punctuation. No quotes. Examples: reading project configuration, \
checking network connectivity, listing files in src directory";
let user_text = format!("Tool: {name}\nArguments: {args_json}");
let message = Message::user().with_text(&user_text);
match provider
.complete_fast(&sid.0, system, &[message], &[])
.await
{
Ok((response, _)) => {
let summary: String = response
.content
.iter()
.filter_map(|c: &MessageContent| c.as_text())
.collect::<String>()
.trim()
.to_string();
let title = if summary.is_empty() {
fallback_title
} else {
summary
};
let fields = ToolCallUpdateFields::new().title(title);
let _ = cx.send_notification(SessionNotification::new(
sid,
SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
ToolCallId::new(request_id),
fields,
)),
));
}
Err(e) => {
warn!("tool call summary: fast_complete failed: {e}");
let fields = ToolCallUpdateFields::new().title(fallback_title);
let _ = cx.send_notification(SessionNotification::new(
sid,
SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
ToolCallId::new(request_id),
fields,
)),
));
}
}
let fields = ToolCallUpdateFields::new().title(title);
let _ = cx.send_notification(SessionNotification::new(
sid,
SessionUpdate::ToolCallUpdate(
ToolCallUpdate::new(ToolCallId::new(request_id), fields)
.meta(identity_meta),
),
));
});
}
@@ -2172,21 +2213,14 @@ impl GooseAcpAgent {
// don't require a full GooseAcpSession.
replay_tool_requests.insert(tool_request.id.clone(), tool_request.clone());
let tool_name = match &tool_request.tool_call {
Ok(tool_call) => tool_call.name.to_string(),
Err(_) => "error".to_string(),
};
let pending_tool_call = pending_tool_call_from_request(tool_request);
let tool_call = pending_tool_call.tool_call.meta(
merge_replay_message_meta(pending_tool_call.identity_meta, message),
);
cx.send_notification(SessionNotification::new(
args.session_id.clone(),
SessionUpdate::ToolCall(
ToolCall::new(
ToolCallId::new(tool_request.id.clone()),
format_tool_name(&tool_name),
)
.status(ToolCallStatus::Pending)
.meta(replay_message_meta(message)),
),
SessionUpdate::ToolCall(tool_call),
))?;
}
MessageContent::ToolResponse(tool_response) => {
@@ -3020,6 +3054,28 @@ print(\"hello, world\")
);
}
#[test]
fn test_tool_call_identity_meta_uses_goose_extension_metadata() {
let request = ToolRequest {
id: "req_1".to_string(),
tool_call: Ok(CallToolRequestParams::new("context7__query-docs")),
metadata: None,
tool_meta: Some(serde_json::json!({"goose_extension": "context7"})),
};
let meta = tool_call_identity_meta(&request).expect("expected metadata");
assert_eq!(
meta.get("goose"),
Some(&serde_json::json!({
"toolCall": {
"toolName": "context7__query-docs",
"extensionName": "context7",
},
})),
);
}
#[test]
fn test_summarize_tool_call_long_value_truncated() {
let long_path = "a".repeat(80);
-7
View File
@@ -783,13 +783,6 @@ impl Agent {
let results = futures::future::join_all(extension_futures).await;
// Persist once after all extensions are loaded
if results.iter().any(|r| r.success) {
if let Err(e) = self.persist_extension_state(&session_id).await {
warn!("Failed to persist extension state after bulk load: {}", e);
}
}
results
}
@@ -36,7 +36,7 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
display_name: "Analyze",
description:
"Analyze code structure with tree-sitter: directory overviews, file details, symbol call graphs",
default_enabled: true,
default_enabled: false,
unprefixed_tools: true,
hidden: false,
client_factory: |ctx| Box::new(analyze::AnalyzeClient::new(ctx).unwrap()),
@@ -64,7 +64,7 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
display_name: "Apps",
description:
"Create and manage custom Goose apps through chat. Apps are HTML/CSS/JavaScript and run in sandboxed windows.",
default_enabled: true,
default_enabled: false,
unprefixed_tools: false,
hidden: false,
client_factory: |ctx| Box::new(apps::AppsManagerClient::new(ctx).unwrap()),
@@ -105,7 +105,7 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
name: summon::EXTENSION_NAME,
display_name: "Summon",
description: "Load knowledge and delegate tasks to subagents",
default_enabled: true,
default_enabled: false,
unprefixed_tools: true,
hidden: false,
client_factory: |ctx| Box::new(summon::SummonClient::new(ctx).unwrap()),
@@ -182,7 +182,7 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
display_name: "Top Of Mind",
description:
"Inject custom context into every turn via GOOSE_MOIM_MESSAGE_TEXT and GOOSE_MOIM_MESSAGE_FILE environment variables",
default_enabled: true,
default_enabled: false,
unprefixed_tools: false,
hidden: false,
client_factory: |ctx| Box::new(tom::TomClient::new(ctx).unwrap()),
+4 -2
View File
@@ -1986,10 +1986,12 @@ extensions:
)
.unwrap();
// User config (higher priority / write target) disables developer and adds a new extension
// User config (higher priority / write target) has already migrated, then disables
// developer and adds a new extension.
std::fs::write(
local_file.path(),
r#"
extensions_on_demand_migration: true
extensions:
developer:
enabled: false
@@ -2014,7 +2016,7 @@ extensions:
let values = config.load()?;
let extensions = values.get("extensions").unwrap().as_mapping().unwrap();
// developer should be disabled (user config overrides system)
// developer should be disabled (user config overrides system after migration)
let dev = extensions.get("developer").unwrap().as_mapping().unwrap();
assert!(!dev.get("enabled").unwrap().as_bool().unwrap());
// Fields from the system config should be preserved via merge
+23
View File
@@ -13,8 +13,13 @@ pub const DEFAULT_EXTENSION_DESCRIPTION: &str = "";
pub const DEFAULT_DISPLAY_NAME: &str = "Developer";
const EXTENSIONS_CONFIG_KEY: &str = "extensions";
fn default_extension_enabled() -> bool {
true
}
#[derive(Debug, Deserialize, Serialize, Clone, ToSchema)]
pub struct ExtensionEntry {
#[serde(default = "default_extension_enabled")]
pub enabled: bool,
#[serde(flatten)]
pub config: ExtensionConfig,
@@ -210,4 +215,22 @@ mod tests {
assert!(!is_extension_available(&unknown_platform));
assert!(is_extension_available(&builtin));
}
#[test]
fn test_extension_entry_defaults_missing_enabled_to_true() {
let value = serde_yaml::from_str(
r#"
type: stdio
name: github
description: GitHub tools
cmd: npx
args: []
"#,
)
.unwrap();
let entry: ExtensionEntry = serde_yaml::from_value(value).unwrap();
assert!(entry.enabled);
}
}
+108 -1
View File
@@ -114,10 +114,18 @@ mod tests {
assert!(changed);
let extensions_key = serde_yaml::Value::String(EXTENSIONS_CONFIG_KEY.to_string());
assert!(config.contains_key(&extensions_key));
let extensions = config.get(&extensions_key).unwrap().as_mapping().unwrap();
for (key, value) in extensions {
let key = key.as_str().unwrap();
let def = PLATFORM_EXTENSIONS.get(key).unwrap();
let entry: ExtensionEntry = serde_yaml::from_value(value.clone()).unwrap();
assert_eq!(entry.enabled, def.default_enabled);
}
}
#[test]
fn test_migrate_platform_extensions_preserves_enabled_state() {
fn test_migrate_platform_extensions_refreshes_metadata_without_changing_enabled() {
let mut config = Mapping::new();
let mut extensions = Mapping::new();
let todo_entry = ExtensionEntry {
@@ -149,6 +157,105 @@ mod tests {
let todo_entry: ExtensionEntry = serde_yaml::from_value(todo_value.clone()).unwrap();
assert!(!todo_entry.enabled);
match todo_entry.config {
ExtensionConfig::Platform {
description,
display_name,
..
} => {
assert_ne!(description, "old description");
assert_ne!(display_name.as_deref(), Some("Old Name"));
}
other => panic!("expected platform extension, got {other:?}"),
}
}
#[test]
fn test_migrate_platform_extensions_preserves_existing_enabled_values() {
let mut config = Mapping::new();
let mut extensions = Mapping::new();
let analyze_entry = ExtensionEntry {
config: ExtensionConfig::Platform {
name: "analyze".to_string(),
description: "Analyze code structure".to_string(),
display_name: Some("Analyze".to_string()),
bundled: Some(true),
available_tools: Vec::new(),
},
enabled: true,
};
let developer_entry = ExtensionEntry {
config: ExtensionConfig::Platform {
name: "developer".to_string(),
description: "Write and edit files, and execute shell commands".to_string(),
display_name: Some("Developer".to_string()),
bundled: Some(true),
available_tools: Vec::new(),
},
enabled: false,
};
let custom_developer_entry = ExtensionEntry {
config: ExtensionConfig::Stdio {
name: "developer".to_string(),
description: "Custom user developer tools".to_string(),
cmd: "custom-developer".to_string(),
args: Vec::new(),
envs: Default::default(),
env_keys: Vec::new(),
timeout: Some(300),
bundled: None,
available_tools: Vec::new(),
},
enabled: true,
};
extensions.insert(
serde_yaml::Value::String("analyze".to_string()),
serde_yaml::to_value(&analyze_entry).unwrap(),
);
extensions.insert(
serde_yaml::Value::String("developer".to_string()),
serde_yaml::to_value(&developer_entry).unwrap(),
);
extensions.insert(
serde_yaml::Value::String("custom-developer".to_string()),
serde_yaml::to_value(&custom_developer_entry).unwrap(),
);
config.insert(
serde_yaml::Value::String(EXTENSIONS_CONFIG_KEY.to_string()),
serde_yaml::Value::Mapping(extensions),
);
assert!(run_migrations(&mut config));
let extensions_key = serde_yaml::Value::String(EXTENSIONS_CONFIG_KEY.to_string());
let extensions = config.get(&extensions_key).unwrap().as_mapping().unwrap();
let analyze: ExtensionEntry = serde_yaml::from_value(
extensions
.get(serde_yaml::Value::String("analyze".to_string()))
.unwrap()
.clone(),
)
.unwrap();
let developer: ExtensionEntry = serde_yaml::from_value(
extensions
.get(serde_yaml::Value::String("developer".to_string()))
.unwrap()
.clone(),
)
.unwrap();
let custom_developer: ExtensionEntry = serde_yaml::from_value(
extensions
.get(serde_yaml::Value::String("custom-developer".to_string()))
.unwrap()
.clone(),
)
.unwrap();
assert!(analyze.enabled);
assert!(!developer.enabled);
assert!(custom_developer.enabled);
}
#[test]
+5 -5
View File
@@ -144,7 +144,7 @@ pub async fn run_config_mcp<C: Connection>() {
let mcp = McpFixture::new(expected_session_id.clone()).await;
let config_yaml = format!(
"GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n",
"GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions_on_demand_migration: true\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n",
mcp.url
);
fs::write(temp_dir.path().join(CONFIG_YAML_NAME), config_yaml).unwrap();
@@ -194,7 +194,7 @@ pub async fn run_config_mcp<C: Connection>() {
pub async fn run_fs_read_text_file_true<C: Connection>() {
let temp_dir = tempfile::tempdir().unwrap();
let config_yaml = format!(
"GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions:\n developer:\n enabled: true\n type: platform\n name: developer\n description: Developer\n display_name: Developer\n bundled: true\n available_tools: []\n"
"GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions_on_demand_migration: true\nextensions:\n developer:\n enabled: true\n type: platform\n name: developer\n description: Developer\n display_name: Developer\n bundled: true\n available_tools: []\n"
);
fs::write(temp_dir.path().join(CONFIG_YAML_NAME), config_yaml).unwrap();
@@ -363,7 +363,7 @@ pub async fn run_load_mode<C: Connection>() {
let mcp = McpFixture::new(expected_session_id.clone()).await;
let config_yaml = format!(
"GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n",
"GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions_on_demand_migration: true\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n",
mcp.url
);
fs::write(temp_dir.path().join(CONFIG_YAML_NAME), config_yaml).unwrap();
@@ -601,7 +601,7 @@ async fn run_mode_set_impl<C: Connection>(via: SetModeVia) {
let mcp = McpFixture::new(expected_session_id.clone()).await;
let config_yaml = format!(
"GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n",
"GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions_on_demand_migration: true\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n",
mcp.url
);
fs::write(temp_dir.path().join(CONFIG_YAML_NAME), config_yaml).unwrap();
@@ -1198,7 +1198,7 @@ pub async fn run_prompt_skill<C: Connection>() {
.await;
let config = TestConnectionConfig {
builtins: vec!["summon".to_string()],
builtins: vec!["summon".to_string(), "skills".to_string()],
cwd: Some(cwd),
..Default::default()
};
@@ -253,6 +253,7 @@ fn test_developer_fs_requests_use_acp_session_id() {
// gpt-5-nano routes to the Responses API; use a Chat Completions
// model so the canned SSE fixtures are parsed correctly.
current_model: "gpt-4.1".to_string(),
builtins: vec!["developer".to_string()],
read_text_file: Some(Arc::new(move |req| {
*seen_session_id_clone.lock().unwrap() = Some(req.session_id.0.to_string());
Ok(sacp::schema::ReadTextFileResponse::new(