diff --git a/crates/goose-sdk/src/custom_notifications.rs b/crates/goose-sdk/src/custom_notifications.rs new file mode 100644 index 00000000..44424fb3 --- /dev/null +++ b/crates/goose-sdk/src/custom_notifications.rs @@ -0,0 +1,173 @@ +use crate::custom_requests::CustomMethodSchema; +use agent_client_protocol::{JsonRpcMessage, JsonRpcNotification}; +use schemars::{JsonSchema, SchemaGenerator}; +use serde::{Deserialize, Serialize}; + +/// Goose-custom session update notification — a parallel to ACP's +/// `session/update` carrying goose-specific update variants. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcNotification)] +#[notification(method = "_goose/unstable/session/update")] +#[serde(rename_all = "camelCase")] +pub struct GooseSessionNotification { + pub session_id: String, + pub update: GooseSessionUpdate, +} + +/// Discriminated union of goose-specific session update payloads. +/// Variant tag matches ACP's convention (`sessionUpdate: ""`). +/// +/// `discriminator.mapping` is what makes TS codegen (`@hey-api/openapi-ts`) +/// emit the correct snake_case tag value even when this enum has a single +/// variant. Add a mapping entry per variant. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "sessionUpdate", rename_all = "snake_case")] +#[schemars(extend("discriminator" = { + "propertyName": "sessionUpdate", + "mapping": { + "usage_update": "#/$defs/SessionUsageUpdate", + "status_message": "#/$defs/StatusMessageUpdate", + "interaction_update": "#/$defs/InteractionUpdate" + } +}))] +pub enum GooseSessionUpdate { + UsageUpdate(SessionUsageUpdate), + StatusMessage(StatusMessageUpdate), + InteractionUpdate(InteractionUpdate), +} + +impl Default for GooseSessionUpdate { + fn default() -> Self { + GooseSessionUpdate::UsageUpdate(SessionUsageUpdate::default()) + } +} + +/// Streaming context-window usage update for a session. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SessionUsageUpdate { + pub used: u64, + pub context_limit: u64, + pub accumulated_input_tokens: u64, + pub accumulated_output_tokens: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub accumulated_cost: Option, +} + +/// Live UI/session status. This is not conversation transcript content, and +/// should not be persisted or replayed as history. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct StatusMessageUpdate { + pub status: StatusMessage, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum StatusMessage { + #[serde(rename_all = "camelCase")] + Notice { message: String }, + #[serde(rename_all = "camelCase")] + Progress { message: String }, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct InteractionUpdate { + pub interaction: Interaction, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "_meta")] + pub meta: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Interaction { + #[serde(rename_all = "camelCase")] + Elicitation { + id: String, + state: InteractionState, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + requested_schema: Option, + }, +} + +impl Default for Interaction { + fn default() -> Self { + Self::Elicitation { + id: String::new(), + state: InteractionState::Pending, + message: None, + requested_schema: None, + } + } +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum InteractionState { + #[default] + Pending, + Submitted, +} + +fn notification_schema(generator: &mut SchemaGenerator) -> CustomMethodSchema +where + T: Default + JsonRpcMessage + JsonSchema, +{ + let dummy = T::default(); + let type_name = std::any::type_name::() + .rsplit("::") + .next() + .unwrap_or(std::any::type_name::()) + .to_string(); + CustomMethodSchema { + method: dummy.method().to_string(), + params_schema: Some(generator.subschema_for::()), + params_type_name: Some(type_name), + response_schema: None, + response_type_name: None, + } +} + +/// Schemas for every goose-custom outbound notification. To register a new +/// notification, define the struct above (with `JsonRpcNotification` + +/// `Default`) and add one line below. +pub fn custom_notification_schemas(generator: &mut SchemaGenerator) -> Vec { + vec![notification_schema::(generator)] +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn status_message_serializes_to_expected_wire_shape() { + let notification = GooseSessionNotification { + session_id: "s1".to_string(), + update: GooseSessionUpdate::StatusMessage(StatusMessageUpdate { + status: StatusMessage::Notice { + message: "Compaction complete".to_string(), + }, + }), + }; + + let value = serde_json::to_value(notification).unwrap(); + + assert_eq!( + value, + json!({ + "sessionId": "s1", + "update": { + "sessionUpdate": "status_message", + "status": { + "type": "notice", + "message": "Compaction complete" + } + } + }) + ); + } +} diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index 5b007574..672ff8b0 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -436,6 +436,17 @@ pub struct ImportSessionResponse { pub message_count: u64, } +/// Submit a response for a pending MCP elicitation in an active session. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/elicitation/respond", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct ElicitationRespondRequest { + pub session_id: String, + pub elicitation_id: String, + #[serde(default)] + pub user_data: serde_json::Value, +} + #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct ProviderConfigKey { diff --git a/crates/goose-sdk/src/lib.rs b/crates/goose-sdk/src/lib.rs index 6c1c1bf5..eb50a017 100644 --- a/crates/goose-sdk/src/lib.rs +++ b/crates/goose-sdk/src/lib.rs @@ -1 +1,2 @@ +pub mod custom_notifications; pub mod custom_requests; diff --git a/crates/goose-server/src/routes/recipe_utils.rs b/crates/goose-server/src/routes/recipe_utils.rs index 800283dd..085fe354 100644 --- a/crates/goose-server/src/routes/recipe_utils.rs +++ b/crates/goose-server/src/routes/recipe_utils.rs @@ -1,7 +1,4 @@ use std::collections::HashMap; -use std::fs; -use std::hash::DefaultHasher; -use std::hash::{Hash, Hasher}; use std::path::PathBuf; use std::sync::Arc; @@ -10,10 +7,10 @@ use crate::state::AppState; use anyhow::Result; use axum::http::StatusCode; use goose::agents::Agent; -use goose::recipe::build_recipe::{ - build_recipe_from_template, resolve_sub_recipe_path, RecipeError, -}; -use goose::recipe::local_recipes::{get_recipe_library_dir, list_local_recipes}; +use goose::recipe::build_recipe::{build_recipe_from_template, RecipeError}; +use goose::recipe::local_recipes::get_recipe_library_dir; +pub use goose::recipe::manifest::short_id_from_path; +use goose::recipe::manifest::{list_recipe_file_manifests, load_recipe_from_path}; use goose::recipe::validate_recipe::validate_recipe_template_from_content; use goose::recipe::Recipe; use serde::Serialize; @@ -36,44 +33,18 @@ pub struct RecipeManifest { pub slash_command: Option, } -pub fn short_id_from_path(path: &str) -> String { - let mut hasher = DefaultHasher::new(); - path.hash(&mut hasher); - let h = hasher.finish(); - format!("{:016x}", h) -} - pub fn get_all_recipes_manifests() -> Result> { - let recipes_with_path = list_local_recipes()?; - let mut recipe_manifests_with_path = Vec::new(); - for (file_path, mut recipe) in recipes_with_path { - let Ok(last_modified) = fs::metadata(file_path.clone()) - .map(|m| chrono::DateTime::::from(m.modified().unwrap()).to_rfc3339()) - else { - continue; - }; - - if let Some(recipe_dir) = file_path.parent() { - if let Some(ref mut sub_recipes) = recipe.sub_recipes { - for sr in sub_recipes.iter_mut() { - if let Ok(resolved) = resolve_sub_recipe_path(&sr.path, recipe_dir) { - sr.path = resolved; - } - } - } - } - - let manifest_with_path = RecipeManifest { - id: short_id_from_path(file_path.to_string_lossy().as_ref()), - recipe, - file_path, - last_modified, + let recipe_manifests_with_path = list_recipe_file_manifests()? + .into_iter() + .map(|manifest| RecipeManifest { + id: manifest.id, + recipe: manifest.recipe, + file_path: manifest.file_path, + last_modified: manifest.last_modified, schedule_cron: None, slash_command: None, - }; - recipe_manifests_with_path.push(manifest_with_path); - } - recipe_manifests_with_path.sort_by(|a, b| b.last_modified.cmp(&a.last_modified)); + }) + .collect(); Ok(recipe_manifests_with_path) } @@ -138,22 +109,10 @@ pub async fn get_recipe_file_path_by_id( pub async fn load_recipe_by_id(state: &AppState, id: &str) -> Result { let path = get_recipe_file_path_by_id(state, id).await?; - let mut recipe = Recipe::from_file_path(&path).map_err(|err| ErrorResponse { + load_recipe_from_path(&path).map_err(|err| ErrorResponse { message: format!("Failed to load recipe: {}", err), status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - - if let Some(recipe_dir) = path.parent() { - if let Some(ref mut sub_recipes) = recipe.sub_recipes { - for sr in sub_recipes.iter_mut() { - if let Ok(resolved) = resolve_sub_recipe_path(&sr.path, recipe_dir) { - sr.path = resolved; - } - } - } - } - - Ok(recipe) + }) } pub async fn build_recipe_with_parameter_values( diff --git a/crates/goose/acp-meta.json b/crates/goose/acp-meta.json index e1d6679e..e72f4370 100644 --- a/crates/goose/acp-meta.json +++ b/crates/goose/acp-meta.json @@ -185,6 +185,11 @@ "requestType": "ImportSessionRequest_unstable", "responseType": "ImportSessionResponse_unstable" }, + { + "method": "_goose/unstable/elicitation/respond", + "requestType": "ElicitationRespondRequest_unstable", + "responseType": "EmptyResponse" + }, { "method": "_goose/unstable/session/project/update", "requestType": "UpdateSessionProjectRequest_unstable", @@ -285,5 +290,11 @@ "requestType": "DictationModelSelectRequest_unstable", "responseType": "EmptyResponse" } + ], + "notifications": [ + { + "method": "_goose/unstable/session/update", + "paramsType": "GooseSessionNotification_unstable" + } ] } diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 9afa77e6..5980ff34 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -1887,6 +1887,27 @@ "x-side": "agent", "x-method": "_goose/unstable/session/import" }, + "ElicitationRespondRequest_unstable": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "elicitationId": { + "type": "string" + }, + "userData": { + "default": null + } + }, + "required": [ + "sessionId", + "elicitationId" + ], + "description": "Submit a response for a pending MCP elicitation in an active session.", + "x-side": "agent", + "x-method": "_goose/unstable/elicitation/respond" + }, "UpdateSessionProjectRequest_unstable": { "type": "object", "properties": { @@ -2651,6 +2672,209 @@ "x-side": "agent", "x-method": "_goose/unstable/dictation/models/select" }, + "GooseSessionNotification_unstable": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "update": { + "$ref": "#/$defs/GooseSessionUpdate" + } + }, + "required": [ + "sessionId", + "update" + ], + "description": "Goose-custom session update notification — a parallel to ACP's\n`session/update` carrying goose-specific update variants.", + "x-side": "agent", + "x-method": "_goose/unstable/session/update" + }, + "GooseSessionUpdate": { + "oneOf": [ + { + "$ref": "#/$defs/SessionUsageUpdate", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "usage_update" + } + }, + "required": [ + "sessionUpdate" + ] + }, + { + "$ref": "#/$defs/StatusMessageUpdate", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "status_message" + } + }, + "required": [ + "sessionUpdate" + ] + }, + { + "$ref": "#/$defs/InteractionUpdate", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "interaction_update" + } + }, + "required": [ + "sessionUpdate" + ] + } + ], + "description": "Discriminated union of goose-specific session update payloads.\nVariant tag matches ACP's convention (`sessionUpdate: \"\"`).\n\n`discriminator.mapping` is what makes TS codegen (`@hey-api/openapi-ts`)\nemit the correct snake_case tag value even when this enum has a single\nvariant. Add a mapping entry per variant.", + "discriminator": { + "propertyName": "sessionUpdate", + "mapping": { + "usage_update": "#/$defs/SessionUsageUpdate", + "status_message": "#/$defs/StatusMessageUpdate", + "interaction_update": "#/$defs/InteractionUpdate" + } + } + }, + "SessionUsageUpdate": { + "type": "object", + "properties": { + "used": { + "type": "integer", + "minimum": 0 + }, + "contextLimit": { + "type": "integer", + "minimum": 0 + }, + "accumulatedInputTokens": { + "type": "integer", + "minimum": 0 + }, + "accumulatedOutputTokens": { + "type": "integer", + "minimum": 0 + }, + "accumulatedCost": { + "type": [ + "number", + "null" + ], + "format": "double" + } + }, + "required": [ + "used", + "contextLimit", + "accumulatedInputTokens", + "accumulatedOutputTokens" + ], + "description": "Streaming context-window usage update for a session." + }, + "StatusMessage": { + "oneOf": [ + { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "type": { + "type": "string", + "const": "notice" + } + }, + "required": [ + "type", + "message" + ] + }, + { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "type": { + "type": "string", + "const": "progress" + } + }, + "required": [ + "type", + "message" + ] + } + ] + }, + "StatusMessageUpdate": { + "type": "object", + "properties": { + "status": { + "$ref": "#/$defs/StatusMessage" + } + }, + "required": [ + "status" + ], + "description": "Live UI/session status. This is not conversation transcript content, and\nshould not be persisted or replayed as history." + }, + "Interaction": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "state": { + "$ref": "#/$defs/InteractionState" + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "requestedSchema": {}, + "type": { + "type": "string", + "const": "elicitation" + } + }, + "required": [ + "type", + "id", + "state" + ] + } + ] + }, + "InteractionState": { + "type": "string", + "enum": [ + "pending", + "submitted" + ] + }, + "InteractionUpdate": { + "type": "object", + "properties": { + "interaction": { + "$ref": "#/$defs/Interaction" + }, + "_meta": {} + }, + "required": [ + "interaction" + ] + }, "ExtRequest": { "properties": { "id": { @@ -2996,6 +3220,15 @@ "description": "Params for _goose/unstable/session/import", "title": "ImportSessionRequest_unstable" }, + { + "allOf": [ + { + "$ref": "#/$defs/ElicitationRespondRequest_unstable" + } + ], + "description": "Params for _goose/unstable/elicitation/respond", + "title": "ElicitationRespondRequest_unstable" + }, { "allOf": [ { @@ -3523,6 +3756,42 @@ } ], "x-docs-ignore": true + }, + "ExtNotification": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "anyOf": [ + { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/GooseSessionNotification_unstable" + } + ], + "description": "Params for _goose/unstable/session/update", + "title": "GooseSessionNotification_unstable" + } + ] + }, + { + "description": "Untyped params", + "type": [ + "object", + "null" + ] + } + ] + } + }, + "required": [ + "method" + ], + "type": "object", + "x-docs-ignore": true } }, "anyOf": [ @@ -3543,6 +3812,15 @@ ], "description": "Extension response (agent → client)", "title": "Response" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExtNotification" + } + ], + "description": "Extension notification (agent → client, fire-and-forget)", + "title": "Notification" } ] } diff --git a/crates/goose/src/acp/mod.rs b/crates/goose/src/acp/mod.rs index 594c14ca..1a0396a4 100644 --- a/crates/goose/src/acp/mod.rs +++ b/crates/goose/src/acp/mod.rs @@ -3,12 +3,14 @@ mod common; pub(crate) mod fs; mod mcp_app_proxy; mod provider; +mod response_builder; pub mod server; pub mod server_factory; pub(crate) mod tools; pub mod transport; pub use common::{map_permission_response, PermissionDecision}; +pub use goose_sdk::custom_notifications; pub use goose_sdk::custom_requests; pub use provider::{ extension_configs_to_mcp_servers, AcpProvider, AcpProviderConfig, ACP_CURRENT_MODEL, diff --git a/crates/goose/src/acp/response_builder.rs b/crates/goose/src/acp/response_builder.rs new file mode 100644 index 00000000..7a93bce5 --- /dev/null +++ b/crates/goose/src/acp/response_builder.rs @@ -0,0 +1,400 @@ +use crate::config::GooseMode; +use crate::providers::inventory::{ProviderInventoryEntry, ProviderInventoryService}; +use crate::session::Session; +use agent_client_protocol::schema::{ + AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, ModelId, ModelInfo, + SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption, SessionId, + SessionMode, SessionModeId, SessionModeState, SessionModelState, SessionNotification, + SessionUpdate, UnstructuredCommandInput, +}; +use agent_client_protocol::{Client, ConnectionTo}; +use strum::{EnumMessage, VariantNames}; + +use super::server::{build_usage_updates, DEFAULT_PROVIDER_ID, DEFAULT_PROVIDER_LABEL}; + +pub(super) fn session_provider_selection(session: &Session) -> &str { + session + .provider_name + .as_deref() + .unwrap_or(DEFAULT_PROVIDER_ID) +} + +pub(super) fn build_model_state( + current_model: &str, + inventory: &ProviderInventoryEntry, +) -> SessionModelState { + let mut available_models = inventory + .models + .iter() + .map(|model| ModelInfo::new(ModelId::new(model.id.as_str()), model.name.as_str())) + .collect::>(); + if !available_models + .iter() + .any(|model| model.model_id.0.as_ref() == current_model) + { + available_models.insert( + 0, + ModelInfo::new(ModelId::new(current_model), current_model), + ); + } + SessionModelState::new(ModelId::new(current_model), available_models) +} + +struct ProviderOptionEntry { + id: String, + label: String, +} + +async fn list_provider_entries(current_provider: Option<&str>) -> Vec { + let mut providers = crate::providers::providers() + .await + .into_iter() + .map(|(metadata, _)| ProviderOptionEntry { + id: metadata.name, + label: metadata.display_name, + }) + .collect::>(); + providers.sort_by(|left, right| left.id.cmp(&right.id)); + providers.dedup_by(|left, right| left.id == right.id); + + if let Some(current_provider) = current_provider { + if current_provider != DEFAULT_PROVIDER_ID + && !providers + .iter() + .any(|provider| provider.id == current_provider) + { + providers.push(ProviderOptionEntry { + id: current_provider.to_string(), + label: current_provider.to_string(), + }); + providers.sort_by(|left, right| left.id.cmp(&right.id)); + } + } + + let mut entries = Vec::with_capacity(providers.len() + 1); + entries.push(ProviderOptionEntry { + id: DEFAULT_PROVIDER_ID.to_string(), + label: DEFAULT_PROVIDER_LABEL.to_string(), + }); + entries.extend(providers); + entries +} + +pub(super) async fn build_provider_options( + current_provider: Option<&str>, +) -> Vec { + list_provider_entries(current_provider) + .await + .into_iter() + .map(|provider| SessionConfigSelectOption::new(provider.id, provider.label)) + .collect() +} + +pub(super) fn should_refresh_inventory_for_session_init(entry: &ProviderInventoryEntry) -> bool { + entry.configured + && entry.supports_refresh + && (entry.last_updated_at.is_none() || ProviderInventoryService::is_stale(entry)) +} + +pub(super) fn build_mode_state( + current_mode: GooseMode, +) -> Result { + let mut available = Vec::with_capacity(GooseMode::VARIANTS.len()); + for &name in GooseMode::VARIANTS { + let goose_mode: GooseMode = name.parse().map_err(|_| { + agent_client_protocol::Error::internal_error() // impossible but satisfy linters + .data(format!("Failed to parse GooseMode variant: {}", name)) + })?; + let mut mode = SessionMode::new(SessionModeId::new(name), name); + mode.description = goose_mode.get_message().map(Into::into); + available.push(mode); + } + Ok(SessionModeState::new( + SessionModeId::new(current_mode.to_string()), + available, + )) +} + +pub(super) async fn build_session_setup_config( + provider_inventory: &ProviderInventoryService, + session: &Session, +) -> Result< + ( + SessionModeState, + Option, + Option>, + ), + agent_client_protocol::Error, +> { + let mode_state = build_mode_state(session.goose_mode)?; + + let (Some(provider_name), Some(model_config)) = ( + session.provider_name.as_deref(), + session.model_config.as_ref(), + ) else { + return Ok((mode_state, None, None)); + }; + let Some(inventory) = provider_inventory + .find_entry_for_provider(provider_name) + .await + else { + return Ok((mode_state, None, None)); + }; + let model_state = build_model_state(model_config.model_name.as_str(), &inventory); + let provider_selection = session_provider_selection(session); + let provider_options = build_provider_options(Some(provider_name)).await; + let config_options = build_config_options( + &mode_state, + &model_state, + provider_selection, + provider_options, + ); + Ok((mode_state, Some(model_state), Some(config_options))) +} + +pub(super) fn build_config_options( + mode_state: &SessionModeState, + model_state: &SessionModelState, + provider_selection: &str, + provider_options: Vec, +) -> Vec { + let mode_options: Vec = mode_state + .available_modes + .iter() + .map(|m| { + SessionConfigSelectOption::new(m.id.0.clone(), m.name.clone()) + .description(m.description.clone()) + }) + .collect(); + let model_options: Vec = model_state + .available_models + .iter() + .map(|m| SessionConfigSelectOption::new(m.model_id.0.clone(), m.name.clone())) + .collect(); + vec![ + SessionConfigOption::select( + "provider", + "Provider", + provider_selection.to_string(), + provider_options, + ), + SessionConfigOption::select( + "mode", + "Mode", + mode_state.current_mode_id.0.clone(), + mode_options, + ) + .category(SessionConfigOptionCategory::Mode), + SessionConfigOption::select( + "model", + "Model", + model_state.current_model_id.0.clone(), + model_options, + ) + .category(SessionConfigOptionCategory::Model), + ] +} + +fn available_commands_update(working_dir: &std::path::Path) -> AvailableCommandsUpdate { + let commands = crate::slash_commands::slash_command::list_acp_commands(Some(working_dir)) + .into_iter() + .map(|entry| { + let mut command = AvailableCommand::new(entry.name, entry.description); + if let Some(input_hint) = entry.input_hint { + command = command.input(AvailableCommandInput::Unstructured( + UnstructuredCommandInput::new(input_hint), + )); + } + command + }) + .collect(); + + AvailableCommandsUpdate::new(commands) +} + +pub(super) fn send_session_setup_notifications( + cx: &ConnectionTo, + session: &Session, +) -> Result<(), agent_client_protocol::Error> { + let session_id = SessionId::new(session.id.clone()); + if let Some(updates) = build_usage_updates(session) { + cx.send_notification(updates.custom)?; + cx.send_notification(SessionNotification::new( + session_id.clone(), + SessionUpdate::UsageUpdate(updates.standard), + ))?; + } + cx.send_notification(SessionNotification::new( + session_id, + SessionUpdate::AvailableCommandsUpdate(available_commands_update(&session.working_dir)), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use test_case::test_case; + + #[test_case( + vec!["model-a".into(), "model-b".into()] + => SessionModelState::new( + ModelId::new("unused"), + vec![ModelInfo::new(ModelId::new("unused"), "unused"), + ModelInfo::new(ModelId::new("model-a"), "model-a"), + ModelInfo::new(ModelId::new("model-b"), "model-b")], + ) + ; "returns current and available models" + )] + #[test_case( + vec![] + => SessionModelState::new( + ModelId::new("unused"), + vec![ModelInfo::new(ModelId::new("unused"), "unused")], + ) + ; "empty model list" + )] + fn test_build_model_state(models: Vec) -> SessionModelState { + let inventory = ProviderInventoryEntry { + provider_id: "mock".to_string(), + provider_name: "Mock".to_string(), + description: "Mock".to_string(), + default_model: "unused".to_string(), + configured: true, + provider_type: crate::providers::base::ProviderType::Builtin, + category: crate::providers::catalog::ProviderSetupCategory::Model, + config_keys: vec![], + setup_steps: vec![], + supports_refresh: true, + refreshing: false, + models: models + .into_iter() + .map(|id| crate::providers::inventory::InventoryModel { + name: id.clone(), + id, + family: None, + context_limit: None, + reasoning: None, + recommended: false, + }) + .collect(), + last_updated_at: None, + last_refresh_attempt_at: None, + last_refresh_error: None, + model_selection_hint: None, + }; + build_model_state("unused", &inventory) + } + + #[test_case( + GooseMode::Auto + => Ok(SessionModeState::new( + SessionModeId::new("auto"), + vec![ + SessionMode::new(SessionModeId::new("auto"), "auto") + .description("Automatically approve tool calls"), + SessionMode::new(SessionModeId::new("approve"), "approve") + .description("Ask before every tool call"), + SessionMode::new(SessionModeId::new("smart_approve"), "smart_approve") + .description("Ask only for sensitive tool calls"), + SessionMode::new(SessionModeId::new("chat"), "chat") + .description("Chat only, no tool calls"), + ], + )) + ; "auto mode" + )] + #[test_case( + GooseMode::Approve + => Ok(SessionModeState::new( + SessionModeId::new("approve"), + vec![ + SessionMode::new(SessionModeId::new("auto"), "auto") + .description("Automatically approve tool calls"), + SessionMode::new(SessionModeId::new("approve"), "approve") + .description("Ask before every tool call"), + SessionMode::new(SessionModeId::new("smart_approve"), "smart_approve") + .description("Ask only for sensitive tool calls"), + SessionMode::new(SessionModeId::new("chat"), "chat") + .description("Chat only, no tool calls"), + ], + )) + ; "approve mode" + )] + fn test_build_mode_state( + current_mode: GooseMode, + ) -> Result { + build_mode_state(current_mode) + } + + #[test_case( + build_mode_state(GooseMode::Auto).unwrap(), + "openai", + vec![ + SessionConfigSelectOption::new("anthropic", "anthropic"), + SessionConfigSelectOption::new("openai", "openai"), + ], + SessionModelState::new( + ModelId::new("gpt-4"), + vec![ModelInfo::new(ModelId::new("gpt-4"), "gpt-4"), ModelInfo::new(ModelId::new("gpt-3.5"), "gpt-3.5")], + ) + => vec![ + SessionConfigOption::select( + "provider", "Provider", "openai", + vec![ + SessionConfigSelectOption::new("anthropic", "anthropic"), + SessionConfigSelectOption::new("openai", "openai"), + ], + ), + SessionConfigOption::select( + "mode", "Mode", "auto", + vec![ + SessionConfigSelectOption::new("auto", "auto").description("Automatically approve tool calls"), + SessionConfigSelectOption::new("approve", "approve").description("Ask before every tool call"), + SessionConfigSelectOption::new("smart_approve", "smart_approve").description("Ask only for sensitive tool calls"), + SessionConfigSelectOption::new("chat", "chat").description("Chat only, no tool calls"), + ], + ).category(SessionConfigOptionCategory::Mode), + SessionConfigOption::select( + "model", "Model", "gpt-4", + vec![ + SessionConfigSelectOption::new("gpt-4", "gpt-4"), + SessionConfigSelectOption::new("gpt-3.5", "gpt-3.5"), + ], + ).category(SessionConfigOptionCategory::Model), + ] + ; "auto mode with multiple models" + )] + #[test_case( + build_mode_state(GooseMode::Approve).unwrap(), + "openai", + vec![SessionConfigSelectOption::new("openai", "openai")], + SessionModelState::new(ModelId::new("only-model"), vec![ModelInfo::new(ModelId::new("only-model"), "only-model")]) + => vec![ + SessionConfigOption::select( + "provider", "Provider", "openai", + vec![SessionConfigSelectOption::new("openai", "openai")], + ), + SessionConfigOption::select( + "mode", "Mode", "approve", + vec![ + SessionConfigSelectOption::new("auto", "auto").description("Automatically approve tool calls"), + SessionConfigSelectOption::new("approve", "approve").description("Ask before every tool call"), + SessionConfigSelectOption::new("smart_approve", "smart_approve").description("Ask only for sensitive tool calls"), + SessionConfigSelectOption::new("chat", "chat").description("Chat only, no tool calls"), + ], + ).category(SessionConfigOptionCategory::Mode), + SessionConfigOption::select( + "model", "Model", "only-model", + vec![SessionConfigSelectOption::new("only-model", "only-model")], + ).category(SessionConfigOptionCategory::Model), + ] + ; "approve mode with single model" + )] + fn test_build_config_options( + mode_state: SessionModeState, + provider_name: &'static str, + provider_options: Vec, + model_state: SessionModelState, + ) -> Vec { + build_config_options(&mode_state, &model_state, provider_name, provider_options) + } +} diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index b95010df..d6bd5f7f 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -1,49 +1,61 @@ +use crate::acp::custom_notifications::*; use crate::acp::custom_requests::*; use crate::acp::fs::AcpTools; +pub(super) use crate::acp::response_builder::{ + build_config_options, build_mode_state, build_model_state, build_provider_options, + build_session_setup_config, send_session_setup_notifications, session_provider_selection, + should_refresh_inventory_for_session_init, +}; use crate::acp::tools::AcpAwareToolMeta; use crate::acp::{PermissionDecision, ACP_CURRENT_MODEL}; +use crate::action_required_manager::ActionRequiredManager; use crate::agents::extension::{Envs, PLATFORM_EXTENSIONS}; use crate::agents::extension_manager::TRUSTED_TOOL_UPDATE_META_KEY; use crate::agents::mcp_client::{GooseMcpHostInfo, McpClientTrait}; use crate::agents::platform_extensions::developer::DeveloperClient; -use crate::agents::{Agent, AgentConfig, ExtensionConfig, GoosePlatform, SessionConfig}; +use crate::agents::{ + Agent, AgentConfig, ExtensionConfig, ExtensionLoadResult, GoosePlatform, SessionConfig, +}; use crate::config::base::CONFIG_YAML_NAME; 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, ToolRequest}; +use crate::conversation::message::{ + ActionRequiredData, Message, MessageContent, SystemNotificationContent, SystemNotificationType, + ToolRequest, +}; +use crate::execution::manager::{AgentManager, AgentManagerGetResult, RuntimeContext}; use crate::mcp_utils::ToolResult; use crate::permission::permission_confirmation::PrincipalType; use crate::permission::{Permission, PermissionConfirmation}; use crate::providers::base::Provider; use crate::providers::inventory::{ - InventoryIdentity, ProviderInventoryEntry, ProviderInventoryService, RefreshJobPlan, - RefreshPlan, RefreshSkipReason, + ProviderInventoryEntry, ProviderInventoryService, RefreshJobPlan, RefreshPlan, + RefreshSkipReason, }; use crate::session::session_manager::{SessionListCursor, SessionType}; -use crate::session::{EnabledExtensionsState, Session, SessionManager}; +use crate::session::{ + EnabledExtensionsState, ExtensionData, ExtensionState, Session, SessionManager, +}; use crate::source_roots::SourceRoot; use crate::utils::sanitize_unicode_tags; use agent_client_protocol::schema::{ AgentCapabilities, Annotations, AuthMethod, AuthMethodAgent, AuthenticateRequest, - AuthenticateResponse, AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, - BlobResourceContents, CancelNotification, CloseSessionRequest, CloseSessionResponse, - ConfigOptionUpdate, Content, ContentBlock, ContentChunk, CurrentModeUpdate, EmbeddedResource, - EmbeddedResourceResource, FileSystemCapabilities, ForkSessionRequest, ForkSessionResponse, - ImageContent, InitializeRequest, InitializeResponse, ListSessionsRequest, ListSessionsResponse, - LoadSessionRequest, LoadSessionResponse, McpCapabilities, McpServer, Meta, ModelId, ModelInfo, - NewSessionRequest, NewSessionResponse, PermissionOption, PermissionOptionKind, - PromptCapabilities, PromptRequest, PromptResponse, RequestPermissionOutcome, - RequestPermissionRequest, ResourceLink, SessionCapabilities, SessionCloseCapabilities, - SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption, SessionId, - SessionInfo, SessionInfoUpdate, SessionListCapabilities, SessionMode, SessionModeId, - SessionModeState, SessionModelState, SessionNotification, SessionUpdate, - SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest, - SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, StopReason, - TextContent, TextResourceContents, ToolCall, ToolCallContent, ToolCallId, ToolCallLocation, - ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput, - Usage, UsageUpdate, + AuthenticateResponse, BlobResourceContents, CancelNotification, CloseSessionRequest, + CloseSessionResponse, ConfigOptionUpdate, Content, ContentBlock, ContentChunk, + CurrentModeUpdate, EmbeddedResource, EmbeddedResourceResource, FileSystemCapabilities, + ForkSessionRequest, ForkSessionResponse, ImageContent, InitializeRequest, InitializeResponse, + ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, + McpCapabilities, McpServer, Meta, NewSessionRequest, NewSessionResponse, PermissionOption, + PermissionOptionKind, PromptCapabilities, PromptRequest, PromptResponse, + RequestPermissionOutcome, RequestPermissionRequest, ResourceLink, SessionCapabilities, + SessionCloseCapabilities, SessionConfigOption, SessionId, SessionInfo, SessionInfoUpdate, + SessionListCapabilities, SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, + SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, + SetSessionModelRequest, SetSessionModelResponse, StopReason, TextContent, TextResourceContents, + ToolCall, ToolCallContent, ToolCallId, ToolCallLocation, ToolCallStatus, ToolCallUpdate, + ToolCallUpdateFields, ToolKind, Usage, UsageUpdate, }; use agent_client_protocol::util::MatchDispatchFrom; use agent_client_protocol::{ @@ -53,7 +65,7 @@ use agent_client_protocol::{ use anyhow::Result; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use fs_err as fs; -use futures::future::{BoxFuture, Either}; +use futures::future::BoxFuture; use futures::stream::{self, StreamExt}; use futures::FutureExt; use rmcp::model::{ @@ -65,7 +77,6 @@ use std::collections::{HashMap, HashSet}; use std::panic::AssertUnwindSafe; use std::path::{Path, PathBuf}; use std::sync::Arc; -use strum::{EnumMessage, VariantNames}; use tokio::sync::{Mutex, OnceCell}; use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _}; use tokio_util::sync::CancellationToken; @@ -77,10 +88,13 @@ mod custom_dispatch; mod dictation; mod dispatch; mod extensions; +mod fork_session; +mod load_session; +mod manage_sessions; +mod new_session; mod onboarding; mod providers; mod resources; -mod sessions; mod sources; mod tools; @@ -131,24 +145,10 @@ impl ResultExt for Result { } } -const DEFAULT_PROVIDER_ID: &str = "goose"; -const DEFAULT_PROVIDER_LABEL: &str = "Goose (Default)"; +pub(super) const DEFAULT_PROVIDER_ID: &str = "goose"; +pub(super) const DEFAULT_PROVIDER_LABEL: &str = "Goose (Default)"; const PROVIDER_CONFIG_STATUS_CHECK_CONCURRENCY: usize = 16; -async fn ensure_refresh_identity_current( - provider_id: &str, - planned_identity: &InventoryIdentity, -) -> Result<()> { - let current_identity = crate::providers::inventory_identity(provider_id) - .await? - .into_identity()?; - if current_identity != *planned_identity { - anyhow::bail!("provider inventory identity changed before refresh completed"); - } - - Ok(()) -} - /// In-memory state for an active ACP session. /// /// ## Terminology (temporary, until all clients migrate to ACP) @@ -161,7 +161,7 @@ async fn ensure_refresh_identity_current( /// The ACP session ID maps directly to a `sessions` row. The `sessions` HashMap /// below is keyed by session ID. struct GooseAcpSession { - agent: AgentHandle, + agent: Arc, tool_requests: HashMap, /// For each tool_call_id that belongs to a multi-tool chain (run of /// consecutive ToolRequest blocks within one assistant message), the chain @@ -176,9 +176,6 @@ struct GooseAcpSession { /// Idempotence guard so we summarize each chain at most once. summarized_chains: HashSet, cancel_token: Option, - /// Working directory set while the agent was still loading. - /// Applied once the agent becomes ready. - pending_working_dir: Option, } /// A run of consecutive ToolRequest blocks within one assistant message, @@ -193,45 +190,11 @@ struct ToolChain { message_id: String, } -/// Progress stages signalled by the background agent setup task via the watch -/// channel. `ProviderReady` fires as soon as the provider (and goose-mode) -/// are initialized — before extensions finish loading. `FullyReady` fires -/// once every extension has been loaded (or failed). -#[derive(Clone)] -enum AgentSetupProgress { - /// Provider is initialized; extensions are still loading in the background. - ProviderReady(Arc), - /// Provider *and* all extensions are initialized. - FullyReady(Arc), -} - -type AgentSetupSignal = Option>; - -/// The agent may still be initializing in the background (extension loading, -/// provider setup). Callers that need the live agent (e.g. `on_prompt`) await -/// the handle; callers that only need the session metadata can proceed without it. -enum AgentHandle { - Ready(Arc), - Loading(tokio::sync::watch::Receiver), -} - -struct AgentSetupRequest { - session_id: SessionId, - goose_session: Session, - mcp_servers: Vec, - /// Pre-resolved provider name + model config (from config, no network). - /// When present the spawn skips re-deriving these from config. - resolved_provider: Option<(String, crate::model::ModelConfig)>, - /// Pre-instantiated provider reused from synchronous session initialization. - prebuilt_provider: Option>, -} - pub struct GooseAcpAgentOptions { pub provider_factory: AcpProviderFactory, pub builtins: Vec, pub data_dir: std::path::PathBuf, pub config_dir: std::path::PathBuf, - pub goose_mode: GooseMode, pub disable_session_naming: bool, pub goose_platform: GoosePlatform, pub additional_source_roots: Vec, @@ -239,26 +202,26 @@ pub struct GooseAcpAgentOptions { pub struct GooseAcpAgent { sessions: Arc>>, + agent_manager: Arc, provider_factory: AcpProviderFactory, builtins: Vec, client_fs_capabilities: OnceCell, client_terminal: OnceCell, client_mcp_host_info: OnceCell, use_login_shell_path: OnceCell, + client_cx: OnceCell>, config_dir: std::path::PathBuf, session_manager: Arc, permission_manager: Arc, - goose_mode: GooseMode, disable_session_naming: bool, provider_inventory: ProviderInventoryService, - goose_platform: GoosePlatform, additional_source_roots: Vec, } /// Shorten a session/thread id for perf log correlation. /// All `perf:` logs use `sid=<8-char-prefix>` so a single session's activity /// can be extracted with `grep 'perf:' | grep 'sid=abc12345'`. -fn sid_short(id: &str) -> String { +pub(super) fn sid_short(id: &str) -> String { id.chars().take(8).collect() } @@ -350,7 +313,20 @@ fn encode_session_list_cursor( Ok(URL_SAFE_NO_PAD.encode(bytes)) } -fn session_meta(session: &Session) -> serde_json::Map { +fn display_title(s: &Session) -> Option { + if !s.user_set_name { + if let Some(recipe) = &s.recipe { + return Some(recipe.title.clone()); + } + } + if s.name.is_empty() { + None + } else { + Some(s.name.clone()) + } +} + +pub(super) fn session_meta(session: &Session) -> serde_json::Map { let mut meta = serde_json::Map::new(); meta.insert( "messageCount".to_string(), @@ -370,6 +346,10 @@ fn session_meta(session: &Session) -> serde_json::Map "userSetName".to_string(), serde_json::Value::Bool(session.user_set_name), ); + meta.insert( + "hasRecipe".to_string(), + serde_json::Value::Bool(session.recipe.is_some()), + ); if let Some(ref pid) = session.project_id { meta.insert( @@ -392,6 +372,12 @@ fn session_meta(session: &Session) -> serde_json::Map meta } +fn meta_string(meta: Option<&Meta>, key: &str) -> Option { + meta.and_then(|m| m.get(key)) + .and_then(|v| v.as_str()) + .map(ToString::to_string) +} + fn spawn_session_name_update_notifier( cx: ConnectionTo, ) -> tokio::sync::mpsc::UnboundedSender { @@ -528,6 +514,54 @@ fn mcp_server_to_extension_config(mcp_server: McpServer) -> Result, extension: ExtensionConfig) { + let name = extension.name().to_string(); + if let Some(index) = extensions + .iter() + .position(|existing| existing.name() == name) + { + extensions.remove(index); + } + extensions.push(extension); +} + +fn resolve_default_provider_model_config( + config: &Config, +) -> Result<(String, crate::model::ModelConfig), agent_client_protocol::Error> { + let resolved_provider = config.get_goose_provider().map_err(|error| { + agent_client_protocol::Error::internal_error() + .data(format!("Failed to resolve provider: {}", error)) + })?; + let resolved_model = config.get_goose_model().map_err(|error| { + agent_client_protocol::Error::internal_error() + .data(format!("Failed to resolve model: {}", error)) + })?; + let resolved_model_config = crate::model::ModelConfig::new(&resolved_model) + .map(|model_config| model_config.with_canonical_limits(&resolved_provider)) + .map_err(|error| { + agent_client_protocol::Error::internal_error() + .data(format!("Failed to resolve model: {}", error)) + })?; + Ok((resolved_provider, resolved_model_config)) +} + +async fn resolve_provider_default_model_config( + provider_name: &str, +) -> Result { + let entry = crate::providers::get_from_registry(provider_name) + .await + .map_err(|error| { + agent_client_protocol::Error::invalid_params() + .data(format!("Unknown provider '{}': {}", provider_name, error)) + })?; + crate::model::ModelConfig::new(&entry.metadata().default_model) + .map(|model_config| model_config.with_canonical_limits(provider_name)) + .map_err(|error| { + agent_client_protocol::Error::internal_error() + .data(format!("Failed to resolve model: {}", error)) + }) +} + fn get_requested_line(arguments: Option<&rmcp::model::JsonObject>) -> Option { arguments .and_then(|args| args.get("line")) @@ -795,50 +829,6 @@ struct PendingToolCall { fallback_title: String, } -/// Extract chains (runs of consecutive `MessageContent::ToolRequest` blocks) -/// from a single message's content. Mirrors the frontend's chain detection in -/// `MessageBubble.groupContentSections`: any non-tool block (text, thinking, -/// image, etc.) breaks the run. -/// -/// Returns one inner Vec per detected chain, holding the tool_call_ids in -/// document order. Single-tool runs are included; callers (chain -/// summarization) gate on `chain.len() >= 2`. -/// -/// Note: this is the per-message view, kept around for tests and potential -/// replay use. The live runtime path uses a streaming buffer fed by -/// [`register_chain_buffer`] so chains that span multiple `AgentEvent::Message` -/// events (e.g. Bedrock-style streaming, where one LLM message is split across -/// rows — see `f087fa63c`) are still detected. -#[allow(dead_code)] -fn extract_tool_chains( - content: &[crate::conversation::message::MessageContent], -) -> Vec> { - use crate::conversation::message::MessageContent; - let mut chains: Vec> = Vec::new(); - let mut current: Vec = Vec::new(); - - for block in content { - match block { - MessageContent::ToolRequest(tr) => current.push(tr.id.clone()), - MessageContent::ToolResponse(_) => { - // Server-side, assistant messages don't carry responses; - // responses arrive in subsequent messages. Treat as - // chain-neutral so a stray response doesn't split a chain - // if the data shape ever changes. - } - _ => { - if !current.is_empty() { - chains.push(std::mem::take(&mut current)); - } - } - } - } - if !current.is_empty() { - chains.push(current); - } - chains -} - /// If `buffer` holds a multi-tool run (≥ 2 tool requests), (re)register a /// [`ToolChain`] in `chain_membership` anchored on the **first** tool's /// message_id (the row [`SessionManager::update_tool_request_meta`] will patch @@ -849,11 +839,10 @@ fn extract_tool_chains( /// The buffer contains `(tool_call_id, message_id)` pairs in arrival order, /// fed by the prompt stream loop. Sequential tool use (Bedrock/Anthropic) /// interleaves request → response → request → response across separate -/// `AgentEvent::Message` events, so per-event `extract_tool_chains` only -/// sees length-1 chains and would miss the run. Tool responses are -/// chain-neutral (they don't split the run); only non-tool content (text, -/// thinking, image, etc.) does, matching the frontend's -/// `groupContentSections` behavior. +/// `AgentEvent::Message` events, so a per-event view would only see length-1 +/// chains and miss the run. Tool responses are chain-neutral (they don't +/// split the run); only non-tool content (text, thinking, image, etc.) does, +/// matching the frontend's `groupContentSections` behavior. fn extend_chain_membership( buffer: &[(String, String)], chain_membership: &mut HashMap>, @@ -928,117 +917,6 @@ fn builtin_to_extension_config(name: &str) -> ExtensionConfig { } } -fn build_model_state(current_model: &str, inventory: &ProviderInventoryEntry) -> SessionModelState { - let mut available_models = inventory - .models - .iter() - .map(|model| ModelInfo::new(ModelId::new(model.id.as_str()), model.name.as_str())) - .collect::>(); - if !available_models - .iter() - .any(|model| model.model_id.0.as_ref() == current_model) - { - available_models.insert( - 0, - ModelInfo::new(ModelId::new(current_model), current_model), - ); - } - SessionModelState::new(ModelId::new(current_model), available_models) -} - -struct ProviderOptionEntry { - id: String, - label: String, -} - -async fn list_provider_entries(current_provider: Option<&str>) -> Vec { - let mut providers = crate::providers::providers() - .await - .into_iter() - .map(|(metadata, _)| ProviderOptionEntry { - id: metadata.name, - label: metadata.display_name, - }) - .collect::>(); - providers.sort_by(|left, right| left.id.cmp(&right.id)); - providers.dedup_by(|left, right| left.id == right.id); - - if let Some(current_provider) = current_provider { - if current_provider != DEFAULT_PROVIDER_ID - && !providers - .iter() - .any(|provider| provider.id == current_provider) - { - providers.push(ProviderOptionEntry { - id: current_provider.to_string(), - label: current_provider.to_string(), - }); - providers.sort_by(|left, right| left.id.cmp(&right.id)); - } - } - - let mut entries = Vec::with_capacity(providers.len() + 1); - entries.push(ProviderOptionEntry { - id: DEFAULT_PROVIDER_ID.to_string(), - label: DEFAULT_PROVIDER_LABEL.to_string(), - }); - entries.extend(providers); - entries -} - -async fn build_provider_options(current_provider: Option<&str>) -> Vec { - list_provider_entries(current_provider) - .await - .into_iter() - .map(|provider| SessionConfigSelectOption::new(provider.id, provider.label)) - .collect() -} - -fn session_provider_selection(session: &Session) -> &str { - session - .provider_name - .as_deref() - .unwrap_or(DEFAULT_PROVIDER_ID) -} - -/// Resolve the provider name and model config for a session from an -/// already-loaded `Config`. -async fn resolve_provider_and_model_from_config( - config: &Config, - goose_session: &Session, -) -> Result<(String, crate::model::ModelConfig), String> { - let global_provider = config.get_goose_provider().ok(); - let provider_override = goose_session - .provider_name - .as_deref() - .filter(|p| *p != DEFAULT_PROVIDER_ID); - let provider_name = provider_override - .map(ToOwned::to_owned) - .or_else(|| global_provider.clone()) - .ok_or_else(|| "Missing provider".to_string())?; - let explicitly_switched = - provider_override.is_some() && provider_override != global_provider.as_deref(); - let model_config = match &goose_session.model_config { - Some(mc) => mc.clone(), - None if explicitly_switched => { - let entry = crate::providers::get_from_registry(&provider_name) - .await - .map_err(|e| e.to_string())?; - let default_model = &entry.metadata().default_model; - crate::model::ModelConfig::new(default_model) - .map_err(|e| e.to_string())? - .with_canonical_limits(&provider_name) - } - None => { - let model_id = config.get_goose_model().map_err(|e| e.to_string())?; - crate::model::ModelConfig::new(&model_id) - .map_err(|e| e.to_string())? - .with_canonical_limits(&provider_name) - } - }; - Ok((provider_name, model_config)) -} - fn with_preserved_session_request_params( mut model_config: crate::model::ModelConfig, current_model_config: Option<&crate::model::ModelConfig>, @@ -1067,100 +945,6 @@ fn with_preserved_session_request_params( model_config } -/// Convenience wrapper: reads config from disk, then resolves provider + model. -/// Cheap enough to call from `on_new_session` (file + registry reads, no network). -async fn resolve_provider_and_model( - config_dir: &std::path::Path, - goose_session: &Session, -) -> Result<(String, crate::model::ModelConfig), String> { - let config = - Config::new(config_dir.join(CONFIG_YAML_NAME), "goose").map_err(|e| e.to_string())?; - resolve_provider_and_model_from_config(&config, goose_session).await -} - -fn build_mode_state( - current_mode: GooseMode, -) -> Result { - let mut available = Vec::with_capacity(GooseMode::VARIANTS.len()); - for &name in GooseMode::VARIANTS { - let goose_mode: GooseMode = name.parse().map_err(|_| { - agent_client_protocol::Error::internal_error() // impossible but satisfy linters - .data(format!("Failed to parse GooseMode variant: {}", name)) - })?; - let mut mode = SessionMode::new(SessionModeId::new(name), name); - mode.description = goose_mode.get_message().map(Into::into); - available.push(mode); - } - Ok(SessionModeState::new( - SessionModeId::new(current_mode.to_string()), - available, - )) -} - -fn should_refresh_inventory_for_session_init(entry: &ProviderInventoryEntry) -> bool { - entry.configured - && entry.supports_refresh - && (entry.last_updated_at.is_none() || ProviderInventoryService::is_stale(entry)) -} - -async fn build_eager_config_from_inventory( - provider_name: &str, - current_model: &str, - inventory: &ProviderInventoryEntry, - mode_state: &SessionModeState, - goose_session: &Session, -) -> (SessionModelState, Vec) { - let ms = build_model_state(current_model, inventory); - let provider_selection = session_provider_selection(goose_session); - let provider_options = build_provider_options(Some(provider_name)).await; - let config_options = - build_config_options(mode_state, &ms, provider_selection, provider_options); - (ms, config_options) -} - -fn build_config_options( - mode_state: &SessionModeState, - model_state: &SessionModelState, - provider_selection: &str, - provider_options: Vec, -) -> Vec { - let mode_options: Vec = mode_state - .available_modes - .iter() - .map(|m| { - SessionConfigSelectOption::new(m.id.0.clone(), m.name.clone()) - .description(m.description.clone()) - }) - .collect(); - let model_options: Vec = model_state - .available_models - .iter() - .map(|m| SessionConfigSelectOption::new(m.model_id.0.clone(), m.name.clone())) - .collect(); - vec![ - SessionConfigOption::select( - "provider", - "Provider", - provider_selection.to_string(), - provider_options, - ), - SessionConfigOption::select( - "mode", - "Mode", - mode_state.current_mode_id.0.clone(), - mode_options, - ) - .category(SessionConfigOptionCategory::Mode), - SessionConfigOption::select( - "model", - "Model", - model_state.current_model_id.0.clone(), - model_options, - ) - .category(SessionConfigOptionCategory::Model), - ] -} - fn to_nonnegative_u64(value: Option) -> Option { value.and_then(|v| u64::try_from(v).ok()) } @@ -1172,12 +956,34 @@ fn build_prompt_usage(session: &Session) -> Option { Some(Usage::new(total, input, output)) } -fn build_usage_update(session: &Session, context_limit: usize) -> UsageUpdate { - let used = session.total_tokens.unwrap_or(0).max(0) as u64; - UsageUpdate::new(used, context_limit as u64) +pub(super) struct UsageUpdates { + pub(super) custom: GooseSessionNotification, + pub(super) standard: UsageUpdate, } -fn validate_absolute_cwd(cwd: &Path) -> Result<(), agent_client_protocol::Error> { +pub(super) fn build_usage_updates(session: &Session) -> Option { + let used = session.total_tokens.unwrap_or(0).max(0) as u64; + let ctx_limit = session.model_config.as_ref()?.context_limit() as u64; + let accumulated_input_tokens = + to_nonnegative_u64(session.accumulated_input_tokens).unwrap_or(0); + let accumulated_output_tokens = + to_nonnegative_u64(session.accumulated_output_tokens).unwrap_or(0); + Some(UsageUpdates { + custom: GooseSessionNotification { + session_id: session.id.clone(), + update: GooseSessionUpdate::UsageUpdate(SessionUsageUpdate { + used, + context_limit: ctx_limit, + accumulated_input_tokens, + accumulated_output_tokens, + accumulated_cost: session.accumulated_cost, + }), + }, + standard: UsageUpdate::new(used, ctx_limit), + }) +} + +pub(super) fn validate_absolute_cwd(cwd: &Path) -> Result<(), agent_client_protocol::Error> { if !cwd.is_absolute() { return Err( agent_client_protocol::Error::invalid_params().data("cwd must be an absolute path") @@ -1192,34 +998,6 @@ fn validate_absolute_cwd(cwd: &Path) -> Result<(), agent_client_protocol::Error> } impl GooseAcpAgent { - fn available_commands_update(working_dir: &std::path::Path) -> AvailableCommandsUpdate { - let commands = crate::slash_commands::slash_command::list_acp_commands(Some(working_dir)) - .into_iter() - .map(|entry| { - let mut command = AvailableCommand::new(entry.name, entry.description); - if let Some(input_hint) = entry.input_hint { - command = command.input(AvailableCommandInput::Unstructured( - UnstructuredCommandInput::new(input_hint), - )); - } - command - }) - .collect(); - - AvailableCommandsUpdate::new(commands) - } - - fn send_available_commands_update( - cx: &ConnectionTo, - session_id: &SessionId, - working_dir: &std::path::Path, - ) -> Result<(), agent_client_protocol::Error> { - cx.send_notification(SessionNotification::new( - session_id.clone(), - SessionUpdate::AvailableCommandsUpdate(Self::available_commands_update(working_dir)), - )) - } - pub fn permission_manager(&self) -> Arc { Arc::clone(&self.permission_manager) } @@ -1236,32 +1014,37 @@ impl GooseAcpAgent { let permission_manager = Arc::new(PermissionManager::new(options.config_dir.clone())); let provider_inventory = ProviderInventoryService::new(session_manager.storage().clone()); + let agent_config = AgentConfig::new( + Arc::clone(&session_manager), + Arc::clone(&permission_manager), + None, + Config::global().get_goose_mode().unwrap_or_default(), + options.disable_session_naming, + options.goose_platform.clone(), + ); + let agent_manager = Arc::new(AgentManager::new(agent_config, None).await?); Ok(Self { sessions: Arc::new(Mutex::new(HashMap::new())), + agent_manager, provider_factory: options.provider_factory, builtins: options.builtins, client_fs_capabilities: OnceCell::new(), client_terminal: OnceCell::new(), client_mcp_host_info: OnceCell::new(), use_login_shell_path: OnceCell::new(), + client_cx: OnceCell::new(), config_dir: options.config_dir, session_manager, permission_manager, - goose_mode: options.goose_mode, disable_session_naming: options.disable_session_naming, provider_inventory, - goose_platform: options.goose_platform, additional_source_roots: options.additional_source_roots, }) } - fn load_config(&self) -> Result { - Config::new(self.config_dir.join(CONFIG_YAML_NAME), "goose").map_err(Into::into) - } - - fn config(&self) -> Result { - self.load_config().internal_err_ctx("Failed to read config") + fn config(&self) -> Result<&'static Config, agent_client_protocol::Error> { + Ok(Config::global()) } async fn create_provider( @@ -1280,427 +1063,258 @@ impl GooseAcpAgent { .await } - async fn prepare_session_init_config( + async fn maybe_refresh_provider_inventory_with_agent( &self, - resolved: &Result<(String, crate::model::ModelConfig), String>, - mode_state: &SessionModeState, goose_session: &Session, - ) -> ( - Option, - Option>, - Option>, + agent: &Arc, ) { - let Ok((provider_name, model_config)) = resolved else { - return (None, None, None); + let Some(provider_name) = goose_session.provider_name.as_deref() else { + return; }; - let Some(mut inventory) = self .provider_inventory - .entry_for_provider(provider_name) + .find_entry_for_provider(provider_name) .await - .ok() - .flatten() else { - return (None, None, None); + return; }; - - let mut prebuilt_provider = None; - if should_refresh_inventory_for_session_init(&inventory) { - match self.load_config() { - Ok(config) => { - let ext_state = EnabledExtensionsState::extensions_or_default( - Some(&goose_session.extension_data), - &config, - ); - Config::global().invalidate_secrets_cache(); - match self - .create_provider( - provider_name, - model_config.clone(), - ext_state, - Some(goose_session.working_dir.clone()), - ) - .await - { - Ok(provider) => { - let provider_id = provider_name.clone(); - prebuilt_provider = Some(provider.clone()); - match self - .provider_inventory - .plan_refresh_jobs(std::slice::from_ref(&provider_id)) - .await - { - Ok(plan) - if plan - .started - .iter() - .any(|job| job.provider_id == provider_id) => - { - let refresh_job = plan - .started - .into_iter() - .find(|job| job.provider_id == provider_id); - if let Some(refresh_job) = refresh_job { - let mut refresh_guard = self - .provider_inventory - .refresh_guard(&refresh_job.identity); - let fetch_result: Result> = - match ensure_refresh_identity_current( - &provider_id, - &refresh_job.identity, - ) - .await - { - Ok(()) => match AssertUnwindSafe( - provider.fetch_recommended_models(), - ) - .catch_unwind() - .await - { - Ok(Ok(models)) => Ok(models), - Ok(Err(error)) => { - Err(anyhow::anyhow!(error.to_string())) - } - Err(_) => Err(anyhow::anyhow!( - "provider inventory refresh task panicked" - )), - }, - Err(error) => Err(error), - }; - match fetch_result { - Ok(models) => { - if let Err(error) = self - .provider_inventory - .store_refreshed_models_for_identity( - &refresh_job.identity, - &models, - ) - .await - { - warn!( - provider = %provider_id, - error = %error, - "failed to store refreshed provider inventory during session init" - ); - } else { - refresh_guard.complete(); - } - } - Err(error) => { - let error_message = error.to_string(); - if let Err(store_error) = self - .provider_inventory - .store_refresh_error_for_identity( - &refresh_job.identity, - error_message.clone(), - ) - .await - { - warn!( - provider = %provider_id, - error = %store_error, - "failed to store provider inventory refresh error during session init" - ); - } else { - refresh_guard.complete(); - } - warn!( - provider = %provider_id, - error = %error_message, - "provider inventory refresh failed during session init" - ); - } - } - } - } - Ok(_) => {} - Err(error) => warn!( - provider = %provider_id, - error = %error, - "failed to plan provider inventory refresh during session init" - ), - } - - if let Ok(Some(refreshed_inventory)) = self - .provider_inventory - .entry_for_provider(provider_name) - .await - { - inventory = refreshed_inventory; - } - } - Err(error) => warn!( - provider = %provider_name, - error = %error, - "failed to initialize provider during synchronous inventory refresh" - ), - } - } - Err(error) => warn!( + if !should_refresh_inventory_for_session_init(&inventory) { + return; + } + let provider = match agent.provider().await { + Ok(provider) => provider, + Err(error) => { + warn!( provider = %provider_name, + session = %goose_session.id, error = %error, - "failed to load config during synchronous inventory refresh" - ), + "agent has no provider available for inventory refresh" + ); + return; + } + }; + self.provider_inventory + .refresh_with_provider(provider_name, &provider, &mut inventory, "session init") + .await; + } + + async fn get_or_create_session_agent_with_results( + &self, + cx: &ConnectionTo, + session_id: String, + ) -> Result { + self.agent_manager + .get_or_create_agent_with_runtime_context( + session_id, + RuntimeContext { + mcp_host_info: self.client_mcp_host_info.get().cloned(), + use_login_shell_path: self.use_login_shell_path.get().copied(), + session_name_update_tx: (!self.disable_session_naming) + .then(|| spawn_session_name_update_notifier(cx.clone())), + }, + ) + .await + .internal_err_ctx("Failed to create agent") + } + + fn initial_session_extensions( + &self, + config: &Config, + mcp_servers: Vec, + ) -> Result, agent_client_protocol::Error> { + let mut extensions = Vec::new(); + for builtin in &self.builtins { + push_or_replace_extension(&mut extensions, builtin_to_extension_config(builtin)); + } + + if mcp_servers.is_empty() { + for extension in get_enabled_extensions_with_config(config) { + push_or_replace_extension(&mut extensions, extension); + } + } else { + for mcp_server in mcp_servers { + let extension = mcp_server_to_extension_config(mcp_server).map_err(|message| { + agent_client_protocol::Error::invalid_params().data(message) + })?; + push_or_replace_extension(&mut extensions, extension); } } - let (model_state, config_options) = build_eager_config_from_inventory( - provider_name, - model_config.model_name.as_str(), - &inventory, - mode_state, - goose_session, - ) - .await; - (Some(model_state), Some(config_options), prebuilt_provider) + Ok(extensions) } - fn spawn_agent_setup( + async fn apply_acp_extension_overrides( &self, cx: &ConnectionTo, - agent_tx: tokio::sync::watch::Sender, - req: AgentSetupRequest, + agent: &Arc, + session: &Session, ) { - let AgentSetupRequest { - session_id, - goose_session, - mcp_servers, - resolved_provider, - prebuilt_provider, - } = req; - - let goose_mode = goose_session.goose_mode; - let setup_session_id = goose_session.id.clone(); - let agent_session_id = SessionId::new(setup_session_id.clone()); - let sid = sid_short(session_id.0.as_ref()); - - let cx = cx.clone(); - let sessions = Arc::clone(&self.sessions); - let session_manager = Arc::clone(&self.session_manager); - let permission_manager = Arc::clone(&self.permission_manager); - let config_dir = self.config_dir.clone(); - let builtins = self.builtins.clone(); let client_fs_capabilities = self .client_fs_capabilities .get() .cloned() .unwrap_or_default(); let client_terminal = self.client_terminal.get().copied().unwrap_or(false); - let client_mcp_host_info = self.client_mcp_host_info.get().cloned(); - let use_login_shell_path = self.use_login_shell_path.get().copied().unwrap_or(false); - let provider_factory = Arc::clone(&self.provider_factory); - let disable_session_naming = self.disable_session_naming; - let goose_platform = self.goose_platform.clone(); + if !client_fs_capabilities.read_text_file + && !client_fs_capabilities.write_text_file + && !client_terminal + { + return; + } - tokio::spawn(async move { - let t_setup = std::time::Instant::now(); - debug!(target: "perf", sid = %sid, "perf: agent_setup start (background)"); - // Shared config — read once, used by both phases. - let config = match Config::new(config_dir.join(CONFIG_YAML_NAME), "goose") { - Ok(c) => c, - Err(e) => { - let msg = e.to_string(); - error!(error = %msg, "Background agent setup failed (config)"); - let _ = agent_tx.send(Some(Err(msg))); - return; - } - }; + if !agent + .extension_manager + .is_extension_enabled("developer") + .await + { + return; + } - let session_name_update_tx = - (!disable_session_naming).then(|| spawn_session_name_update_notifier(cx.clone())); - - // ── Phase 1: create agent + init provider (fast, ~55ms) ────── - let phase1: Result, String> = async { - let agent = Arc::new(Agent::with_config( - AgentConfig::new( - session_manager, - permission_manager, - None, - goose_mode, - disable_session_naming, - goose_platform, - ) - .with_mcp_host_info(client_mcp_host_info) - .with_session_name_update_tx(session_name_update_tx) - .with_use_login_shell_path(use_login_shell_path), - )); - - // Init provider — reuse the pre-resolved name + model when - // available (already computed in on_new_session), otherwise - // fall back to reading config (e.g. load_session path). - let (provider_name, model_config) = match resolved_provider { - Some(resolved) => resolved, - None => resolve_provider_and_model_from_config(&config, &goose_session).await?, - }; - let ext_state = EnabledExtensionsState::extensions_or_default( - Some(&goose_session.extension_data), - &config, - ); - let provider = match prebuilt_provider { - Some(provider) => provider, - None => provider_factory( - provider_name.to_string(), - model_config, - ext_state, - Some(goose_session.working_dir.clone()), - ) - .await - .map_err(|e| e.to_string())?, - }; - agent - .update_provider(provider.clone(), &goose_session.id) - .await - .map_err(|e| e.to_string())?; - - agent - .update_goose_mode(goose_mode, &setup_session_id) - .await - .map_err(|e| e.to_string())?; - - Ok(agent) + let context = agent.extension_manager.get_context().clone(); + let dev_client = match DeveloperClient::new(context) { + Ok(dev_client) => dev_client, + Err(error) => { + warn!(error = %error, "Failed to create ACP developer client"); + return; } - .await; + }; - let agent = match phase1 { - Ok(agent) => { - // Signal ProviderReady — unblocks setProvider / update_provider - // while extensions continue loading below. - let _ = - agent_tx.send(Some(Ok(AgentSetupProgress::ProviderReady(agent.clone())))); - debug!(target: "perf", sid = %sid, ms = t_setup.elapsed().as_millis() as u64, "perf: agent_setup provider_ready (signalled)"); - agent - } - Err(e) => { - error!(error = %e, "Background agent setup failed (provider init)"); - debug!(target: "perf", sid = %sid, ms = t_setup.elapsed().as_millis() as u64, "perf: agent_setup failed (provider)"); - let _ = agent_tx.send(Some(Err(e))); - return; - } - }; - - // ── Phase 2: load extensions (slow, may take seconds) ──────── - let phase2: Result<(), String> = async { - let mut extensions = get_enabled_extensions_with_config(&config); - extensions.extend(builtins.iter().map(|b| builtin_to_extension_config(b))); - - let acp_developer = if (client_fs_capabilities.read_text_file - || client_fs_capabilities.write_text_file - || client_terminal) - && extensions.iter().any(|e| e.name() == "developer") - { - let context = agent.extension_manager.get_context().clone(); - match DeveloperClient::new(context) { - Ok(dev_client) => { - let client: Arc = Arc::new(AcpTools { - inner: Arc::new(dev_client), - cx: cx.clone(), - session_id: session_id.clone(), - fs_read: client_fs_capabilities.read_text_file, - fs_write: client_fs_capabilities.write_text_file, - terminal: client_terminal, - }); - let dev_ext = extensions.iter().find(|e| e.name() == "developer"); - let available_tools = dev_ext - .and_then(|e| match e { - ExtensionConfig::Platform { - available_tools, .. - } => Some(available_tools.clone()), - _ => None, - }) - .unwrap_or_default(); - let def = &PLATFORM_EXTENSIONS["developer"]; - let config = ExtensionConfig::Platform { - name: def.name.into(), - description: def.description.into(), - display_name: Some(def.display_name.into()), - bundled: Some(true), - available_tools, - }; - Some((client, config)) - } - Err(e) => { - warn!(error = %e, "Failed to create developer client"); - None - } - } - } else { - None - }; - - let skip_developer = acp_developer.is_some(); - let sid_str = Some(agent_session_id.0.to_string()); - - if skip_developer { - extensions.retain(|ext| ext.name() != "developer"); - } - - let ext_manager = &agent.extension_manager; - let working_dir = goose_session.working_dir.clone(); - let extension_futures = extensions - .into_iter() - .map(|ext| { - let ext_manager = Arc::clone(ext_manager); - let sid_inner = sid_str.clone(); - let working_dir = working_dir.clone(); - async move { - let name = ext.name().to_string(); - if let Err(e) = ext_manager - .add_extension(ext, Some(working_dir), None, sid_inner.as_deref()) - .await - { - warn!(extension = %name, error = %e, "extension load failed"); - } - } - }) - .collect::>(); - futures::future::join_all(extension_futures).await; - - if let Some((client, config)) = acp_developer { - let info = client.get_info().cloned(); - agent - .extension_manager - .add_client("developer".into(), config, client, info, None) - .await; - } - - GooseAcpAgent::add_mcp_extensions(&agent, mcp_servers, &setup_session_id) - .await - .map_err(|e| e.to_string())?; - - Ok(()) - } - .await; - - if let Err(e) = &phase2 { - // Extension failures are non-fatal — individual failures are - // already logged as warnings. Log the top-level error but - // don't block the session: the provider is ready and the agent - // is usable. - error!(error = %e, "Background agent setup: extension phase had errors"); - } - - // Promote the handle to Ready and apply any working directory that - // was set while we were loading — regardless of phase-2 outcome, - // since the agent (with its provider) is fully usable. - { - let mut locked = sessions.lock().await; - if let Some(session) = locked.get_mut(session_id.0.as_ref()) { - if let Some(dir) = session.pending_working_dir.take() { - agent.extension_manager.update_working_dir(&dir).await; - } - session.agent = AgentHandle::Ready(agent.clone()); - } - } - - let _ = agent_tx.send(Some(Ok(AgentSetupProgress::FullyReady(agent)))); - debug!( - target: "perf", - sid = %sid, - ms = t_setup.elapsed().as_millis() as u64, - "perf: agent_setup done{}", - if phase2.is_err() { " (with extension errors)" } else { "" } - ); + let client: Arc = Arc::new(AcpTools { + inner: Arc::new(dev_client), + cx: cx.clone(), + session_id: SessionId::new(session.id.clone()), + fs_read: client_fs_capabilities.read_text_file, + fs_write: client_fs_capabilities.write_text_file, + terminal: client_terminal, }); + let info = client.get_info().cloned(); + + let developer_config = agent + .extension_manager + .get_extension_configs() + .await + .into_iter() + .find(|extension| extension.name() == "developer") + .unwrap_or_else(|| builtin_to_extension_config("developer")); + + agent + .extension_manager + .add_client("developer".into(), developer_config, client, info, None) + .await; + } + + async fn prepare_acp_session_agent( + &self, + cx: &ConnectionTo, + session: &Session, + ) -> Result<(Arc, Vec), agent_client_protocol::Error> { + let agent_result = self + .get_or_create_session_agent_with_results(cx, session.id.clone()) + .await?; + let agent = agent_result.agent.clone(); + self.apply_acp_extension_overrides(cx, &agent, session) + .await; + self.maybe_refresh_provider_inventory_with_agent(session, &agent) + .await; + + Ok((agent, agent_result.extension_results)) + } + + async fn prepare_session_for_activation( + &self, + mut session: Session, + cwd: std::path::PathBuf, + mcp_servers: Vec, + include_messages_on_reload: bool, + ) -> Result { + let config = Config::global(); + let mut builder = self.session_manager.update(&session.id); + let mut session_needs_update = false; + + if cwd != session.working_dir { + builder = builder.working_dir(cwd); + session_needs_update = true; + } + + if session.provider_name.is_none() || session.model_config.is_none() { + let (resolved_provider, resolved_model_config) = + resolve_default_provider_model_config(config)?; + builder = builder + .provider_name(resolved_provider) + .model_config(resolved_model_config); + session_needs_update = true; + } + + if !mcp_servers.is_empty() + || EnabledExtensionsState::from_extension_data(&session.extension_data).is_none() + { + let extension_data = + self.build_enabled_extensions_data(config, &session, mcp_servers)?; + builder = builder.extension_data(extension_data); + session_needs_update = true; + } + + if session_needs_update { + let session_id = session.id.clone(); + builder + .apply() + .await + .internal_err_ctx("Failed to update session")?; + + let _ = self.agent_manager.remove_session(&session_id).await; + + session = self + .session_manager + .get_session(&session_id, include_messages_on_reload) + .await + .internal_err_ctx("Failed to reload session")?; + } + + Ok(session) + } + + fn build_enabled_extensions_data( + &self, + config: &Config, + session: &Session, + mcp_servers: Vec, + ) -> Result { + let extensions = self.initial_session_extensions(config, mcp_servers)?; + let mut extension_data = session.extension_data.clone(); + EnabledExtensionsState::new(extensions) + .to_extension_data(&mut extension_data) + .internal_err_ctx("Failed to initialize session extensions")?; + Ok(extension_data) + } + + async fn register_acp_session( + &self, + session_id: String, + agent: Arc, + tool_requests: HashMap, + ) { + let acp_session = GooseAcpSession { + agent, + tool_requests, + chain_membership: HashMap::new(), + responded_tool_ids: HashSet::new(), + summarized_chains: HashSet::new(), + cancel_token: None, + }; + self.sessions.lock().await.insert(session_id, acp_session); + } + + async fn activate_acp_session( + &self, + cx: &ConnectionTo, + session: &Session, + tool_requests: HashMap, + ) -> Result<(Arc, Vec), agent_client_protocol::Error> { + let (agent, extension_results) = self.prepare_acp_session_agent(cx, session).await?; + self.register_acp_session(session.id.clone(), agent.clone(), tool_requests) + .await; + + Ok((agent, extension_results)) } pub async fn has_session(&self, session_id: &str) -> bool { @@ -1782,6 +1396,7 @@ impl GooseAcpAgent { session_id: &SessionId, session_id_str: &str, message_id: Option<&str>, + message_created: i64, agent: &Arc, session: &mut GooseAcpSession, cx: &ConnectionTo, @@ -1790,9 +1405,10 @@ impl GooseAcpAgent { MessageContent::Text(text) => { cx.send_notification(SessionNotification::new( session_id.clone(), - SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text( - TextContent::new(text.text.clone()), - ))), + SessionUpdate::AgentMessageChunk( + ContentChunk::new(ContentBlock::Text(TextContent::new(text.text.clone()))) + .meta(message_update_meta(message_id, message_created)), + ), ))?; } MessageContent::ToolRequest(tool_request) => { @@ -1820,19 +1436,21 @@ impl GooseAcpAgent { MessageContent::Thinking(thinking) => { cx.send_notification(SessionNotification::new( session_id.clone(), - SessionUpdate::AgentThoughtChunk(ContentChunk::new(ContentBlock::Text( - TextContent::new(thinking.thinking.clone()), - ))), + SessionUpdate::AgentThoughtChunk( + ContentChunk::new(ContentBlock::Text(TextContent::new( + thinking.thinking.clone(), + ))) + .meta(message_update_meta(message_id, message_created)), + ), ))?; } - MessageContent::ActionRequired(action_required) => { - if let ActionRequiredData::ToolConfirmation { + MessageContent::ActionRequired(action_required) => match &action_required.data { + ActionRequiredData::ToolConfirmation { id, tool_name, arguments, prompt, - } = &action_required.data - { + } => { self.handle_tool_permission_request( cx, agent, @@ -1843,6 +1461,25 @@ impl GooseAcpAgent { prompt.clone(), )?; } + ActionRequiredData::Elicitation { + id, + message, + requested_schema, + } => { + send_elicitation_interaction_update( + cx, + session_id.0.as_ref(), + id.clone(), + InteractionState::Pending, + Some(message.clone()), + Some(requested_schema.clone()), + Some(interaction_update_meta(message_id, message_created)), + )?; + } + ActionRequiredData::ElicitationResponse { .. } => {} + }, + MessageContent::SystemNotification(notification) => { + send_status_message_update(cx, session_id.0.as_ref(), notification)?; } _ => {} } @@ -1879,10 +1516,7 @@ impl GooseAcpAgent { } if let Ok(tool_call) = &tool_request.tool_call { - let agent = match &session.agent { - AgentHandle::Ready(a) => a.clone(), - AgentHandle::Loading(_) => return Ok(()), - }; + let agent = session.agent.clone(); let sid = session_id.clone(); let request_id = tool_request.id.clone(); let cx = cx.clone(); @@ -2127,15 +1761,7 @@ impl GooseAcpAgent { return; } - let agent = match &session.agent { - AgentHandle::Ready(a) => a.clone(), - AgentHandle::Loading(_) => { - warn!( - "tool chain summary: agent still loading; skipping chain anchored at {first_id}", - ); - return; - } - }; + let agent = session.agent.clone(); // Snapshot (name, args_json) for each step in document order. let steps: Vec<(String, String)> = chain @@ -2377,6 +2003,111 @@ fn outcome_to_confirmation(outcome: &RequestPermissionOutcome) -> PermissionConf } } +fn prompt_error_from_message_content( + content_item: &MessageContent, +) -> Option { + match content_item { + MessageContent::SystemNotification(notification) + if notification.notification_type == SystemNotificationType::CreditsExhausted => + { + Some(credits_exhausted_prompt_error(notification)) + } + _ => None, + } +} + +fn credits_exhausted_prompt_error( + notification: &SystemNotificationContent, +) -> agent_client_protocol::Error { + let mut data = serde_json::Map::new(); + data.insert( + "reason".to_string(), + serde_json::Value::String("credits_exhausted".to_string()), + ); + + if let Some(url) = notification + .data + .as_ref() + .and_then(|data| data.get("top_up_url")) + .and_then(|url| url.as_str()) + { + data.insert( + "url".to_string(), + serde_json::Value::String(url.to_string()), + ); + } + + agent_client_protocol::Error::new(-32603, notification.msg.clone()) + .data(serde_json::Value::Object(data)) +} + +fn send_status_message_update( + cx: &ConnectionTo, + session_id: &str, + notification: &SystemNotificationContent, +) -> Result<(), agent_client_protocol::Error> { + if let Some(status) = status_message_from_system_notification(notification) { + cx.send_notification(GooseSessionNotification { + session_id: session_id.to_string(), + update: GooseSessionUpdate::StatusMessage(StatusMessageUpdate { status }), + })?; + } + Ok(()) +} + +fn status_message_from_system_notification( + notification: &SystemNotificationContent, +) -> Option { + match notification.notification_type { + SystemNotificationType::InlineMessage => Some(StatusMessage::Notice { + message: notification.msg.clone(), + }), + SystemNotificationType::ThinkingMessage => Some(StatusMessage::Progress { + message: notification.msg.clone(), + }), + SystemNotificationType::CreditsExhausted => None, + } +} + +fn send_elicitation_interaction_update( + cx: &ConnectionTo, + session_id: &str, + id: String, + state: InteractionState, + message: Option, + requested_schema: Option, + meta: Option, +) -> Result<(), agent_client_protocol::Error> { + cx.send_notification(GooseSessionNotification { + session_id: session_id.to_string(), + update: GooseSessionUpdate::InteractionUpdate(InteractionUpdate { + interaction: Interaction::Elicitation { + id, + state, + message, + requested_schema, + }, + meta, + }), + }) +} + +fn interaction_update_meta(message_id: Option<&str>, created: i64) -> serde_json::Value { + serde_json::Value::Object(message_update_meta(message_id, created)) +} + +fn message_update_meta(message_id: Option<&str>, created: i64) -> Meta { + let mut goose = serde_json::Map::new(); + goose.insert("created".to_string(), serde_json::json!(created)); + if let Some(id) = message_id { + goose.insert("messageId".to_string(), serde_json::json!(id)); + } + + let mut meta = serde_json::Map::new(); + meta.insert("goose".to_string(), serde_json::Value::Object(goose)); + meta +} + fn extract_tool_call_update_meta( tool_response: &crate::conversation::message::ToolResponse, ) -> Option { @@ -2401,32 +2132,6 @@ fn replay_message_meta(message: &Message) -> Meta { meta } -fn replay_audience_annotations(audience: &[Role]) -> Annotations { - Annotations::new().audience( - audience - .iter() - .map(|role| match role { - Role::Assistant => agent_client_protocol::schema::Role::Assistant, - Role::User => agent_client_protocol::schema::Role::User, - }) - .collect::>(), - ) -} - -fn send_replay_content_chunk( - cx: &ConnectionTo, - session_id: &SessionId, - message: &Message, - content: ContentBlock, -) -> std::result::Result<(), agent_client_protocol::Error> { - let chunk = ContentChunk::new(content).meta(replay_message_meta(message)); - let update = match message.role { - Role::User => SessionUpdate::UserMessageChunk(chunk), - Role::Assistant => SessionUpdate::AgentMessageChunk(chunk), - }; - cx.send_notification(SessionNotification::new(session_id.clone(), update)) -} - fn replay_message_goose_meta(message: &Message) -> serde_json::Map { let mut goose = serde_json::Map::new(); goose.insert("created".to_string(), serde_json::json!(message.created)); @@ -2550,218 +2255,52 @@ impl GooseAcpAgent { cx: &ConnectionTo, args: NewSessionRequest, ) -> Result { - debug!(?args, "new session request"); - let t_start = std::time::Instant::now(); - validate_absolute_cwd(&args.cwd)?; - - let requested_provider = args - .meta - .as_ref() - .and_then(|m| m.get("provider")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let project_id = args - .meta - .as_ref() - .and_then(|m| m.get("projectId")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - // When _meta.client is set, the session is created by a known client - // (e.g. "goose" for the desktop app) and treated as a User session. - // Without it, sessions default to Acp for programmatic ACP clients. - let session_type = match args - .meta - .as_ref() - .and_then(|m| m.get("client")) - .and_then(|v| v.as_str()) - { - Some(_) => SessionType::User, - None => SessionType::Acp, - }; - - let t0 = std::time::Instant::now(); - let goose_session = self - .session_manager - .create_session( - args.cwd.clone(), - "New Chat".to_string(), - session_type, - self.goose_mode, - ) - .await - .internal_err_ctx("Failed to create session")?; - - let mut builder = self.session_manager.update(&goose_session.id); - if let Some(ref provider) = requested_provider { - builder = builder.provider_name(provider); - } - if let Some(pid) = project_id { - builder = builder.project_id(Some(pid)); - } - builder - .apply() - .await - .internal_err_ctx("Failed to update session")?; - - let goose_session = self - .session_manager - .get_session(&goose_session.id, false) - .await - .internal_err_ctx("Failed to reload session")?; - - let session_id_str = goose_session.id.clone(); - let sid = sid_short(&session_id_str); - debug!(target: "perf", sid = %sid, ms = t0.elapsed().as_millis() as u64, "perf: new_session create_session"); - - let (agent_tx, agent_rx) = tokio::sync::watch::channel::(None); - - let acp_session = GooseAcpSession { - agent: AgentHandle::Loading(agent_rx), - tool_requests: HashMap::new(), - chain_membership: HashMap::new(), - responded_tool_ids: HashSet::new(), - summarized_chains: HashSet::new(), - cancel_token: None, - pending_working_dir: None, - }; - self.sessions - .lock() - .await - .insert(session_id_str.clone(), acp_session); - - let mode_state = build_mode_state(self.goose_mode)?; - - let resolved = resolve_provider_and_model(&self.config_dir, &goose_session).await; - let initial_usage_update = resolved - .as_ref() - .ok() - .map(|(_, mc)| build_usage_update(&goose_session, mc.context_limit())); - let acp_session_id = SessionId::new(session_id_str); - let (model_state, config_options, prebuilt_provider) = self - .prepare_session_init_config(&resolved, &mode_state, &goose_session) - .await; - - let working_dir = goose_session.working_dir.clone(); - - self.spawn_agent_setup( - cx, - agent_tx, - AgentSetupRequest { - session_id: acp_session_id.clone(), - goose_session, - mcp_servers: args.mcp_servers, - resolved_provider: resolved.as_ref().ok().cloned(), - prebuilt_provider, - }, - ); - - let mut response = NewSessionResponse::new(acp_session_id.clone()).modes(mode_state); - if let Some(ms) = model_state { - response = response.models(ms); - } - if let Some(co) = config_options { - response = response.config_options(co); - } - if let Some(usage_update) = initial_usage_update { - cx.send_notification(SessionNotification::new( - acp_session_id.clone(), - SessionUpdate::UsageUpdate(usage_update), - ))?; - } - Self::send_available_commands_update(cx, &acp_session_id, &working_dir)?; - debug!( - target: "perf", - sid = %sid, - ms = t_start.elapsed().as_millis() as u64, - "perf: new_session done (agent setup continues in background)" - ); - Ok(response) + self.handle_new_session(cx, args).await } - /// Look up the session and return the agent if already ready, or the watch - /// receiver if still loading. Optionally sets a cancellation token on the - /// session (needed by `on_prompt`). - async fn get_agent_or_receiver( - &self, - session_id: &str, - cancel_token: Option, - ) -> Result< - Either, tokio::sync::watch::Receiver>, - agent_client_protocol::Error, - > { - let mut sessions = self.sessions.lock().await; - let session = sessions.get_mut(session_id).ok_or_else(|| { - agent_client_protocol::Error::resource_not_found(Some(session_id.to_string())) - .data(format!("Session not found: {}", session_id)) - })?; - if let Some(token) = cancel_token { - session.cancel_token = Some(token); - } - match &session.agent { - AgentHandle::Ready(agent) => Ok(Either::Left(agent.clone())), - AgentHandle::Loading(rx) => Ok(Either::Right(rx.clone())), - } - } - - /// Wait until the agent is **fully ready** (provider + all extensions). - /// Most callers (e.g. `on_prompt`, `on_get_tools`) should use this. + /// Look up the session's agent. Optionally sets a cancellation token on + /// the session (needed by `on_prompt`). async fn get_session_agent( &self, session_id: &str, cancel_token: Option, ) -> Result, agent_client_protocol::Error> { - let mut rx = match self.get_agent_or_receiver(session_id, cancel_token).await? { - Either::Left(agent) => return Ok(agent), - Either::Right(rx) => rx, - }; - // Wait specifically for FullyReady (not just ProviderReady). - let guard = rx - .wait_for(|v| { - matches!( - v, - Some(Ok(AgentSetupProgress::FullyReady(_))) | Some(Err(_)) - ) - }) + { + let mut sessions = self.sessions.lock().await; + if let Some(session) = sessions.get_mut(session_id) { + if let Some(token) = cancel_token { + session.cancel_token = Some(token); + } + return Ok(session.agent.clone()); + } + } + + let cx = self.client_cx.get().ok_or_else(|| { + agent_client_protocol::Error::resource_not_found(Some(session_id.to_string())) + .data(format!("Session not found: {}", session_id)) + })?; + let session = self + .session_manager + .get_session(session_id, false) .await .map_err(|_| { - agent_client_protocol::Error::internal_error() - .data("Agent setup task was dropped".to_string()) + agent_client_protocol::Error::resource_not_found(Some(session_id.to_string())) + .data(format!("Session not found: {}", session_id)) })?; - match guard.as_ref().unwrap() { - Ok(AgentSetupProgress::FullyReady(agent)) => Ok(agent.clone()), - Err(e) => Err(agent_client_protocol::Error::internal_error().data(e.clone())), - // wait_for predicate excludes ProviderReady - _ => unreachable!(), - } - } - - /// Wait only until the **provider** is initialized. Extensions may still - /// be loading in the background. Use this for operations that only touch - /// the provider (e.g. `update_provider`, `set_model`, `build_config_update`). - async fn get_session_agent_provider_ready( - &self, - session_id: &str, - ) -> Result, agent_client_protocol::Error> { - let mut rx = match self.get_agent_or_receiver(session_id, None).await? { - Either::Left(agent) => return Ok(agent), - Either::Right(rx) => rx, - }; - // Any signal (ProviderReady, FullyReady, or Err) unblocks us. - let guard = rx.wait_for(|v| v.is_some()).await.map_err(|_| { - agent_client_protocol::Error::internal_error() - .data("Agent setup task was dropped".to_string()) - })?; - match guard.as_ref().unwrap() { - Ok(progress) => match progress { - AgentSetupProgress::ProviderReady(agent) - | AgentSetupProgress::FullyReady(agent) => Ok(agent.clone()), - }, - Err(e) => Err(agent_client_protocol::Error::internal_error().data(e.clone())), + let (agent, _) = self + .activate_acp_session(cx, &session, HashMap::new()) + .await?; + + if let Some(token) = cancel_token { + let mut sessions = self.sessions.lock().await; + if let Some(session) = sessions.get_mut(session_id) { + session.cancel_token = Some(token); + } } + Ok(agent) } + #[allow(dead_code)] async fn add_mcp_extensions( agent: &Arc, mcp_servers: Vec, @@ -2803,250 +2342,7 @@ impl GooseAcpAgent { cx: &ConnectionTo, args: LoadSessionRequest, ) -> Result { - debug!(?args, "load session request"); - validate_absolute_cwd(&args.cwd)?; - - let session_id = args.session_id.0.to_string(); - let sid = sid_short(&session_id); - let t_start = std::time::Instant::now(); - - let t0 = std::time::Instant::now(); - let goose_session = self - .session_manager - .get_session(&session_id, true) - .await - .map_err(|_| { - agent_client_protocol::Error::resource_not_found(Some(session_id.clone())) - .data(format!("Session not found: {}", session_id)) - })?; - debug!(target: "perf", sid = %sid, ms = t0.elapsed().as_millis() as u64, "perf: load_session get_session"); - let loaded_mode = goose_session.goose_mode; - - // ── REPLAY MESSAGES ── - // Stream user-visible messages back to the client so the chat view - // populates immediately, before the slow agent/provider/extension setup. - let messages = goose_session - .conversation - .as_ref() - .map(|c| c.messages().to_vec()) - .unwrap_or_default(); - debug!( - target: "perf", - sid = %sid, - messages = messages.len(), - "perf: load_session messages loaded" - ); - - let mut replay_tool_requests = - HashMap::::new(); - - for message in &messages { - if !message.metadata.user_visible { - continue; - } - - for content_item in &message.content { - match content_item { - MessageContent::Text(text) => { - let mut tc = TextContent::new(text.text.clone()); - if let Some(audience) = text.audience() { - tc = tc.annotations(replay_audience_annotations(audience)); - } - send_replay_content_chunk( - cx, - &args.session_id, - message, - ContentBlock::Text(tc), - )?; - } - MessageContent::Image(image) => { - let mut image_content = - ImageContent::new(image.data.clone(), image.mime_type.clone()); - if let Some(audience) = image.audience() { - image_content = - image_content.annotations(replay_audience_annotations(audience)); - } - send_replay_content_chunk( - cx, - &args.session_id, - message, - ContentBlock::Image(image_content), - )?; - } - MessageContent::ToolRequest(tool_request) => { - // Replay-only: emit the ToolCall notification and - // stash the request for location extraction, but - // don't require a full GooseAcpSession. - replay_tool_requests.insert(tool_request.id.clone(), tool_request.clone()); - - let pending_tool_call = pending_tool_call_from_request(tool_request); - let mut meta = pending_tool_call.identity_meta; - // If this tool request is the first of a chain whose - // summary was persisted at completion time, attach the - // chain summary to the initial ToolCall so the chain - // header is correct on first paint after reload. - if let Some(chain_summary) = tool_request.persisted_chain_summary() { - meta = with_tool_chain_summary_meta( - meta, - &chain_summary.summary, - chain_summary.count, - ); - } - let tool_call = pending_tool_call - .tool_call - .meta(merge_replay_message_meta(meta, message)); - - cx.send_notification(SessionNotification::new( - args.session_id.clone(), - SessionUpdate::ToolCall(tool_call), - ))?; - } - MessageContent::ToolResponse(tool_response) => { - // Replay-only: emit the ToolCallUpdate notification, - // using the stashed replay_tool_requests for location - // extraction. - let status = match &tool_response.tool_result { - Ok(result) if result.is_error == Some(true) => ToolCallStatus::Failed, - Ok(_) => ToolCallStatus::Completed, - Err(_) => ToolCallStatus::Failed, - }; - - let mut fields = ToolCallUpdateFields::new().status(status); - if let Some(raw_output) = - extract_tool_raw_output(&tool_response.tool_result) - { - fields = fields.raw_output(raw_output); - } - if !tool_response - .tool_result - .as_ref() - .is_ok_and(|r| r.is_acp_aware()) - { - let content = build_tool_call_content(&tool_response.tool_result); - fields = fields.content(content); - - let locations = extract_locations_from_meta(tool_response) - .unwrap_or_else(|| { - if let Some(tool_request) = - replay_tool_requests.get(&tool_response.id) - { - extract_tool_locations(tool_request, tool_response) - } else { - Vec::new() - } - }); - if !locations.is_empty() { - fields = fields.locations(locations); - } - } - - let update = - ToolCallUpdate::new(ToolCallId::new(tool_response.id.clone()), fields) - .meta(merge_replay_message_meta( - extract_tool_call_update_meta(tool_response), - message, - )); - cx.send_notification(SessionNotification::new( - args.session_id.clone(), - SessionUpdate::ToolCallUpdate(update), - ))?; - } - MessageContent::Thinking(thinking) => { - cx.send_notification(SessionNotification::new( - args.session_id.clone(), - SessionUpdate::AgentThoughtChunk( - ContentChunk::new(ContentBlock::Text(TextContent::new( - thinking.thinking.clone(), - ))) - .meta(replay_message_meta(message)), - ), - ))?; - } - _ => {} - } - } - } - - // Update working directory. - self.session_manager - .update(&session_id) - .working_dir(args.cwd.clone()) - .apply() - .await - .internal_err_ctx("Failed to update session working directory")?; - let goose_session = self - .session_manager - .get_session(&session_id, false) - .await - .internal_err_ctx("Failed to reload session")?; - - // Register the session with a Loading handle. - let (agent_tx, agent_rx) = tokio::sync::watch::channel::(None); - - let acp_session = GooseAcpSession { - agent: AgentHandle::Loading(agent_rx), - tool_requests: replay_tool_requests, - chain_membership: HashMap::new(), - responded_tool_ids: HashSet::new(), - summarized_chains: HashSet::new(), - cancel_token: None, - pending_working_dir: None, - }; - self.sessions - .lock() - .await - .insert(session_id.clone(), acp_session); - - let mode_state = build_mode_state(loaded_mode)?; - - let resolved = resolve_provider_and_model(&self.config_dir, &goose_session).await; - let initial_usage_update = resolved - .as_ref() - .ok() - .map(|(_, mc)| build_usage_update(&goose_session, mc.context_limit())) - .or_else(|| { - goose_session - .model_config - .as_ref() - .map(|mc| build_usage_update(&goose_session, mc.context_limit())) - }); - let (model_state, config_options, prebuilt_provider) = self - .prepare_session_init_config(&resolved, &mode_state, &goose_session) - .await; - - self.spawn_agent_setup( - cx, - agent_tx, - AgentSetupRequest { - session_id: args.session_id.clone(), - goose_session, - mcp_servers: args.mcp_servers, - resolved_provider: None, - prebuilt_provider, - }, - ); - - let mut response = LoadSessionResponse::new().modes(mode_state); - if let Some(ms) = model_state { - response = response.models(ms); - } - if let Some(co) = config_options { - response = response.config_options(co); - } - if let Some(usage_update) = initial_usage_update { - cx.send_notification(SessionNotification::new( - args.session_id.clone(), - SessionUpdate::UsageUpdate(usage_update), - ))?; - } - Self::send_available_commands_update(cx, &args.session_id, &args.cwd)?; - debug!( - target: "perf", - sid = %sid, - ms = t_start.elapsed().as_millis() as u64, - "perf: load_session done (agent setup continues in background)" - ); - Ok(response) + self.handle_load_session(cx, args).await } async fn on_prompt( @@ -3146,6 +2442,11 @@ impl GooseAcpAgent { })?; for content_item in &message.content { + if let Some(error) = prompt_error_from_message_content(content_item) { + session.cancel_token = None; + return Err(error); + } + match content_item { MessageContent::ToolRequest(tr) => { if let Some(msg_id) = stored_message_id.as_deref() { @@ -3178,6 +2479,7 @@ impl GooseAcpAgent { &args.session_id, &session_id, stored_message_id.as_deref(), + message.created, &agent, session, cx, @@ -3210,16 +2512,16 @@ impl GooseAcpAgent { .get_session(&session_id, false) .await .internal_err_ctx("Failed to load session")?; - let provider = agent - .provider() - .await - .internal_err_ctx("Failed to get provider")?; - let usage_update = - build_usage_update(&session, provider.get_model_config().context_limit()); - cx.send_notification(SessionNotification::new( - args.session_id.clone(), - SessionUpdate::UsageUpdate(usage_update), - ))?; + if let Some(updates) = build_usage_updates(&session) { + cx.send_notification(updates.custom)?; + // Standard ACP notification — emitted alongside the custom one for + // backwards compatibility. Remove once all known clients have + // migrated to `_goose/unstable/session/update`. + cx.send_notification(SessionNotification::new( + args.session_id.clone(), + SessionUpdate::UsageUpdate(updates.standard), + ))?; + } debug!( target: "perf", @@ -3263,13 +2565,52 @@ impl GooseAcpAgent { Ok(()) } + async fn on_elicitation_respond( + &self, + cx: &ConnectionTo, + req: ElicitationRespondRequest, + ) -> Result { + ActionRequiredManager::global() + .submit_response(req.elicitation_id.clone(), req.user_data.clone()) + .await + .invalid_params_err_ctx("Failed to submit elicitation response")?; + + let response_message = Message::user() + .with_generated_id() + .with_content(MessageContent::action_required_elicitation_response( + req.elicitation_id.clone(), + req.user_data, + )) + .agent_only(); + + self.session_manager + .add_message(&req.session_id, &response_message) + .await + .internal_err_ctx("Failed to persist elicitation response")?; + + send_elicitation_interaction_update( + cx, + &req.session_id, + req.elicitation_id, + InteractionState::Submitted, + None, + None, + Some(interaction_update_meta( + response_message.id.as_deref(), + response_message.created, + )), + )?; + + Ok(EmptyResponse {}) + } + async fn on_set_model( &self, session_id: &str, model_id: &str, ) -> Result { let config = self.config()?; - let agent = self.get_session_agent_provider_ready(session_id).await?; + let agent = self.get_session_agent(session_id, None).await?; let current_provider = agent .provider() .await @@ -3277,7 +2618,7 @@ impl GooseAcpAgent { let provider_name = current_provider.get_name().to_string(); let current_model_config = current_provider.get_model_config(); let extensions = - EnabledExtensionsState::for_session(&self.session_manager, session_id, &config).await; + EnabledExtensionsState::for_session(&self.session_manager, session_id, config).await; let model_config = crate::model::ModelConfig::new(model_id) .invalid_params_err_ctx("Invalid model config")? .with_canonical_limits(&provider_name); @@ -3319,7 +2660,7 @@ impl GooseAcpAgent { .get_session(&session_id.0, false) .await .internal_err()?; - let agent = self.get_session_agent_provider_ready(&session_id.0).await?; + let agent = self.get_session_agent(&session_id.0, None).await?; let provider = agent .provider() .await @@ -3362,7 +2703,7 @@ impl GooseAcpAgent { .data(format!("Invalid mode: {}", mode_id)) })?; - let agent = self.get_session_agent_provider_ready(session_id).await?; + let agent = self.get_session_agent(session_id, None).await?; agent .update_goose_mode(mode, session_id) .await @@ -3382,7 +2723,7 @@ impl GooseAcpAgent { request_params: Option>, ) -> Result<(), agent_client_protocol::Error> { let config = self.config()?; - let agent = self.get_session_agent_provider_ready(session_id).await?; + let agent = self.get_session_agent(session_id, None).await?; let current_provider = agent .provider() .await @@ -3390,8 +2731,6 @@ impl GooseAcpAgent { let current_provider_name = current_provider.get_name(); let current_model_config = current_provider.get_model_config(); let current_model = current_model_config.model_name.clone(); - let has_default_overrides = - model_name.is_some() || context_limit.is_some() || request_params.is_some(); let use_default_provider = provider_name == DEFAULT_PROVIDER_ID; let resolved_provider_name = if use_default_provider { config @@ -3424,7 +2763,7 @@ impl GooseAcpAgent { ); let extensions = - EnabledExtensionsState::for_session(&self.session_manager, session_id, &config).await; + EnabledExtensionsState::for_session(&self.session_manager, session_id, config).await; let session = self .session_manager .get_session(session_id, false) @@ -3448,32 +2787,8 @@ impl GooseAcpAgent { .update_goose_mode(mode, session_id) .await .internal_err_ctx("Failed to propagate mode")?; - let provider = agent - .provider() - .await - .internal_err_ctx("Failed to get provider")?; // provider_name is already updated on the session by the agent's update_provider call. - - if use_default_provider { - let update = self - .session_manager - .update(session_id) - .provider_name(DEFAULT_PROVIDER_ID); - if has_default_overrides { - update - .model_config(provider.get_model_config()) - .apply() - .await - .internal_err_ctx("Failed to persist default provider selection overrides")?; - } else { - update - .clear_model_config() - .apply() - .await - .internal_err_ctx("Failed to persist default provider selection")?; - } - } Ok(()) } @@ -3508,10 +2823,14 @@ impl GooseAcpAgent { .into_iter() .map(|s| { let meta = session_meta(&s); - SessionInfo::new(SessionId::new(s.id), s.working_dir) - .title(s.name) + let title = display_title(&s); + let mut info = SessionInfo::new(SessionId::new(s.id), s.working_dir) .updated_at(s.updated_at.to_rfc3339()) - .meta(meta) + .meta(meta); + if let Some(t) = title { + info = info.title(t); + } + info }) .collect(); let next_cursor = page @@ -3527,80 +2846,7 @@ impl GooseAcpAgent { cx: &ConnectionTo, args: ForkSessionRequest, ) -> Result { - validate_absolute_cwd(&args.cwd)?; - let source_session_id = &*args.session_id.0; - - let new_session = self - .session_manager - .copy_session(source_session_id, "Fork".to_string()) - .await - .internal_err()?; - let new_session_id = new_session.id.clone(); - - // Update working dir for the fork. - self.session_manager - .update(&new_session_id) - .working_dir(args.cwd.clone()) - .apply() - .await - .internal_err()?; - - let goose_session = self - .session_manager - .get_session(&new_session_id, false) - .await - .internal_err()?; - - let (agent_tx, agent_rx) = tokio::sync::watch::channel::(None); - - let acp_session = GooseAcpSession { - agent: AgentHandle::Loading(agent_rx), - tool_requests: HashMap::new(), - chain_membership: HashMap::new(), - responded_tool_ids: HashSet::new(), - summarized_chains: HashSet::new(), - cancel_token: None, - pending_working_dir: None, - }; - self.sessions - .lock() - .await - .insert(new_session_id.clone(), acp_session); - - let mode_state = build_mode_state(self.goose_mode)?; - let resolved = resolve_provider_and_model(&self.config_dir, &goose_session).await; - let (model_state, config_options, prebuilt_provider) = self - .prepare_session_init_config(&resolved, &mode_state, &goose_session) - .await; - - let acp_session_id = SessionId::new(new_session_id.clone()); - - self.spawn_agent_setup( - cx, - agent_tx, - AgentSetupRequest { - session_id: acp_session_id.clone(), - goose_session, - mcp_servers: args.mcp_servers, - resolved_provider: resolved.ok(), - prebuilt_provider, - }, - ); - - let meta = session_meta(&new_session); - - let mut response = ForkSessionResponse::new(acp_session_id.clone()) - .modes(mode_state) - .meta(meta); - - if let Some(ms) = model_state { - response = response.models(ms); - } - if let Some(co) = config_options { - response = response.config_options(co); - } - Self::send_available_commands_update(cx, &acp_session_id, &args.cwd)?; - Ok(response) + self.handle_fork_session(cx, args).await } async fn on_close_session( @@ -3614,6 +2860,10 @@ impl GooseAcpAgent { } } sessions.remove(session_id); + drop(sessions); + + let _ = self.agent_manager.remove_session(session_id).await; + info!(session_id = %session_id, "ACP session closed"); Ok(CloseSessionResponse::new()) } @@ -3671,8 +2921,7 @@ mod tests { use crate::conversation::message::{ToolRequest, ToolResponse}; use agent_client_protocol::schema::{ EnvVariable, HttpHeader, McpServer, McpServerHttp, McpServerSse, McpServerStdio, - PermissionOptionId, ResourceLink, SelectedPermissionOutcome, SessionConfigSelectOption, - SessionMode, SessionModeId, SessionModeState, + PermissionOptionId, ResourceLink, SelectedPermissionOutcome, }; use rmcp::model::{CallToolRequestParams, Content as RmcpContent}; use std::io::Write; @@ -3834,90 +3083,6 @@ print(\"hello, world\") ); } - fn tool_request_block(id: &str) -> crate::conversation::message::MessageContent { - crate::conversation::message::MessageContent::ToolRequest(ToolRequest { - id: id.to_string(), - tool_call: Ok(CallToolRequestParams::new("dummy")), - metadata: None, - tool_meta: None, - }) - } - - fn text_block(text: &str) -> crate::conversation::message::MessageContent { - crate::conversation::message::MessageContent::text(text) - } - - #[test] - fn extract_tool_chains_returns_empty_for_no_tool_blocks() { - let content = vec![text_block("hello"), text_block("world")]; - assert!(extract_tool_chains(&content).is_empty()); - } - - #[test] - fn extract_tool_chains_returns_single_chain_when_only_tools() { - let content = vec![ - tool_request_block("a"), - tool_request_block("b"), - tool_request_block("c"), - ]; - let chains = extract_tool_chains(&content); - assert_eq!( - chains, - vec![vec!["a".to_string(), "b".to_string(), "c".to_string()]] - ); - } - - #[test] - fn extract_tool_chains_breaks_on_text_block() { - let content = vec![ - tool_request_block("a"), - tool_request_block("b"), - text_block("interlude"), - tool_request_block("c"), - tool_request_block("d"), - ]; - let chains = extract_tool_chains(&content); - assert_eq!( - chains, - vec![ - vec!["a".to_string(), "b".to_string()], - vec!["c".to_string(), "d".to_string()], - ] - ); - } - - #[test] - fn extract_tool_chains_includes_singletons() { - let content = vec![ - tool_request_block("a"), - text_block("split"), - tool_request_block("b"), - text_block("split"), - tool_request_block("c"), - ]; - let chains = extract_tool_chains(&content); - assert_eq!( - chains, - vec![ - vec!["a".to_string()], - vec!["b".to_string()], - vec!["c".to_string()], - ] - ); - } - - #[test] - fn extract_tool_chains_keeps_run_when_text_leads_or_trails() { - let content = vec![ - text_block("intro"), - tool_request_block("a"), - tool_request_block("b"), - text_block("outro"), - ]; - let chains = extract_tool_chains(&content); - assert_eq!(chains, vec![vec!["a".to_string(), "b".to_string()]]); - } - fn buf_entry(tool_id: &str, msg_id: &str) -> (String, String) { (tool_id.to_string(), msg_id.to_string()) } @@ -4148,56 +3313,6 @@ print(\"hello, world\") assert_eq!(outcome_to_confirmation(&input), expected); } - #[test_case( - vec!["model-a".into(), "model-b".into()] - => SessionModelState::new( - ModelId::new("unused"), - vec![ModelInfo::new(ModelId::new("unused"), "unused"), - ModelInfo::new(ModelId::new("model-a"), "model-a"), - ModelInfo::new(ModelId::new("model-b"), "model-b")], - ) - ; "returns current and available models" - )] - #[test_case( - vec![] - => SessionModelState::new( - ModelId::new("unused"), - vec![ModelInfo::new(ModelId::new("unused"), "unused")], - ) - ; "empty model list" - )] - fn test_build_model_state(models: Vec) -> SessionModelState { - let inventory = ProviderInventoryEntry { - provider_id: "mock".to_string(), - provider_name: "Mock".to_string(), - description: "Mock".to_string(), - default_model: "unused".to_string(), - configured: true, - provider_type: crate::providers::base::ProviderType::Builtin, - category: crate::providers::catalog::ProviderSetupCategory::Model, - config_keys: vec![], - setup_steps: vec![], - supports_refresh: true, - refreshing: false, - models: models - .into_iter() - .map(|id| crate::providers::inventory::InventoryModel { - name: id.clone(), - id, - family: None, - context_limit: None, - reasoning: None, - recommended: false, - }) - .collect(), - last_updated_at: None, - last_refresh_attempt_at: None, - last_refresh_error: None, - model_selection_hint: None, - }; - build_model_state("unused", &inventory) - } - fn json_object(pairs: Vec<(&str, serde_json::Value)>) -> rmcp::model::JsonObject { pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect() } @@ -4424,6 +3539,57 @@ print(\"hello, world\") ); } + #[test] + fn test_message_update_meta_includes_created_and_message_id() { + let meta = message_update_meta(Some("msg_live"), 1_700_000_000); + + assert_eq!( + meta.get("goose"), + Some(&serde_json::json!({ + "created": 1_700_000_000, + "messageId": "msg_live", + })), + ); + } + + #[test] + fn test_credits_exhausted_system_notification_maps_to_prompt_error() { + let content = MessageContent::SystemNotification(SystemNotificationContent { + notification_type: SystemNotificationType::CreditsExhausted, + msg: "Please add credits to your account, then resend your message to continue." + .to_string(), + data: Some(serde_json::json!({ + "top_up_url": "https://router.tetrate.ai/billing" + })), + }); + + let error = prompt_error_from_message_content(&content).expect("expected prompt error"); + let value = serde_json::to_value(error).unwrap(); + + assert_eq!( + value, + serde_json::json!({ + "code": -32603, + "message": "Please add credits to your account, then resend your message to continue.", + "data": { + "reason": "credits_exhausted", + "url": "https://router.tetrate.ai/billing" + } + }) + ); + } + + #[test] + fn test_non_credit_system_notification_does_not_map_to_prompt_error() { + let content = MessageContent::SystemNotification(SystemNotificationContent { + notification_type: SystemNotificationType::InlineMessage, + msg: "Compaction complete".to_string(), + data: None, + }); + + assert!(prompt_error_from_message_content(&content).is_none()); + } + #[test] fn test_merge_replay_message_meta_omits_message_id_when_none() { let message = Message::new(Role::Assistant, 1_700_000_000, vec![]); @@ -4533,122 +3699,27 @@ print(\"hello, world\") #[test] fn test_build_usage_update_clamps_negative_used_to_zero() { - let session = make_session_with_usage(Some(-7), Some(0), Some(0), None, None, None); - let usage = build_usage_update(&session, 258_000); + let mut session = make_session_with_usage(Some(-7), Some(0), Some(0), None, None, None); + session.model_config = Some( + crate::model::ModelConfig::new("test-model") + .unwrap() + .with_context_limit(Some(258_000)), + ); + let updates = build_usage_updates(&session).expect("usage updates should be present"); + assert_eq!(updates.custom.session_id, "session-1"); + let usage = match updates.custom.update { + GooseSessionUpdate::UsageUpdate(usage) => usage, + other => panic!("expected usage update, got {other:?}"), + }; assert_eq!(usage.used, 0); - assert_eq!(usage.size, 258_000); + assert_eq!(usage.context_limit, 258_000); + assert_eq!(updates.standard.used, 0); + assert_eq!(updates.standard.size, 258_000); } - #[test_case( - GooseMode::Auto - => Ok(SessionModeState::new( - SessionModeId::new("auto"), - vec![ - SessionMode::new(SessionModeId::new("auto"), "auto") - .description("Automatically approve tool calls"), - SessionMode::new(SessionModeId::new("approve"), "approve") - .description("Ask before every tool call"), - SessionMode::new(SessionModeId::new("smart_approve"), "smart_approve") - .description("Ask only for sensitive tool calls"), - SessionMode::new(SessionModeId::new("chat"), "chat") - .description("Chat only, no tool calls"), - ], - )) - ; "auto mode" - )] - #[test_case( - GooseMode::Approve - => Ok(SessionModeState::new( - SessionModeId::new("approve"), - vec![ - SessionMode::new(SessionModeId::new("auto"), "auto") - .description("Automatically approve tool calls"), - SessionMode::new(SessionModeId::new("approve"), "approve") - .description("Ask before every tool call"), - SessionMode::new(SessionModeId::new("smart_approve"), "smart_approve") - .description("Ask only for sensitive tool calls"), - SessionMode::new(SessionModeId::new("chat"), "chat") - .description("Chat only, no tool calls"), - ], - )) - ; "approve mode" - )] - fn test_build_mode_state( - current_mode: GooseMode, - ) -> Result { - build_mode_state(current_mode) - } - - #[test_case( - build_mode_state(GooseMode::Auto).unwrap(), - "openai", - vec![ - SessionConfigSelectOption::new("anthropic", "anthropic"), - SessionConfigSelectOption::new("openai", "openai"), - ], - SessionModelState::new( - ModelId::new("gpt-4"), - vec![ModelInfo::new(ModelId::new("gpt-4"), "gpt-4"), ModelInfo::new(ModelId::new("gpt-3.5"), "gpt-3.5")], - ) - => vec![ - SessionConfigOption::select( - "provider", "Provider", "openai", - vec![ - SessionConfigSelectOption::new("anthropic", "anthropic"), - SessionConfigSelectOption::new("openai", "openai"), - ], - ), - SessionConfigOption::select( - "mode", "Mode", "auto", - vec![ - SessionConfigSelectOption::new("auto", "auto").description("Automatically approve tool calls"), - SessionConfigSelectOption::new("approve", "approve").description("Ask before every tool call"), - SessionConfigSelectOption::new("smart_approve", "smart_approve").description("Ask only for sensitive tool calls"), - SessionConfigSelectOption::new("chat", "chat").description("Chat only, no tool calls"), - ], - ).category(SessionConfigOptionCategory::Mode), - SessionConfigOption::select( - "model", "Model", "gpt-4", - vec![ - SessionConfigSelectOption::new("gpt-4", "gpt-4"), - SessionConfigSelectOption::new("gpt-3.5", "gpt-3.5"), - ], - ).category(SessionConfigOptionCategory::Model), - ] - ; "auto mode with multiple models" - )] - #[test_case( - build_mode_state(GooseMode::Approve).unwrap(), - "openai", - vec![SessionConfigSelectOption::new("openai", "openai")], - SessionModelState::new(ModelId::new("only-model"), vec![ModelInfo::new(ModelId::new("only-model"), "only-model")]) - => vec![ - SessionConfigOption::select( - "provider", "Provider", "openai", - vec![SessionConfigSelectOption::new("openai", "openai")], - ), - SessionConfigOption::select( - "mode", "Mode", "approve", - vec![ - SessionConfigSelectOption::new("auto", "auto").description("Automatically approve tool calls"), - SessionConfigSelectOption::new("approve", "approve").description("Ask before every tool call"), - SessionConfigSelectOption::new("smart_approve", "smart_approve").description("Ask only for sensitive tool calls"), - SessionConfigSelectOption::new("chat", "chat").description("Chat only, no tool calls"), - ], - ).category(SessionConfigOptionCategory::Mode), - SessionConfigOption::select( - "model", "Model", "only-model", - vec![SessionConfigSelectOption::new("only-model", "only-model")], - ).category(SessionConfigOptionCategory::Model), - ] - ; "approve mode with single model" - )] - fn test_build_config_options( - mode_state: SessionModeState, - provider_name: &'static str, - provider_options: Vec, - model_state: SessionModelState, - ) -> Vec { - build_config_options(&mode_state, &model_state, provider_name, provider_options) + #[test] + fn test_build_usage_update_requires_model_config() { + let session = make_session_with_usage(Some(120), Some(80), Some(40), None, None, None); + assert!(build_usage_updates(&session).is_none()); } } diff --git a/crates/goose/src/acp/server/config.rs b/crates/goose/src/acp/server/config.rs index 41a9b96d..1657e79e 100644 --- a/crates/goose/src/acp/server/config.rs +++ b/crates/goose/src/acp/server/config.rs @@ -114,11 +114,11 @@ impl GooseAcpAgent { let config = self.config()?; let model = model_id.clone().unwrap_or_else(|| { - crate::config::get_provider_entry(&config, &provider_id) + crate::config::get_provider_entry(config, &provider_id) .map(|e| e.model) .unwrap_or_default() }); - crate::config::set_active_provider(&config, &provider_id, &model) + crate::config::set_active_provider(config, &provider_id, &model) .internal_err_ctx("Failed to save default provider")?; Ok(DefaultsReadResponse { diff --git a/crates/goose/src/acp/server/custom_dispatch.rs b/crates/goose/src/acp/server/custom_dispatch.rs index 81c6e671..65c23aa8 100644 --- a/crates/goose/src/acp/server/custom_dispatch.rs +++ b/crates/goose/src/acp/server/custom_dispatch.rs @@ -306,6 +306,15 @@ impl GooseAcpAgent { self.on_import_session(req).await } + #[custom_method(ElicitationRespondRequest)] + async fn dispatch_elicitation_respond( + &self, + _req: ElicitationRespondRequest, + ) -> Result { + Err(agent_client_protocol::Error::invalid_params() + .data("_goose/unstable/elicitation/respond must be handled by the connection-scoped dispatcher")) + } + #[custom_method(UpdateSessionProjectRequest)] async fn dispatch_update_session_project( &self, diff --git a/crates/goose/src/acp/server/dispatch.rs b/crates/goose/src/acp/server/dispatch.rs index 7e23333e..b02e0351 100644 --- a/crates/goose/src/acp/server/dispatch.rs +++ b/crates/goose/src/acp/server/dispatch.rs @@ -1,4 +1,5 @@ use super::*; +use crate::providers::inventory::ensure_refresh_identity_current; impl HandleDispatchFrom for GooseAcpHandler { fn describe_chain(&self) -> impl std::fmt::Debug { @@ -16,6 +17,12 @@ impl HandleDispatchFrom for GooseAcpHandler { // The MatchDispatchFrom chain produces an ~85KB async state machine. // Box::pin moves it to the heap so it doesn't overflow the tokio worker stack. Box::pin(async move { + // Capture the connection handle so handlers can lazily activate + // sessions that exist on disk but were never activated via + // new_session/load_session on this connection. Set-once per + // connection; the result is ignored on later requests. + let _ = agent.client_cx.set(cx.clone()); + // InitializeRequest runs inline: it sets connection-scoped state // (client fs/terminal capabilities) that later handlers read with // defaults, so a pipelined NewSessionRequest must not race ahead of it. @@ -88,6 +95,19 @@ impl HandleDispatchFrom for GooseAcpHandler { Ok(()) }) .await + .if_request({ + let agent = agent.clone(); + let cx = cx.clone(); + |req: ElicitationRespondRequest, responder: Responder| async move { + let cx_spawn = cx.clone(); + cx.spawn(async move { + responder.respond_with_result(agent.on_elicitation_respond(&cx_spawn, req).await)?; + Ok(()) + })?; + Ok(()) + } + }) + .await // set_config_option (SACP 11) and legacy set_mode/set_model; custom _goose/* in otherwise. .if_request({ let agent = agent.clone(); diff --git a/crates/goose/src/acp/server/fork_session.rs b/crates/goose/src/acp/server/fork_session.rs new file mode 100644 index 00000000..c0e3c764 --- /dev/null +++ b/crates/goose/src/acp/server/fork_session.rs @@ -0,0 +1,72 @@ +use super::*; + +impl GooseAcpAgent { + #[allow(dead_code)] + pub(super) async fn handle_fork_session( + &self, + cx: &ConnectionTo, + args: ForkSessionRequest, + ) -> Result { + validate_absolute_cwd(&args.cwd)?; + let source_session_id = &*args.session_id.0; + + let source = self + .session_manager + .get_session(source_session_id, false) + .await + .internal_err()?; + let fork_name = if source.name.trim().is_empty() { + "(copy)".to_string() + } else { + format!("{} (copy)", source.name) + }; + + let new_session = self + .session_manager + .copy_session(source_session_id, fork_name) + .await + .internal_err()?; + let new_session_id = new_session.id.clone(); + + let goose_session = self + .session_manager + .get_session(&new_session_id, false) + .await + .internal_err()?; + + let goose_session = self + .prepare_session_for_activation( + goose_session, + args.cwd.clone(), + args.mcp_servers, + false, + ) + .await?; + + let (_agent, extension_results) = self + .activate_acp_session(cx, &goose_session, HashMap::new()) + .await?; + + let acp_session_id = SessionId::new(new_session_id.clone()); + let mut meta = session_meta(&new_session); + if let Ok(v) = serde_json::to_value(&extension_results) { + meta.insert("extensionResults".to_string(), v); + } + + let (mode_state, model_state, config_options) = + build_session_setup_config(&self.provider_inventory, &goose_session).await?; + + let mut response = ForkSessionResponse::new(acp_session_id.clone()) + .modes(mode_state) + .meta(meta); + + if let Some(ms) = model_state { + response = response.models(ms); + } + if let Some(co) = config_options { + response = response.config_options(co); + } + send_session_setup_notifications(cx, &goose_session)?; + Ok(response) + } +} diff --git a/crates/goose/src/acp/server/load_session.rs b/crates/goose/src/acp/server/load_session.rs new file mode 100644 index 00000000..6a2fc862 --- /dev/null +++ b/crates/goose/src/acp/server/load_session.rs @@ -0,0 +1,288 @@ +use super::*; + +fn replay_audience_annotations(audience: &[Role]) -> Annotations { + Annotations::new().audience( + audience + .iter() + .map(|role| match role { + Role::Assistant => agent_client_protocol::schema::Role::Assistant, + Role::User => agent_client_protocol::schema::Role::User, + }) + .collect::>(), + ) +} + +fn send_replay_content_chunk( + cx: &ConnectionTo, + session_id: &SessionId, + message: &Message, + content: ContentBlock, +) -> std::result::Result<(), agent_client_protocol::Error> { + let chunk = ContentChunk::new(content).meta(replay_message_meta(message)); + let update = match message.role { + Role::User => SessionUpdate::UserMessageChunk(chunk), + Role::Assistant => SessionUpdate::AgentMessageChunk(chunk), + }; + cx.send_notification(SessionNotification::new(session_id.clone(), update)) +} + +fn replay_conversation_to_client( + cx: &ConnectionTo, + session: &Session, +) -> Result, agent_client_protocol::Error> +{ + let session_id = SessionId::new(session.id.clone()); + let sid = sid_short(session_id.0.as_ref()); + + let messages = session + .conversation + .as_ref() + .map(|c| c.messages().to_vec()) + .unwrap_or_default(); + debug!( + target: "perf", + sid = %sid, + messages = messages.len(), + "perf: load_session messages loaded" + ); + + let mut replay_tool_requests = + HashMap::::new(); + let submitted_elicitation_ids = collect_submitted_elicitation_ids(&messages); + + for message in &messages { + if !message.metadata.user_visible { + continue; + } + + for content_item in &message.content { + match content_item { + MessageContent::Text(text) => { + let mut tc = TextContent::new(text.text.clone()); + if let Some(audience) = text.audience() { + tc = tc.annotations(replay_audience_annotations(audience)); + } + send_replay_content_chunk(cx, &session_id, message, ContentBlock::Text(tc))?; + } + MessageContent::Image(image) => { + let mut image_content = + ImageContent::new(image.data.clone(), image.mime_type.clone()); + if let Some(audience) = image.audience() { + image_content = + image_content.annotations(replay_audience_annotations(audience)); + } + send_replay_content_chunk( + cx, + &session_id, + message, + ContentBlock::Image(image_content), + )?; + } + MessageContent::ToolRequest(tool_request) => { + replay_tool_requests.insert(tool_request.id.clone(), tool_request.clone()); + + let pending_tool_call = pending_tool_call_from_request(tool_request); + let mut meta = pending_tool_call.identity_meta; + if let Some(chain_summary) = tool_request.persisted_chain_summary() { + meta = with_tool_chain_summary_meta( + meta, + &chain_summary.summary, + chain_summary.count, + ); + } + let tool_call = pending_tool_call + .tool_call + .meta(merge_replay_message_meta(meta, message)); + + cx.send_notification(SessionNotification::new( + session_id.clone(), + SessionUpdate::ToolCall(tool_call), + ))?; + } + MessageContent::ToolResponse(tool_response) => { + let status = match &tool_response.tool_result { + Ok(result) if result.is_error == Some(true) => ToolCallStatus::Failed, + Ok(_) => ToolCallStatus::Completed, + Err(_) => ToolCallStatus::Failed, + }; + + let mut fields = ToolCallUpdateFields::new().status(status); + if let Some(raw_output) = extract_tool_raw_output(&tool_response.tool_result) { + fields = fields.raw_output(raw_output); + } + if !tool_response + .tool_result + .as_ref() + .is_ok_and(|r| r.is_acp_aware()) + { + let content = build_tool_call_content(&tool_response.tool_result); + fields = fields.content(content); + + let locations = + extract_locations_from_meta(tool_response).unwrap_or_else(|| { + if let Some(tool_request) = + replay_tool_requests.get(&tool_response.id) + { + extract_tool_locations(tool_request, tool_response) + } else { + Vec::new() + } + }); + if !locations.is_empty() { + fields = fields.locations(locations); + } + } + + let update = + ToolCallUpdate::new(ToolCallId::new(tool_response.id.clone()), fields) + .meta(merge_replay_message_meta( + extract_tool_call_update_meta(tool_response), + message, + )); + cx.send_notification(SessionNotification::new( + session_id.clone(), + SessionUpdate::ToolCallUpdate(update), + ))?; + } + MessageContent::Thinking(thinking) => { + cx.send_notification(SessionNotification::new( + session_id.clone(), + SessionUpdate::AgentThoughtChunk( + ContentChunk::new(ContentBlock::Text(TextContent::new( + thinking.thinking.clone(), + ))) + .meta(replay_message_meta(message)), + ), + ))?; + } + MessageContent::ActionRequired(action_required) => { + if let ActionRequiredData::Elicitation { + id, + message: elicitation_message, + requested_schema, + } = &action_required.data + { + if !submitted_elicitation_ids.contains(id) { + send_elicitation_interaction_update( + cx, + session_id.0.as_ref(), + id.clone(), + InteractionState::Pending, + Some(elicitation_message.clone()), + Some(requested_schema.clone()), + Some(serde_json::Value::Object(replay_message_meta(message))), + )?; + } + } + } + MessageContent::SystemNotification(_) => {} + _ => {} + } + } + } + + Ok(replay_tool_requests) +} + +fn collect_submitted_elicitation_ids(messages: &[Message]) -> HashSet { + let mut submitted_ids = HashSet::new(); + + for message in messages { + for content_item in &message.content { + if let MessageContent::ActionRequired(action_required) = content_item { + if let ActionRequiredData::ElicitationResponse { id, .. } = &action_required.data { + submitted_ids.insert(id.clone()); + } + } + } + } + + submitted_ids +} + +impl GooseAcpAgent { + pub(super) async fn handle_load_session( + &self, + cx: &ConnectionTo, + args: LoadSessionRequest, + ) -> Result { + debug!(?args, "load session request"); + validate_absolute_cwd(&args.cwd)?; + + let session_id_str = args.session_id.0.to_string(); + let sid = sid_short(&session_id_str); + let t_start = std::time::Instant::now(); + + let mut session = self + .session_manager + .get_session(&session_id_str, true) + .await + .map_err(|_| { + agent_client_protocol::Error::resource_not_found(Some(session_id_str.clone())) + .data(format!("Session not found: {}", session_id_str)) + })?; + + session = self + .prepare_session_for_activation(session, args.cwd.clone(), args.mcp_servers, true) + .await?; + + let replay_tool_requests = replay_conversation_to_client(cx, &session)?; + let (agent, extension_results) = self.prepare_acp_session_agent(cx, &session).await?; + self.register_acp_session(session_id_str.clone(), agent.clone(), replay_tool_requests) + .await; + + session = self + .session_manager + .get_session(&session_id_str, true) + .await + .internal_err_ctx("Failed to reload session")?; + + agent + .extension_manager + .update_working_dir(&session.working_dir) + .await; + + let (mode_state, model_state, config_options) = + build_session_setup_config(&self.provider_inventory, &session).await?; + + send_session_setup_notifications(cx, &session)?; + + let mut response = LoadSessionResponse::new().modes(mode_state); + if let Some(ms) = model_state { + response = response.models(ms); + } + if let Some(co) = config_options { + response = response.config_options(co); + } + + let mut meta = serde_json::Map::new(); + if let Some(recipe) = &session.recipe { + if let Ok(v) = serde_json::to_value(recipe) { + meta.insert("recipe".to_string(), v); + } + } + if let Some(values) = &session.user_recipe_values { + if let Ok(v) = serde_json::to_value(values) { + meta.insert("userRecipeValues".to_string(), v); + } + } + if let Ok(v) = serde_json::to_value(&extension_results) { + meta.insert("extensionResults".to_string(), v); + } + meta.insert( + "workingDir".to_string(), + serde_json::Value::String(session.working_dir.to_string_lossy().to_string()), + ); + if !meta.is_empty() { + response = response.meta(meta); + } + + debug!( + target: "perf", + sid = %sid, + ms = t_start.elapsed().as_millis() as u64, + "perf: load_session_refactor done" + ); + Ok(response) + } +} diff --git a/crates/goose/src/acp/server/sessions.rs b/crates/goose/src/acp/server/manage_sessions.rs similarity index 91% rename from crates/goose/src/acp/server/sessions.rs rename to crates/goose/src/acp/server/manage_sessions.rs index e46a5e75..8e5163bd 100644 --- a/crates/goose/src/acp/server/sessions.rs +++ b/crates/goose/src/acp/server/manage_sessions.rs @@ -20,15 +20,12 @@ impl GooseAcpAgent { .await .internal_err()?; - if let Some(session) = self.sessions.lock().await.get_mut(session_id) { - match &session.agent { - AgentHandle::Ready(agent) => { - agent.extension_manager.update_working_dir(&path).await; - } - AgentHandle::Loading(_) => { - session.pending_working_dir = Some(path); - } - } + if let Some(session) = self.sessions.lock().await.get(session_id) { + session + .agent + .extension_manager + .update_working_dir(&path) + .await; } Ok(EmptyResponse {}) @@ -45,7 +42,7 @@ impl GooseAcpAgent { ); } - let agent = self.get_session_agent_provider_ready(session_id).await?; + let agent = self.get_session_agent(session_id, None).await?; match req.mode { SessionSystemPromptMode::Set => { if req.text.trim().is_empty() { @@ -84,6 +81,7 @@ impl GooseAcpAgent { .await .internal_err()?; self.sessions.lock().await.remove(&req.session_id); + let _ = self.agent_manager.remove_session(&req.session_id).await; Ok(EmptyResponse {}) } @@ -156,6 +154,7 @@ impl GooseAcpAgent { .await .internal_err()?; self.sessions.lock().await.remove(&req.session_id); + let _ = self.agent_manager.remove_session(&req.session_id).await; Ok(EmptyResponse {}) } diff --git a/crates/goose/src/acp/server/new_session.rs b/crates/goose/src/acp/server/new_session.rs new file mode 100644 index 00000000..dba27c84 --- /dev/null +++ b/crates/goose/src/acp/server/new_session.rs @@ -0,0 +1,108 @@ +use crate::acp::server::{meta_string, sid_short, validate_absolute_cwd, ResultExt}; +use crate::config::{Config, GooseMode}; +use crate::session::SessionType; + +use super::GooseAcpAgent; +use agent_client_protocol::schema::{NewSessionRequest, NewSessionResponse, SessionId}; +use agent_client_protocol::{Client, ConnectionTo}; +use std::collections::HashMap; +use tracing::debug; + +impl GooseAcpAgent { + #[allow(dead_code)] + pub(super) async fn handle_new_session( + &self, + cx: &ConnectionTo, + args: NewSessionRequest, + ) -> Result { + debug!(?args, "new session request"); + let t_start = std::time::Instant::now(); + validate_absolute_cwd(&args.cwd)?; + let project_id = meta_string(args.meta.as_ref(), "projectId"); + let session_type = match meta_string(args.meta.as_ref(), "client") { + Some(_) => SessionType::User, + None => SessionType::Acp, + }; + let config = Config::global(); + let (resolved_provider, resolved_model_config) = + match meta_string(args.meta.as_ref(), "provider") { + Some(provider) => { + let model_config = + super::resolve_provider_default_model_config(&provider).await?; + (provider, model_config) + } + None => super::resolve_default_provider_model_config(config)?, + }; + let current_mode: GooseMode = config.get_goose_mode().unwrap_or_default(); + let t0 = std::time::Instant::now(); + let mut goose_session = self + .session_manager + .create_session( + args.cwd.clone(), + "New Chat".to_string(), + session_type, + current_mode, + ) + .await + .internal_err_ctx("Failed to create session")?; + let mut builder = self.session_manager.update(&goose_session.id); + let extension_data = + self.build_enabled_extensions_data(config, &goose_session, args.mcp_servers)?; + builder = builder + .provider_name(resolved_provider) + .model_config(resolved_model_config) + .extension_data(extension_data); + if let Some(pid) = project_id { + builder = builder.project_id(Some(pid)); + } + builder + .apply() + .await + .internal_err_ctx("Failed to update session")?; + + goose_session = self + .session_manager + .get_session(&goose_session.id, false) + .await + .internal_err_ctx("Failed to reload session")?; + let session_id_str = goose_session.id.clone(); + let sid = sid_short(&session_id_str); + debug!(target: "perf", sid = %sid, ms = t0.elapsed().as_millis() as u64, "perf: new_session create_session"); + + let (_agent, extension_results) = self + .activate_acp_session(cx, &goose_session, HashMap::new()) + .await?; + + let goose_session = self + .session_manager + .get_session(&goose_session.id, false) + .await + .internal_err_ctx("Failed to reload session")?; + + let acp_session_id = SessionId::new(session_id_str.clone()); + + let (mode_state, model_state, config_options) = + super::build_session_setup_config(&self.provider_inventory, &goose_session).await?; + + let mut response = NewSessionResponse::new(acp_session_id.clone()).modes(mode_state); + if let Some(ms) = model_state { + response = response.models(ms); + } + if let Some(co) = config_options { + response = response.config_options(co); + } + if let Ok(extension_results) = serde_json::to_value(&extension_results) { + let mut meta = serde_json::Map::new(); + meta.insert("extensionResults".to_string(), extension_results); + response = response.meta(meta); + } + super::send_session_setup_notifications(cx, &goose_session)?; + debug!( + target: "perf", + sid = %sid, + ms = t_start.elapsed().as_millis() as u64, + "perf: new_session done" + ); + Ok(response) + } +} diff --git a/crates/goose/src/acp/server/onboarding.rs b/crates/goose/src/acp/server/onboarding.rs index d0ff12a1..233f7a43 100644 --- a/crates/goose/src/acp/server/onboarding.rs +++ b/crates/goose/src/acp/server/onboarding.rs @@ -59,7 +59,7 @@ impl GooseAcpAgent { ) -> Result { let config = self.config()?; Ok(apply_onboarding_import_candidates( - &config, + config, &self.config_dir, &req, )) diff --git a/crates/goose/src/acp/server/providers.rs b/crates/goose/src/acp/server/providers.rs index 2916ccf4..c24105fb 100644 --- a/crates/goose/src/acp/server/providers.rs +++ b/crates/goose/src/acp/server/providers.rs @@ -1,5 +1,6 @@ use super::*; use crate::config::declarative_providers; +use crate::providers::inventory::ensure_refresh_identity_current; use std::str::FromStr; fn inventory_entry_to_dto(entry: ProviderInventoryEntry) -> ProviderInventoryEntryDto { diff --git a/crates/goose/src/acp/server_factory.rs b/crates/goose/src/acp/server_factory.rs index 079287d9..8cd971a6 100644 --- a/crates/goose/src/acp/server_factory.rs +++ b/crates/goose/src/acp/server_factory.rs @@ -23,15 +23,7 @@ impl AcpServer { } pub async fn create_agent(&self) -> Result> { - let config_path = self - .config - .config_dir - .join(crate::config::base::CONFIG_YAML_NAME); - let config = crate::config::Config::new(&config_path, "goose")?; - - let goose_mode = config - .get_goose_mode() - .unwrap_or(crate::config::GooseMode::Auto); + let config = crate::config::Config::global(); let disable_session_naming = config.get_goose_disable_session_naming().unwrap_or(false); let provider_factory: AcpProviderFactory = Arc::new( @@ -60,7 +52,6 @@ impl AcpServer { builtins: self.config.builtins.clone(), data_dir: self.config.data_dir.clone(), config_dir: self.config.config_dir.clone(), - goose_mode, disable_session_naming, goose_platform: self.config.goose_platform.clone(), additional_source_roots: self.config.additional_source_roots.clone(), diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 91f2e874..6ab6e625 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -2522,9 +2522,14 @@ impl Agent { .await .is_ok() { - let p = crate::providers::create(&provider_name, model_config, extensions) - .await - .map_err(|e| anyhow!("Could not create provider: {}", e))?; + let p = crate::providers::create_with_working_dir( + &provider_name, + model_config, + extensions, + session.working_dir.clone(), + ) + .await + .map_err(|e| anyhow!("Could not create provider: {}", e))?; (p, false) } else { let fallback_provider_name = config @@ -2552,10 +2557,11 @@ impl Agent { .map_err(|e| anyhow!("Could not configure fallback provider: invalid model {}", e))? .with_canonical_limits(&fallback_provider_name); - let fallback_provider = crate::providers::create( + let fallback_provider = crate::providers::create_with_working_dir( &fallback_provider_name, fallback_model_config.clone(), extensions, + session.working_dir.clone(), ) .await .map_err(|e| { diff --git a/crates/goose/src/agents/execute_commands.rs b/crates/goose/src/agents/execute_commands.rs index e4e1a8c3..8860c89f 100644 --- a/crates/goose/src/agents/execute_commands.rs +++ b/crates/goose/src/agents/execute_commands.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use anyhow::{anyhow, Result}; use crate::context_mgmt::compact_messages; -use crate::conversation::message::{Message, SystemNotificationType}; +use crate::conversation::message::Message; use crate::slash_commands::{recipe_slash_command, skill_slash_command}; use super::Agent; @@ -150,10 +150,7 @@ impl Agent { self.update_session_metrics(session_id, session.schedule_id, &usage, true) .await?; - Ok(Some(Message::assistant().with_system_notification( - SystemNotificationType::InlineMessage, - "Compaction complete", - ))) + Ok(Some(user_only_assistant_text("Compaction complete"))) } async fn handle_clear_command(&self, session_id: &str) -> Result> { @@ -172,10 +169,7 @@ impl Agent { .apply() .await?; - Ok(Some(Message::assistant().with_system_notification( - SystemNotificationType::InlineMessage, - "Conversation cleared", - ))) + Ok(Some(user_only_assistant_text("Conversation cleared"))) } async fn handle_skills_command(&self, session_id: &str) -> Result> { @@ -425,9 +419,14 @@ impl Agent { } } +fn user_only_assistant_text(text: impl Into) -> Message { + Message::assistant().with_text(text).user_only() +} + #[cfg(test)] mod tests { use super::*; + use crate::conversation::message::MessageContent; #[test] fn parse_slash_command_splits_on_literal_space() { @@ -447,4 +446,17 @@ mod tests { assert_eq!(parsed.command, "speckit.plan\nhello"); assert_eq!(parsed.params_str, ""); } + + #[test] + fn user_only_assistant_text_is_durable_text_not_system_notification() { + let message = user_only_assistant_text("Conversation cleared"); + + assert!(message.metadata.user_visible); + assert!(!message.metadata.agent_visible); + assert_eq!(message.role, rmcp::model::Role::Assistant); + assert!(matches!( + message.content.as_slice(), + [MessageContent::Text(text)] if text.text == "Conversation cleared" + )); + } } diff --git a/crates/goose/src/bin/generate_acp_schema.rs b/crates/goose/src/bin/generate_acp_schema.rs index 905dacb1..90daed77 100644 --- a/crates/goose/src/bin/generate_acp_schema.rs +++ b/crates/goose/src/bin/generate_acp_schema.rs @@ -1,3 +1,4 @@ +use goose::acp::custom_notifications::custom_notification_schemas; use goose::acp::server::GooseAcpAgent; use schemars::SchemaGenerator; use serde_json::{json, Map, Value}; @@ -9,6 +10,7 @@ use std::path::PathBuf; fn main() { let mut generator = SchemaGenerator::default(); let methods = GooseAcpAgent::custom_method_schemas(&mut generator); + let notifications = custom_notification_schemas(&mut generator); // Collect $defs from the generator (all types referenced via subschema_for). let mut defs: Map = generator @@ -19,7 +21,7 @@ fn main() { // Track which types map to which methods so we can detect shared types. let mut type_methods: HashMap> = HashMap::new(); - for m in &methods { + for m in methods.iter().chain(notifications.iter()) { let method = m.method.clone(); if let Some(name) = &m.params_type_name { type_methods @@ -90,6 +92,7 @@ fn main() { // deduplicating response variants (e.g. EmptyResponse appears once). let mut request_variants: Vec = Vec::new(); let mut response_variants: Vec = Vec::new(); + let mut notification_variants: Vec = Vec::new(); let mut seen_response_types: BTreeSet = BTreeSet::new(); for m in &methods { @@ -113,6 +116,17 @@ fn main() { } } + for n in ¬ifications { + if let Some(name) = &n.params_type_name { + let generated_name = generated_type_name(name, &unstable_type_names); + notification_variants.push(json!({ + "allOf": [{ "$ref": format!("#/$defs/{generated_name}") }], + "description": format!("Params for {}", n.method), + "title": generated_name, + })); + } + } + // Build ExtRequest — mirrors AgentRequest structure. defs.insert( "ExtRequest".into(), @@ -174,6 +188,25 @@ fn main() { }), ); + // Build ExtNotification — fire-and-forget message with no `id` and no response. + defs.insert( + "ExtNotification".into(), + json!({ + "properties": { + "method": { "type": "string" }, + "params": { + "anyOf": [ + { "anyOf": notification_variants }, + { "description": "Untyped params", "type": ["object", "null"] }, + ] + } + }, + "required": ["method"], + "type": "object", + "x-docs-ignore": true, + }), + ); + // Assemble the root schema document. let root = json!({ "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -189,6 +222,11 @@ fn main() { "allOf": [{ "$ref": "#/$defs/ExtResponse" }], "description": "Extension response (agent → client)", "title": "Response", + }, + { + "allOf": [{ "$ref": "#/$defs/ExtNotification" }], + "description": "Extension notification (agent → client, fire-and-forget)", + "title": "Notification", } ], }); @@ -219,7 +257,22 @@ fn main() { }) }) .collect(); - let meta = json!({ "methods": method_entries }); + let notification_entries: Vec = notifications + .iter() + .map(|n| { + json!({ + "method": &n.method, + "paramsType": n + .params_type_name + .as_ref() + .map(|name| generated_type_name(name, &unstable_type_names)), + }) + }) + .collect(); + let meta = json!({ + "methods": method_entries, + "notifications": notification_entries, + }); let meta_str = serde_json::to_string_pretty(&meta).expect("failed to serialize meta"); let meta_path = package_path.join("acp-meta.json"); fs::write(&meta_path, format!("{meta_str}\n")).expect("failed to write meta file"); diff --git a/crates/goose/src/execution/manager.rs b/crates/goose/src/execution/manager.rs index 302dd7d9..3c4a7414 100644 --- a/crates/goose/src/execution/manager.rs +++ b/crates/goose/src/execution/manager.rs @@ -1,16 +1,17 @@ -use crate::agents::{Agent, AgentConfig, GoosePlatform}; +use crate::agents::mcp_client::GooseMcpHostInfo; +use crate::agents::{Agent, AgentConfig, ExtensionLoadResult, GoosePlatform}; use crate::config::paths::Paths; use crate::config::permission::PermissionManager; -use crate::config::{Config, GooseMode}; +use crate::config::Config; use crate::scheduler::Scheduler; use crate::scheduler_trait::SchedulerTrait; -use crate::session::SessionManager; +use crate::session::{SessionManager, SessionNameUpdate}; use anyhow::Result; use lru::LruCache; use std::collections::HashMap; use std::num::NonZeroUsize; use std::sync::Arc; -use tokio::sync::{Mutex, OnceCell, RwLock}; +use tokio::sync::{mpsc, Mutex, OnceCell, RwLock}; use tokio_util::sync::CancellationToken; use tracing::{debug, info}; @@ -18,12 +19,23 @@ const DEFAULT_MAX_SESSION: usize = 100; static AGENT_MANAGER: OnceCell> = OnceCell::const_new(); +#[derive(Clone, Default)] +pub struct RuntimeContext { + pub mcp_host_info: Option, + pub use_login_shell_path: Option, + pub session_name_update_tx: Option>, +} + +pub struct AgentManagerGetResult { + pub agent: Arc, + pub agent_created: bool, + pub extension_results: Vec, +} + pub struct AgentManager { sessions: Arc>>>, - scheduler: Arc, - session_manager: Arc, + agent_config: AgentConfig, default_provider: Arc>>>, - default_mode: GooseMode, cancel_tokens: Arc>>, /// Per-session creation locks. When `get_or_create_agent` misses the /// `sessions` cache it acquires the per-session lock before doing the @@ -37,23 +49,14 @@ pub struct AgentManager { } impl AgentManager { - pub async fn new( - session_manager: Arc, - schedule_file_path: std::path::PathBuf, - max_sessions: Option, - default_mode: GooseMode, - ) -> Result { - let scheduler = Scheduler::new(schedule_file_path, session_manager.clone()).await?; - + pub async fn new(agent_config: AgentConfig, max_sessions: Option) -> Result { let capacity = NonZeroUsize::new(max_sessions.unwrap_or(DEFAULT_MAX_SESSION)) .unwrap_or_else(|| NonZeroUsize::new(100).unwrap()); let manager = Self { sessions: Arc::new(RwLock::new(LruCache::new(capacity))), - scheduler, - session_manager, + agent_config, default_provider: Arc::new(RwLock::new(None)), - default_mode, cancel_tokens: Arc::new(RwLock::new(HashMap::new())), creation_locks: Arc::new(Mutex::new(HashMap::new())), }; @@ -71,13 +74,18 @@ impl AgentManager { let default_mode = config.get_goose_mode().unwrap_or_default(); let schedule_file_path = Paths::data_dir().join("schedule.json"); let session_manager = Arc::new(SessionManager::instance()); - let manager = Self::new( + let scheduler = Scheduler::new(schedule_file_path, Arc::clone(&session_manager)) + .await + .map(|scheduler| scheduler as Arc)?; + let agent_config = AgentConfig::new( session_manager, - schedule_file_path, - Some(max_sessions), + PermissionManager::instance(), + Some(scheduler), default_mode, - ) - .await?; + config.get_goose_disable_session_naming().unwrap_or(false), + GoosePlatform::GooseDesktop, + ); + let manager = Self::new(agent_config, Some(max_sessions)).await?; Ok(Arc::new(manager)) }) .await @@ -85,12 +93,17 @@ impl AgentManager { } pub fn scheduler(&self) -> Arc { - Arc::clone(&self.scheduler) + Arc::clone( + self.agent_config + .scheduler_service + .as_ref() + .expect("AgentManager scheduler is not configured"), + ) } /// Get the shared SessionManager for session-only operations pub fn session_manager(&self) -> &SessionManager { - &self.session_manager + self.agent_config.session_manager.as_ref() } pub async fn set_default_provider(&self, provider: Arc) { @@ -99,11 +112,26 @@ impl AgentManager { } pub async fn get_or_create_agent(&self, session_id: String) -> Result> { + Ok(self + .get_or_create_agent_with_runtime_context(session_id, RuntimeContext::default()) + .await? + .agent) + } + + pub async fn get_or_create_agent_with_runtime_context( + &self, + session_id: String, + runtime_context: RuntimeContext, + ) -> Result { // Fast path: agent already cached. { let mut sessions = self.sessions.write().await; if let Some(existing) = sessions.get(&session_id) { - return Ok(Arc::clone(existing)); + return Ok(AgentManagerGetResult { + agent: Arc::clone(existing), + agent_created: false, + extension_results: Vec::new(), + }); } } @@ -128,7 +156,7 @@ impl AgentManager { // bail out via `?`, leaving a permanent `creation_locks` entry // for a session that never made it into the LRU cache and that // no one will ever call `remove_session` on. - let result = self.create_agent_locked(&session_id).await; + let result = self.create_agent_locked(&session_id, runtime_context).await; if result.is_err() { // Release BOTH the guard and our local Arc clone of the @@ -149,37 +177,49 @@ impl AgentManager { /// Slow-path body for `get_or_create_agent`. Must be called with the /// per-session creation lock held by the caller. - async fn create_agent_locked(&self, session_id: &str) -> Result> { + async fn create_agent_locked( + &self, + session_id: &str, + runtime_context: RuntimeContext, + ) -> Result { // Re-check under the creation lock: another caller may have // finished creating the agent while we were waiting. { let mut sessions = self.sessions.write().await; if let Some(existing) = sessions.get(session_id) { - return Ok(Arc::clone(existing)); + return Ok(AgentManagerGetResult { + agent: Arc::clone(existing), + agent_created: false, + extension_results: Vec::new(), + }); } } - let mut mode = self.default_mode; - let permission_manager = PermissionManager::instance(); - - if let Ok(session) = self.session_manager.get_session(session_id, false).await { + let mut mode = self.agent_config.goose_mode; + if let Ok(session) = self + .agent_config + .session_manager + .get_session(session_id, false) + .await + { mode = session.goose_mode; info!(goose_mode = %mode, session_id = %session_id, "Session loaded"); } - let config = AgentConfig::new( - Arc::clone(&self.session_manager), - permission_manager, - Some(Arc::clone(&self.scheduler)), - mode, - Config::global() - .get_goose_disable_session_naming() - .unwrap_or(false), - GoosePlatform::GooseDesktop, - ); + let mut config = self.agent_config.clone(); + config.goose_mode = mode; + config.mcp_host_info = runtime_context.mcp_host_info; + config.use_login_shell_path = runtime_context.use_login_shell_path; + config.session_name_update_tx = runtime_context.session_name_update_tx; let agent = Arc::new(Agent::with_config(config)); + let mut extension_results = Vec::new(); - if let Ok(session) = self.session_manager.get_session(session_id, false).await { + if let Ok(session) = self + .agent_config + .session_manager + .get_session(session_id, false) + .await + { if session.provider_name.is_some() { info!( "Restoring evicted session {} (provider: {:?})", @@ -193,7 +233,7 @@ impl AgentManager { ); } } - agent.load_extensions_from_session(&session).await; + extension_results = agent.load_extensions_from_session(&session).await; } if agent.provider().await.is_err() { @@ -210,7 +250,11 @@ impl AgentManager { let mut sessions = self.sessions.write().await; if let Some(existing) = sessions.get(session_id) { - return Ok(Arc::clone(existing)); + return Ok(AgentManagerGetResult { + agent: Arc::clone(existing), + agent_created: false, + extension_results: Vec::new(), + }); } // `push` returns the LRU-evicted entry when the cache is at // capacity, which `put` does not surface. We need the evicted @@ -226,7 +270,11 @@ impl AgentManager { self.prune_creation_lock(&evicted_id).await; } - Ok(agent) + Ok(AgentManagerGetResult { + agent, + agent_created: true, + extension_results, + }) } /// Drop the per-session creation lock for `session_id` if no other @@ -328,6 +376,8 @@ mod tests { use test_case::test_case; + use crate::agents::{AgentConfig, GoosePlatform}; + use crate::config::permission::PermissionManager; use crate::config::GooseMode; use crate::execution::SessionExecutionMode; use crate::session::SessionManager; @@ -336,15 +386,15 @@ mod tests { async fn create_test_manager(temp_dir: &TempDir) -> AgentManager { let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf())); - let schedule_path = temp_dir.path().join("schedule.json"); - AgentManager::new( + let agent_config = AgentConfig::new( session_manager, - schedule_path, - Some(100), + PermissionManager::instance(), + None, GooseMode::default(), - ) - .await - .unwrap() + false, + GoosePlatform::GooseDesktop, + ); + AgentManager::new(agent_config, Some(100)).await.unwrap() } #[test] @@ -632,15 +682,15 @@ mod tests { // even though only `max_sessions` agents remain cached. let temp_dir = TempDir::new().unwrap(); let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf())); - let schedule_path = temp_dir.path().join("schedule.json"); - let manager = AgentManager::new( + let agent_config = AgentConfig::new( session_manager, - schedule_path, - Some(2), + PermissionManager::instance(), + None, GooseMode::default(), - ) - .await - .unwrap(); + false, + GoosePlatform::GooseDesktop, + ); + let manager = AgentManager::new(agent_config, Some(2)).await.unwrap(); manager.get_or_create_agent("a".into()).await.unwrap(); manager.get_or_create_agent("b".into()).await.unwrap(); diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index aaee3376..6bc52a14 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -2,6 +2,7 @@ compile_error!("Features `rustls-tls` and `native-tls` are mutually exclusive"); pub mod acp; +pub use goose_sdk::custom_notifications; pub use goose_sdk::custom_requests; pub mod action_required_manager; pub mod agents; diff --git a/crates/goose/src/providers/inventory/mod.rs b/crates/goose/src/providers/inventory/mod.rs index f363d696..589f2ea3 100644 --- a/crates/goose/src/providers/inventory/mod.rs +++ b/crates/goose/src/providers/inventory/mod.rs @@ -1,4 +1,4 @@ -use super::base::{ConfigKey, ModelInfo, ProviderType}; +use super::base::{ConfigKey, ModelInfo, Provider, ProviderType}; use super::canonical::{map_provider_name, map_to_canonical_model, CanonicalModelRegistry}; use super::catalog::ProviderSetupCategory; use crate::config::declarative_providers::{DeclarativeProviderConfig, ProviderEngine}; @@ -7,10 +7,12 @@ use crate::session::session_manager::SessionStorage; use crate::utils::bytes_to_hex; use anyhow::{Context, Result}; use chrono::{DateTime, Duration, Utc}; +use futures::FutureExt; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use sqlx::{Pool, Row, Sqlite, Transaction}; use std::collections::{BTreeMap, HashMap, HashSet}; +use std::panic::AssertUnwindSafe; use std::sync::{Arc, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard}; use tracing::warn; @@ -310,6 +312,23 @@ impl ProviderInventoryService { })) } + pub async fn find_entry_for_provider( + &self, + provider_id: &str, + ) -> Option { + match self.entry_for_provider(provider_id).await { + Ok(entry) => entry, + Err(error) => { + warn!( + provider = %provider_id, + %error, + "failed to look up provider inventory entry" + ); + None + } + } + } + pub async fn entries(&self, provider_ids: &[String]) -> Result> { let ids = self.resolve_provider_ids(provider_ids).await; let handles: Vec<_> = ids @@ -561,6 +580,106 @@ impl ProviderInventoryService { } } + pub(crate) async fn refresh_with_provider( + &self, + provider_name: &str, + provider: &Arc, + inventory: &mut ProviderInventoryEntry, + context: &str, + ) { + let provider_id = provider_name.to_string(); + match self + .plan_refresh_jobs(std::slice::from_ref(&provider_id)) + .await + { + Ok(plan) + if plan + .started + .iter() + .any(|job| job.provider_id == provider_id) => + { + let refresh_job = plan + .started + .into_iter() + .find(|job| job.provider_id == provider_id); + if let Some(refresh_job) = refresh_job { + let mut refresh_guard = self.refresh_guard(&refresh_job.identity); + let fetch_result: Result> = + match ensure_refresh_identity_current(&provider_id, &refresh_job.identity) + .await + { + Ok(()) => { + match AssertUnwindSafe(provider.fetch_recommended_models()) + .catch_unwind() + .await + { + Ok(Ok(models)) => Ok(models), + Ok(Err(error)) => Err(anyhow::anyhow!(error.to_string())), + Err(_) => Err(anyhow::anyhow!( + "provider inventory refresh task panicked" + )), + } + } + Err(error) => Err(error), + }; + match fetch_result { + Ok(models) => { + if let Err(error) = self + .store_refreshed_models_for_identity(&refresh_job.identity, &models) + .await + { + warn!( + provider = %provider_id, + context = %context, + error = %error, + "failed to store refreshed provider inventory" + ); + } else { + refresh_guard.complete(); + } + } + Err(error) => { + let error_message = error.to_string(); + if let Err(store_error) = self + .store_refresh_error_for_identity( + &refresh_job.identity, + error_message.clone(), + ) + .await + { + warn!( + provider = %provider_id, + context = %context, + error = %store_error, + "failed to store provider inventory refresh error" + ); + } else { + refresh_guard.complete(); + } + warn!( + provider = %provider_id, + context = %context, + error = %error_message, + "provider inventory refresh failed" + ); + } + } + } + } + Ok(_) => {} + Err(error) => warn!( + provider = %provider_id, + context = %context, + error = %error, + "failed to plan provider inventory refresh" + ), + } + + if let Some(refreshed_inventory) = self.find_entry_for_provider(provider_name).await { + *inventory = refreshed_inventory; + } + } + pub fn is_stale(entry: &ProviderInventoryEntry) -> bool { let Some(last_updated_at) = entry.last_updated_at else { return false; @@ -717,6 +836,19 @@ impl ProviderInventoryService { } } +pub(crate) async fn ensure_refresh_identity_current( + provider_id: &str, + planned_identity: &InventoryIdentity, +) -> Result<()> { + let current_identity = crate::providers::inventory_identity(provider_id) + .await? + .into_identity()?; + if current_identity != *planned_identity { + anyhow::bail!("provider inventory identity changed before refresh completed"); + } + Ok(()) +} + pub fn default_inventory_identity( provider_id: &str, provider_family: &str, diff --git a/crates/goose/src/recipe/manifest.rs b/crates/goose/src/recipe/manifest.rs new file mode 100644 index 00000000..f9d11406 --- /dev/null +++ b/crates/goose/src/recipe/manifest.rs @@ -0,0 +1,140 @@ +use anyhow::{anyhow, Result}; +use std::fs; +use std::hash::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; + +use crate::recipe::build_recipe::resolve_sub_recipe_path; +use crate::recipe::local_recipes::list_local_recipes; +use crate::recipe::Recipe; + +#[derive(Debug, Clone)] +pub struct RecipeFileManifest { + pub id: String, + pub recipe: Recipe, + pub file_path: PathBuf, + pub last_modified: String, +} + +pub fn short_id_from_path(path: &str) -> String { + let mut hasher = DefaultHasher::new(); + path.hash(&mut hasher); + let h = hasher.finish(); + format!("{:016x}", h) +} + +pub fn list_recipe_file_manifests() -> Result> { + let recipes_with_path = list_local_recipes()?; + let mut manifests = Vec::new(); + + for (file_path, mut recipe) in recipes_with_path { + let Ok(last_modified) = fs::metadata(file_path.clone()).and_then(|metadata| { + metadata + .modified() + .map(|modified| chrono::DateTime::::from(modified).to_rfc3339()) + }) else { + continue; + }; + + resolve_recipe_sub_recipe_paths(&mut recipe, &file_path); + + manifests.push(RecipeFileManifest { + id: short_id_from_path(file_path.to_string_lossy().as_ref()), + recipe, + file_path, + last_modified, + }); + } + + manifests.sort_by(|a, b| b.last_modified.cmp(&a.last_modified)); + + Ok(manifests) +} + +pub fn get_recipe_file_path_by_id(id: &str) -> Result { + list_recipe_file_manifests()? + .into_iter() + .find(|manifest| manifest.id == id) + .map(|manifest| manifest.file_path) + .ok_or_else(|| anyhow!("Recipe not found: {}", id)) +} + +pub fn load_recipe_by_id(id: &str) -> Result { + let path = get_recipe_file_path_by_id(id)?; + load_recipe_from_path(&path) +} + +pub fn load_recipe_from_path(path: &Path) -> Result { + let mut recipe = Recipe::from_file_path(path)?; + resolve_recipe_sub_recipe_paths(&mut recipe, path); + Ok(recipe) +} + +fn resolve_recipe_sub_recipe_paths(recipe: &mut Recipe, recipe_path: &Path) { + let Some(recipe_dir) = recipe_path.parent() else { + return; + }; + + let Some(ref mut sub_recipes) = recipe.sub_recipes else { + return; + }; + + for sub_recipe in sub_recipes.iter_mut() { + if let Ok(resolved) = resolve_sub_recipe_path(&sub_recipe.path, recipe_dir) { + sub_recipe.path = resolved; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn short_id_from_path_is_stable() { + assert_eq!( + short_id_from_path("/tmp/example.yaml"), + short_id_from_path("/tmp/example.yaml") + ); + assert_ne!( + short_id_from_path("/tmp/example.yaml"), + short_id_from_path("/tmp/other.yaml") + ); + } + + #[test] + fn load_recipe_from_path_resolves_sub_recipe_paths() { + let temp_dir = tempfile::tempdir().unwrap(); + let child_path = temp_dir.path().join("child.yaml"); + fs::write( + &child_path, + r#" +title: Child +description: Child recipe +instructions: Child instructions +"#, + ) + .unwrap(); + let parent_path = temp_dir.path().join("parent.yaml"); + fs::write( + &parent_path, + r#" +title: Parent +description: Parent recipe +instructions: Parent instructions +sub_recipes: + - name: child + path: child.yaml +"#, + ) + .unwrap(); + + let recipe = load_recipe_from_path(&parent_path).unwrap(); + let sub_recipes = recipe.sub_recipes.unwrap(); + + assert_eq!( + sub_recipes[0].path, + child_path.to_string_lossy().to_string() + ); + } +} diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 4dc00760..3de1b8dd 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -15,6 +15,7 @@ use utoipa::ToSchema; pub mod build_recipe; pub mod local_recipes; +pub mod manifest; pub mod read_recipe_file_content; mod recipe_extension_adapter; pub mod template_recipe; diff --git a/crates/goose/tests/acp_common_tests/mod.rs b/crates/goose/tests/acp_common_tests/mod.rs index 18976ab7..d2af23d0 100644 --- a/crates/goose/tests/acp_common_tests/mod.rs +++ b/crates/goose/tests/acp_common_tests/mod.rs @@ -16,18 +16,21 @@ use fs_err as fs; use goose::acp::server::AcpProviderFactory; use goose::config::base::CONFIG_YAML_NAME; use goose::config::GooseMode; -use goose::conversation::message::Message; -use goose::model::ModelConfig; -use goose::providers::base::{ - stream_from_single_message, MessageStream, Provider, ProviderUsage, Usage, -}; -use goose::providers::errors::ProviderError; use goose_test_support::{McpFixture, FAKE_CODE, TEST_IMAGE_B64, TEST_MODEL}; use sqlx::sqlite::SqlitePoolOptions; use std::sync::Arc; use std::time::Duration; const SHELL_TEST_CONTENT: &str = "test-shell-content-98765"; +const OPENAI_SESSION_NAME_RESPONSE: &str = r#"data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1766229303,"model":"gpt-5-nano","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} + +data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1766229303,"model":"gpt-5-nano","choices":[{"index":0,"delta":{"content":"Generated Test Title"},"finish_reason":null}]} + +data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1766229303,"model":"gpt-5-nano","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1766229303,"model":"gpt-5-nano","choices":[],"usage":{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110}} + +data: [DONE]"#; struct BasicSession { conn: C, @@ -58,46 +61,6 @@ async fn new_basic_session(config: TestConnectionConfig) -> Basic BasicSession { conn, session } } -struct NamingProvider { - model_config: ModelConfig, -} - -#[async_trait::async_trait] -impl Provider for NamingProvider { - fn get_name(&self) -> &str { - "naming-test" - } - - async fn stream( - &self, - _model_config: &ModelConfig, - _session_id: &str, - system: &str, - _messages: &[Message], - _tools: &[rmcp::model::Tool], - ) -> Result { - let text = if system.contains("four words or less") || system.contains("4 words or less") { - "Generated Test Title" - } else { - "2" - }; - Ok(stream_from_single_message( - Message::assistant().with_text(text), - ProviderUsage::new(self.model_config.model_name.clone(), Usage::default()), - )) - } - - fn get_model_config(&self) -> ModelConfig { - self.model_config.clone() - } -} - -fn naming_provider_factory() -> AcpProviderFactory { - Arc::new(|_provider_name, model_config, _extensions, _working_dir| { - Box::pin(async move { Ok(Arc::new(NamingProvider { model_config }) as Arc) }) - }) -} - pub async fn run_list_sessions() { let BasicSession { conn, session } = new_basic_session::(TestConnectionConfig::default()).await; @@ -119,6 +82,7 @@ pub async fn run_list_sessions() { serde_json::Value::Number(2.into()), ); expected_meta.insert("userSetName".to_string(), serde_json::Value::Bool(false)); + expected_meta.insert("hasRecipe".to_string(), serde_json::Value::Bool(false)); assert_eq!( response, ListSessionsResponse::new(vec![SessionInfo::new( @@ -132,9 +96,21 @@ pub async fn run_list_sessions() { pub async fn run_session_name_update_notification() { let expected_session_id = C::expected_session_id(); - let openai = OpenAiFixture::new(vec![], expected_session_id.clone()).await; + let openai = OpenAiFixture::new( + vec![ + ( + r#"\nwhat should we call this conversation?""#.into(), + include_str!("../acp_test_data/openai_basic.txt"), + ), + ( + "Generate a short title for the above messages.".into(), + OPENAI_SESSION_NAME_RESPONSE, + ), + ], + expected_session_id.clone(), + ) + .await; let config = TestConnectionConfig { - provider_factory: Some(naming_provider_factory()), disable_session_naming: false, ..Default::default() }; @@ -923,6 +899,39 @@ pub async fn run_new_session_returns_initial_config() { assert!(!models.available_models.is_empty()); } +pub async fn run_new_session_uses_current_config_mode() { + let temp_dir = tempfile::tempdir().unwrap(); + let config_path = temp_dir.path().join(goose::config::base::CONFIG_YAML_NAME); + fs::write( + &config_path, + format!("GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nGOOSE_MODE: approve\n"), + ) + .unwrap(); + + let expected_session_id = C::expected_session_id(); + let openai = OpenAiFixture::new(vec![], expected_session_id.clone()).await; + let config = TestConnectionConfig { + goose_mode: GooseMode::Approve, + data_root: temp_dir.path().to_path_buf(), + ..Default::default() + }; + + let mut conn = C::new(config, openai).await; + + let global_config_path = + goose::config::paths::Paths::config_dir().join(goose::config::base::CONFIG_YAML_NAME); + fs::write( + &global_config_path, + format!("GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nGOOSE_MODE: auto\n"), + ) + .unwrap(); + + let SessionData { session, modes, .. } = conn.new_session().await.unwrap(); + expected_session_id.set(&session.session_id().0); + + assert_eq!(modes.unwrap().current_mode_id, SessionModeId::new("auto")); +} + pub async fn run_config_option_model_set() { run_model_set_impl::(SetModelVia::ConfigOption).await; } @@ -1328,11 +1337,11 @@ pub async fn run_prompt_model_mismatch() { // TODO: add a Responses API mock to OpenAiFixture so we can test with // responses-routed models like o4-mini here. let config = TestConnectionConfig { - current_model: "gpt-4.1".to_string(), + current_model: "gpt-4o".to_string(), ..Default::default() }; - // Server starts on gpt-4.1; client is configured with TEST_MODEL. + // Server starts on gpt-4o; client is configured with TEST_MODEL. // If session_model is seeded from the response, stream() detects the // mismatch and sends set_model(TEST_MODEL) before prompting. let BasicSession { conn: _, .. } = new_basic_session::(config).await; diff --git a/crates/goose/tests/acp_custom_requests_test.rs b/crates/goose/tests/acp_custom_requests_test.rs index 24e237e4..659a820e 100644 --- a/crates/goose/tests/acp_custom_requests_test.rs +++ b/crates/goose/tests/acp_custom_requests_test.rs @@ -12,11 +12,29 @@ use goose::model::ModelConfig; use goose::providers::base::{MessageStream, Provider}; use goose::providers::errors::ProviderError; use goose_test_support::{EnforceSessionId, IgnoreSessionId}; +use serial_test::serial; use std::path::PathBuf; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, LazyLock, Mutex}; use common_tests::fixtures::OpenAiFixture; +const DEFAULT_ACP_TEST_CONFIG: &str = "GOOSE_MODEL: gpt-4o\nGOOSE_PROVIDER: openai\n"; + +static ACP_CONFIG_ROOT: LazyLock = + LazyLock::new(|| tempfile::tempdir().unwrap()); + +fn write_acp_global_config(contents: &str) -> PathBuf { + std::env::set_var("GOOSE_PATH_ROOT", ACP_CONFIG_ROOT.path()); + let config_dir = goose::config::paths::Paths::config_dir(); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write( + config_dir.join(goose::config::base::CONFIG_YAML_NAME), + contents, + ) + .unwrap(); + config_dir +} + struct MockProvider { name: String, model_config: ModelConfig, @@ -75,7 +93,9 @@ fn mock_provider_factory() -> AcpProviderFactory { } #[test] +#[serial] fn test_custom_get_tools() { + write_acp_global_config(DEFAULT_ACP_TEST_CONFIG); run_test(async move { let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; let mut conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await; @@ -98,7 +118,9 @@ fn test_custom_get_tools() { } #[test] +#[serial] fn test_custom_get_extensions() { + write_acp_global_config(DEFAULT_ACP_TEST_CONFIG); run_test(async move { let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await; @@ -124,109 +146,9 @@ fn test_custom_get_extensions() { } #[test] -fn test_new_session_passes_cwd_to_provider_factory() { - run_test(async move { - let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; - let cwd = tempfile::tempdir().unwrap(); - let expected_cwd = cwd.path().to_path_buf(); - let captured_cwds = Arc::new(Mutex::new(Vec::>::new())); - let factory_cwds = Arc::clone(&captured_cwds); - let provider_factory: AcpProviderFactory = Arc::new( - move |provider_name, model_config, _extensions, working_dir| { - factory_cwds.lock().unwrap().push(working_dir); - Box::pin(async move { - Ok(Arc::new(MockProvider { - name: provider_name, - model_config, - recommended_models: Vec::new(), - supported_models: Vec::new(), - }) as Arc) - }) - }, - ); - - let mut conn = AcpServerConnection::new( - TestConnectionConfig { - cwd: Some(cwd), - provider_factory: Some(provider_factory), - ..Default::default() - }, - openai, - ) - .await; - - conn.new_session().await.unwrap(); - - let captured_cwd = tokio::time::timeout(std::time::Duration::from_secs(1), async { - loop { - if let Some(cwd) = captured_cwds.lock().unwrap().first().cloned() { - break cwd; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - }) - .await - .expect("provider factory was not called"); - - assert_eq!(captured_cwd, Some(expected_cwd)); - }); -} - -#[test] -fn test_load_session_passes_load_cwd_to_provider_factory() { - run_test(async move { - let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; - let initial_cwd = tempfile::tempdir().unwrap(); - let captured_cwds = Arc::new(Mutex::new(Vec::>::new())); - let factory_cwds = Arc::clone(&captured_cwds); - let provider_factory: AcpProviderFactory = Arc::new( - move |provider_name, model_config, _extensions, working_dir| { - factory_cwds.lock().unwrap().push(working_dir); - Box::pin(async move { - Ok(Arc::new(MockProvider { - name: provider_name, - model_config, - recommended_models: Vec::new(), - supported_models: Vec::new(), - }) as Arc) - }) - }, - ); - - let mut conn = AcpServerConnection::new( - TestConnectionConfig { - cwd: Some(initial_cwd), - provider_factory: Some(provider_factory), - ..Default::default() - }, - openai, - ) - .await; - - let SessionData { session, .. } = conn.new_session().await.unwrap(); - let session_id = session.session_id().0.to_string(); - let SessionData { - session: loaded, .. - } = conn.load_session(&session_id, vec![]).await.unwrap(); - let expected_cwd = loaded.work_dir(); - - let captured_cwd = tokio::time::timeout(std::time::Duration::from_secs(1), async { - loop { - if let Some(cwd) = captured_cwds.lock().unwrap().get(1).cloned() { - break cwd; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - }) - .await - .expect("provider factory was not called for load session"); - - assert_eq!(captured_cwd, Some(expected_cwd)); - }); -} - -#[test] +#[serial] fn test_custom_list_builtin_skill_sources() { + write_acp_global_config(DEFAULT_ACP_TEST_CONFIG); run_test(async move { let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await; @@ -260,7 +182,9 @@ fn test_custom_list_builtin_skill_sources() { } #[test] +#[serial] fn test_custom_provider_inventory_includes_metadata() { + write_acp_global_config(DEFAULT_ACP_TEST_CONFIG); run_test(async { let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await; @@ -291,19 +215,16 @@ fn test_custom_provider_inventory_includes_metadata() { } #[test] +#[serial] fn test_custom_preferences_read_save_remove() { - run_test(async { - let data_root = tempfile::tempdir().unwrap(); - std::fs::write( - data_root - .path() - .join(goose::config::base::CONFIG_YAML_NAME), - "GOOSE_MODEL: gpt-4o\nGOOSE_PROVIDER: openai\nGOOSE_AUTO_COMPACT_THRESHOLD: 0.7\nVOICE_AUTO_SUBMIT_PHRASES: send it\n", - ) - .unwrap(); + let config_dir = write_acp_global_config( + "GOOSE_MODEL: gpt-4o\nGOOSE_PROVIDER: openai\nGOOSE_AUTO_COMPACT_THRESHOLD: 0.7\nVOICE_AUTO_SUBMIT_PHRASES: send it\n", + ); + + run_test(async move { let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; let config = TestConnectionConfig { - data_root: data_root.path().to_path_buf(), + data_root: config_dir, ..Default::default() }; let conn = AcpServerConnection::new(config, openai).await; @@ -373,7 +294,9 @@ fn test_custom_preferences_read_save_remove() { } #[test] +#[serial] fn test_custom_preferences_save_rejects_invalid_values() { + write_acp_global_config(DEFAULT_ACP_TEST_CONFIG); run_test(async { let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await; @@ -433,17 +356,16 @@ fn test_custom_preferences_save_rejects_invalid_values() { } #[test] +#[serial] fn test_custom_defaults_read() { - run_test(async { - let data_root = tempfile::tempdir().unwrap(); - std::fs::write( - data_root.path().join(goose::config::base::CONFIG_YAML_NAME), - "GOOSE_MODEL: claude-3-5-haiku-latest\nGOOSE_PROVIDER: anthropic\n", - ) - .unwrap(); + let config_dir = write_acp_global_config( + "GOOSE_MODEL: claude-3-5-haiku-latest\nGOOSE_PROVIDER: anthropic\n", + ); + + run_test(async move { let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; let config = TestConnectionConfig { - data_root: data_root.path().to_path_buf(), + data_root: config_dir, ..Default::default() }; let conn = AcpServerConnection::new(config, openai).await; @@ -466,21 +388,15 @@ fn test_custom_defaults_read() { } #[test] +#[serial] fn test_custom_dictation_secret_save_delete() { - let root = tempfile::tempdir().unwrap(); - let root_path = root.path().to_string_lossy().to_string(); let _env = env_lock::lock_env([ - ("GOOSE_PATH_ROOT", Some(root_path.as_str())), ("GOOSE_DISABLE_KEYRING", Some("1")), ("GROQ_API_KEY", None::<&str>), ]); - let config_dir = goose::config::paths::Paths::config_dir(); - std::fs::create_dir_all(&config_dir).unwrap(); - std::fs::write( - config_dir.join(goose::config::base::CONFIG_YAML_NAME), + let config_dir = write_acp_global_config( "GOOSE_MODEL: gpt-4o\nGOOSE_PROVIDER: openai\nGOOSE_DISABLE_KEYRING: true\n", - ) - .unwrap(); + ); run_test(async move { let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; @@ -570,7 +486,9 @@ fn test_custom_dictation_secret_save_delete() { } #[test] +#[serial] fn test_raw_config_and_secret_methods_are_removed() { + write_acp_global_config(DEFAULT_ACP_TEST_CONFIG); run_test(async { let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await; @@ -590,7 +508,9 @@ fn test_raw_config_and_secret_methods_are_removed() { } #[test] +#[serial] fn test_provider_switching_updates_session_state() { + write_acp_global_config(DEFAULT_ACP_TEST_CONFIG); run_test(async { let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; let config = TestConnectionConfig { @@ -618,7 +538,9 @@ fn test_provider_switching_updates_session_state() { } #[test] +#[serial] fn test_custom_unknown_method() { + write_acp_global_config(DEFAULT_ACP_TEST_CONFIG); run_test(async { let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await; @@ -629,6 +551,7 @@ fn test_custom_unknown_method() { } #[test] +#[serial] fn test_developer_fs_requests_use_acp_session_id() { run_test(async { let seen_session_id = Arc::new(Mutex::new(None::)); @@ -648,9 +571,14 @@ fn test_developer_fs_requests_use_acp_session_id() { Arc::new(IgnoreSessionId), ) .await; + let config_dir = write_acp_global_config(&format!( + "GOOSE_MODEL: gpt-4.1\nGOOSE_PROVIDER: openai\nOPENAI_HOST: {}\n", + openai.uri() + )); let config = TestConnectionConfig { // gpt-5-nano routes to the Responses API; use a Chat Completions // model so the canned SSE fixtures are parsed correctly. + data_root: config_dir, current_model: "gpt-4.1".to_string(), read_text_file: Some(Arc::new(move |req| { *seen_session_id_clone.lock().unwrap() = Some(req.session_id.0.to_string()); @@ -683,7 +611,9 @@ fn test_developer_fs_requests_use_acp_session_id() { } #[test] +#[serial] fn test_custom_provider_supported_models_lists_raw_provider_models() { + write_acp_global_config(DEFAULT_ACP_TEST_CONFIG); run_test(async move { let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; let provider_factory: AcpProviderFactory = diff --git a/crates/goose/tests/acp_fixtures/mod.rs b/crates/goose/tests/acp_fixtures/mod.rs index 6ba0b05e..55370be5 100644 --- a/crates/goose/tests/acp_fixtures/mod.rs +++ b/crates/goose/tests/acp_fixtures/mod.rs @@ -23,13 +23,31 @@ use goose::session_context::SESSION_ID_HEADER; use goose_test_support::{ExpectedSessionId, TEST_MODEL}; use std::collections::VecDeque; use std::future::Future; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex}; use tokio::task::JoinHandle; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; +static ACP_TEST_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); +static ACP_CONFIG_ROOT: LazyLock = + LazyLock::new(|| tempfile::tempdir().unwrap()); + +fn write_global_test_config(config_path: &Path, openai_base_url: &str) { + let contents = fs::read_to_string(config_path).unwrap(); + let mut config: serde_yaml::Mapping = serde_yaml::from_str(&contents).unwrap(); + config.insert( + serde_yaml::Value::String("OPENAI_HOST".to_string()), + serde_yaml::Value::String(openai_base_url.to_string()), + ); + + let global_config_dir = Paths::config_dir(); + fs::create_dir_all(&global_config_dir).unwrap(); + let global_config_path = global_config_dir.join(goose::config::base::CONFIG_YAML_NAME); + fs::write(&global_config_path, serde_yaml::to_string(&config).unwrap()).unwrap(); +} + pub struct OpenAiFixture { _server: MockServer, base_url: String, @@ -167,10 +185,14 @@ pub async fn spawn_acp_server_in_process( if !config_path.exists() { fs::write( &config_path, - format!("GOOSE_MODEL: {current_model}\nGOOSE_PROVIDER: openai\n"), + format!( + "GOOSE_MODEL: {current_model}\nGOOSE_PROVIDER: openai\nGOOSE_MODE: {}\n", + goose_mode + ), ) .unwrap(); } + write_global_test_config(&config_path, openai_base_url); let provider_factory = provider_factory.unwrap_or_else(|| { let base_url = openai_base_url.to_string(); Arc::new( @@ -195,7 +217,6 @@ pub async fn spawn_acp_server_in_process( builtins: builtins.to_vec(), data_dir: data_root.to_path_buf(), config_dir: data_root.to_path_buf(), - goose_mode, disable_session_naming, goose_platform: GoosePlatform::GooseCli, additional_source_roots: Vec::new(), @@ -585,6 +606,10 @@ pub fn run_test(fut: F) where F: Future + Send + 'static, { + let _guard = ACP_TEST_LOCK.lock().unwrap_or_else(|err| err.into_inner()); + if std::env::var_os("GOOSE_PATH_ROOT").is_none() { + std::env::set_var("GOOSE_PATH_ROOT", ACP_CONFIG_ROOT.path()); + } register_builtin_extensions(goose_mcp::BUILTIN_EXTENSIONS.clone()); let handle = std::thread::Builder::new() diff --git a/crates/goose/tests/acp_server_test.rs b/crates/goose/tests/acp_server_test.rs index 6faca600..22cbfa32 100644 --- a/crates/goose/tests/acp_server_test.rs +++ b/crates/goose/tests/acp_server_test.rs @@ -14,9 +14,10 @@ use common_tests::{ run_load_mode, run_load_model, run_load_session_error, run_load_session_mcp, run_load_session_replays_image_attachment, run_mode_set, run_model_list, run_model_set, run_model_set_error_session_not_found, run_new_session_returns_initial_config, - run_permission_persistence, run_prompt_basic, run_prompt_error, run_prompt_image, - run_prompt_image_attachment, run_prompt_mcp, run_prompt_model_mismatch, run_prompt_skill, - run_session_name_update_notification, run_shell_terminal_false, run_shell_terminal_true, + run_new_session_uses_current_config_mode, run_permission_persistence, run_prompt_basic, + run_prompt_error, run_prompt_image, run_prompt_image_attachment, run_prompt_mcp, + run_prompt_model_mismatch, run_prompt_skill, run_session_name_update_notification, + run_shell_terminal_false, run_shell_terminal_true, }; use goose::config::GooseMode; use goose::conversation::message::Message; @@ -240,6 +241,11 @@ fn test_new_session_returns_initial_config() { run_test(async { run_new_session_returns_initial_config::().await }); } +#[test] +fn test_new_session_uses_current_config_mode() { + run_test(async { run_new_session_uses_current_config_mode::().await }); +} + #[test] fn test_model_set() { run_test(async { run_model_set::().await }); diff --git a/ui/sdk/generate-schema.ts b/ui/sdk/generate-schema.ts index 1d7c4858..66d7ed67 100644 --- a/ui/sdk/generate-schema.ts +++ b/ui/sdk/generate-schema.ts @@ -71,7 +71,10 @@ async function postProcessTypes() { await fs.writeFile(tsPath, src); } -async function postProcessIndex(meta: { methods: unknown[] }) { +async function postProcessIndex(meta: { + methods: unknown[]; + notifications?: unknown[]; +}) { const indexPath = resolve(OUTPUT_DIR, "index.ts"); let src = await fs.readFile(indexPath, "utf8"); @@ -88,6 +91,10 @@ async function postProcessIndex(meta: { methods: unknown[] }) { export const GOOSE_EXT_METHODS = ${JSON.stringify(meta.methods, null, 2)} as const; export type GooseExtMethod = (typeof GOOSE_EXT_METHODS)[number]; + +export const GOOSE_EXT_NOTIFICATIONS = ${JSON.stringify(meta.notifications ?? [], null, 2)} as const; + +export type GooseExtNotification = (typeof GOOSE_EXT_NOTIFICATIONS)[number]; `, { parser: "typescript" }, ); @@ -126,6 +133,32 @@ interface MethodMeta { responseType: string | null; } +interface NotificationMeta { + method: string; + paramsType: string | null; +} + +function methodToHandlerName(method: string): string { + let methodParts = method.split(/[/_]/).filter((part) => part.length > 0); + let prefix = ""; + if (methodParts[0] == "goose" && methodParts[1] == "unstable") { + methodParts.shift(); + methodParts.shift(); + prefix = "unstable_"; + } else if (methodParts[0] == "goose") { + methodParts.shift(); + } + const body = methodParts + .map((part) => + part.replace(/[^a-zA-Z0-9]+(.)/g, (_, chr: string) => chr.toUpperCase()), + ) + .map((part, i) => + i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1), + ) + .join(""); + return `${prefix}${body}`; +} + function methodToCamelCase(method: string): string { let methodParts = method.split(/[/_]/).filter((part) => part.length > 0); @@ -150,9 +183,13 @@ function methodToCamelCase(method: string): string { return `${prefix}${suffix}`; } -async function generateClient(meta: { methods: MethodMeta[] }) { +async function generateClient(meta: { + methods: MethodMeta[]; + notifications?: NotificationMeta[]; +}) { const typeImports = new Set(); const zodImports = new Set(); + const upstreamTypeImports = new Set(["Client"]); const methodDefs: string[] = []; @@ -200,6 +237,67 @@ async function generateClient(meta: { methods: MethodMeta[] }) { }`); } + const handlerFields: string[] = []; + const dispatchCases: string[] = []; + const handlerKeys: string[] = []; + + for (const n of meta.notifications ?? []) { + const handlerName = methodToHandlerName(n.method); + handlerKeys.push(handlerName); + if (!n.paramsType) { + handlerFields.push( + ` ${handlerName}?: (params: Record) => Promise;`, + ); + dispatchCases.push( + ` case "${n.method}": { + await ${handlerName}?.(params); + return; + }`, + ); + continue; + } + typeImports.add(n.paramsType); + const zodName = `z${n.paramsType}`; + zodImports.add(zodName); + handlerFields.push( + ` ${handlerName}?: (notification: ${n.paramsType}) => Promise;`, + ); + dispatchCases.push( + ` case "${n.method}": { + const parsed = ${zodName}.parse(params) as ${n.paramsType}; + await ${handlerName}?.(parsed); + return; + }`, + ); + } + + const handlerDestructure = + handlerKeys.length > 0 + ? `const { ${handlerKeys.join(", ")}, ...rest } = callbacks;` + : `const rest = callbacks;`; + const handlersInterface = `export interface GooseExtNotifications { +${handlerFields.join("\n")} +}`; + + const dispatcherFn = `export function installGooseExtNotificationDispatcher( + callbacks: GooseClientCallbacks, +): Client { + ${handlerDestructure} + const userExtNotification = rest.extNotification; + return { + ...rest, + extNotification: async (method, params) => { + switch (method) { +${dispatchCases.join("\n")} + default: + await userExtNotification?.(method, params); + return; + } + }, + }; +}`; + + const upstreamImportLine = `import type { ${[...upstreamTypeImports].sort().join(", ")} } from "@agentclientprotocol/sdk";`; const typeImportLine = typeImports.size ? `import type { ${[...typeImports].sort().join(", ")} } from "./types.gen.js";` : ""; @@ -213,6 +311,7 @@ export interface ExtMethodProvider { extMethod(method: string, params: Record): Promise>; } +${upstreamImportLine} ${typeImportLine} ${zodImportLine} @@ -220,6 +319,12 @@ export class GooseExtClient { constructor(private conn: ExtMethodProvider) {} ${methodDefs.join("\n")} } + +${handlersInterface} + +export type GooseClientCallbacks = Client & GooseExtNotifications; + +${dispatcherFn} `; src = await prettier.format(src, { parser: "typescript" }); diff --git a/ui/sdk/src/generated/client.gen.ts b/ui/sdk/src/generated/client.gen.ts index 22beacb4..0d332565 100644 --- a/ui/sdk/src/generated/client.gen.ts +++ b/ui/sdk/src/generated/client.gen.ts @@ -7,6 +7,7 @@ export interface ExtMethodProvider { ): Promise>; } +import type { Client } from "@agentclientprotocol/sdk"; import type { AddConfigExtensionRequest_unstable, AddExtensionRequest_unstable, @@ -40,6 +41,7 @@ import type { DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, + ElicitationRespondRequest_unstable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, @@ -50,6 +52,7 @@ import type { GetSessionExtensionsResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, + GooseSessionNotification_unstable, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, ImportSessionRequest_unstable, @@ -115,6 +118,7 @@ import { zGetExtensionsResponse_unstable, zGetSessionExtensionsResponse_unstable, zGetToolsResponse_unstable, + zGooseSessionNotification_unstable, zGooseToolCallResponse_unstable, zImportSessionResponse_unstable, zImportSourcesResponse_unstable, @@ -527,6 +531,12 @@ export class GooseExtClient { ) as ImportSessionResponse_unstable; } + async elicitationRespond_unstable( + params: ElicitationRespondRequest_unstable, + ): Promise { + await this.conn.extMethod("_goose/unstable/elicitation/respond", params); + } + async sessionProjectUpdate_unstable( params: UpdateSessionProjectRequest_unstable, ): Promise { @@ -716,3 +726,35 @@ export class GooseExtClient { ); } } + +export interface GooseExtNotifications { + unstable_sessionUpdate?: ( + notification: GooseSessionNotification_unstable, + ) => Promise; +} + +export type GooseClientCallbacks = Client & GooseExtNotifications; + +export function installGooseExtNotificationDispatcher( + callbacks: GooseClientCallbacks, +): Client { + const { unstable_sessionUpdate, ...rest } = callbacks; + const userExtNotification = rest.extNotification; + return { + ...rest, + extNotification: async (method, params) => { + switch (method) { + case "_goose/unstable/session/update": { + const parsed = zGooseSessionNotification_unstable.parse( + params, + ) as GooseSessionNotification_unstable; + await unstable_sessionUpdate?.(parsed); + return; + } + default: + await userExtNotification?.(method, params); + return; + } + }, + }; +} diff --git a/ui/sdk/src/generated/index.ts b/ui/sdk/src/generated/index.ts index 85a63bc0..6e348832 100644 --- a/ui/sdk/src/generated/index.ts +++ b/ui/sdk/src/generated/index.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { AddConfigExtensionRequest_unstable, AddExtensionRequest_unstable, ArchiveSessionRequest_unstable, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, EmptyResponse, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtRequest, ExtResponse, GetExtensionsRequest_unstable, GetExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveExtensionRequest_unstable, RenameSessionRequest_unstable, SessionSystemPromptMode, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, ToggleConfigExtensionRequest_unstable, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; +export type { AddConfigExtensionRequest_unstable, AddExtensionRequest_unstable, ArchiveSessionRequest_unstable, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, ElicitationRespondRequest_unstable, EmptyResponse, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtNotification, ExtRequest, ExtResponse, GetExtensionsRequest_unstable, GetExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, Interaction, InteractionState, InteractionUpdate, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveExtensionRequest_unstable, RenameSessionRequest_unstable, SessionSystemPromptMode, SessionUsageUpdate, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, ToggleConfigExtensionRequest_unstable, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; export const GOOSE_EXT_METHODS = [ { @@ -188,6 +188,11 @@ export const GOOSE_EXT_METHODS = [ requestType: "ImportSessionRequest_unstable", responseType: "ImportSessionResponse_unstable", }, + { + method: "_goose/unstable/elicitation/respond", + requestType: "ElicitationRespondRequest_unstable", + responseType: "EmptyResponse", + }, { method: "_goose/unstable/session/project/update", requestType: "UpdateSessionProjectRequest_unstable", @@ -291,3 +296,12 @@ export const GOOSE_EXT_METHODS = [ ] as const; export type GooseExtMethod = (typeof GOOSE_EXT_METHODS)[number]; + +export const GOOSE_EXT_NOTIFICATIONS = [ + { + method: "_goose/unstable/session/update", + paramsType: "GooseSessionNotification_unstable", + }, +] as const; + +export type GooseExtNotification = (typeof GOOSE_EXT_NOTIFICATIONS)[number]; diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index 410f74ad..cfec115f 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -743,6 +743,15 @@ export type ImportSessionResponse_unstable = { messageCount: number; }; +/** + * Submit a response for a pending MCP elicitation in an active session. + */ +export type ElicitationRespondRequest_unstable = { + sessionId: string; + elicitationId: string; + userData?: unknown; +}; + /** * Update the project association for a session. */ @@ -1087,10 +1096,77 @@ export type DictationModelSelectRequest_unstable = { modelId: string; }; +/** + * Goose-custom session update notification — a parallel to ACP's + * `session/update` carrying goose-specific update variants. + */ +export type GooseSessionNotification_unstable = { + sessionId: string; + update: GooseSessionUpdate; +}; + +/** + * Discriminated union of goose-specific session update payloads. + * Variant tag matches ACP's convention (`sessionUpdate: ""`). + * + * `discriminator.mapping` is what makes TS codegen (`@hey-api/openapi-ts`) + * emit the correct snake_case tag value even when this enum has a single + * variant. Add a mapping entry per variant. + */ +export type GooseSessionUpdate = ({ + sessionUpdate: 'usage_update'; +} & SessionUsageUpdate) | ({ + sessionUpdate: 'status_message'; +} & StatusMessageUpdate) | ({ + sessionUpdate: 'interaction_update'; +} & InteractionUpdate); + +/** + * Streaming context-window usage update for a session. + */ +export type SessionUsageUpdate = { + used: number; + contextLimit: number; + accumulatedInputTokens: number; + accumulatedOutputTokens: number; + accumulatedCost?: number | null; +}; + +export type StatusMessage = { + message: string; + type: 'notice'; +} | { + message: string; + type: 'progress'; +}; + +/** + * Live UI/session status. This is not conversation transcript content, and + * should not be persisted or replayed as history. + */ +export type StatusMessageUpdate = { + status: StatusMessage; +}; + +export type Interaction = { + id: string; + state: InteractionState; + message?: string | null; + requestedSchema?: unknown; + type: 'elicitation'; +}; + +export type InteractionState = 'pending' | 'submitted'; + +export type InteractionUpdate = { + interaction: Interaction; + _meta?: unknown; +}; + export type ExtRequest = { id: string; method: string; - params?: AddExtensionRequest_unstable | RemoveExtensionRequest_unstable | GetToolsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | DeleteSessionRequest | GetExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | ToggleConfigExtensionRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | { + params?: AddExtensionRequest_unstable | RemoveExtensionRequest_unstable | GetToolsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | DeleteSessionRequest | GetExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | ToggleConfigExtensionRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | ElicitationRespondRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | { [key: string]: unknown; } | null; }; @@ -1106,3 +1182,10 @@ export type ExtResponse = { }; id: string; }; + +export type ExtNotification = { + method: string; + params?: GooseSessionNotification_unstable | { + [key: string]: unknown; + } | null; +}; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index efebe29b..0597c7f7 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -760,6 +760,15 @@ export const zImportSessionResponse_unstable = z.object({ messageCount: z.number().int().gte(0) }); +/** + * Submit a response for a pending MCP elicitation in an active session. + */ +export const zElicitationRespondRequest_unstable = z.object({ + sessionId: z.string(), + elicitationId: z.string(), + userData: z.unknown().optional().default(null) +}); + /** * Update the project association for a session. */ @@ -1088,6 +1097,86 @@ export const zDictationModelSelectRequest_unstable = z.object({ modelId: z.string() }); +/** + * Streaming context-window usage update for a session. + */ +export const zSessionUsageUpdate = z.object({ + used: z.number().int().gte(0), + contextLimit: z.number().int().gte(0), + accumulatedInputTokens: z.number().int().gte(0), + accumulatedOutputTokens: z.number().int().gte(0), + accumulatedCost: z.union([ + z.number(), + z.null() + ]).optional() +}); + +export const zStatusMessage = z.union([ + z.object({ + message: z.string(), + type: z.literal('notice') + }), + z.object({ + message: z.string(), + type: z.literal('progress') + }) +]); + +/** + * Live UI/session status. This is not conversation transcript content, and + * should not be persisted or replayed as history. + */ +export const zStatusMessageUpdate = z.object({ + status: zStatusMessage +}); + +export const zInteractionState = z.enum(['pending', 'submitted']); + +export const zInteraction = z.object({ + id: z.string(), + state: zInteractionState, + message: z.union([ + z.string(), + z.null() + ]).optional(), + requestedSchema: z.unknown().optional(), + type: z.literal('elicitation') +}); + +export const zInteractionUpdate = z.object({ + interaction: zInteraction, + _meta: z.unknown().optional() +}); + +/** + * Discriminated union of goose-specific session update payloads. + * Variant tag matches ACP's convention (`sessionUpdate: ""`). + * + * `discriminator.mapping` is what makes TS codegen (`@hey-api/openapi-ts`) + * emit the correct snake_case tag value even when this enum has a single + * variant. Add a mapping entry per variant. + */ +export const zGooseSessionUpdate = z.union([ + z.object({ + sessionUpdate: z.literal('usage_update') + }).and(zSessionUsageUpdate), + z.object({ + sessionUpdate: z.literal('status_message') + }).and(zStatusMessageUpdate), + z.object({ + sessionUpdate: z.literal('interaction_update') + }).and(zInteractionUpdate) +]); + +/** + * Goose-custom session update notification — a parallel to ACP's + * `session/update` carrying goose-specific update variants. + */ +export const zGooseSessionNotification_unstable = z.object({ + sessionId: z.string(), + update: zGooseSessionUpdate +}); + export const zExtRequest = z.object({ id: z.string(), method: z.string(), @@ -1130,6 +1219,7 @@ export const zExtRequest = z.object({ zOnboardingImportApplyRequest_unstable, zExportSessionRequest_unstable, zImportSessionRequest_unstable, + zElicitationRespondRequest_unstable, zUpdateSessionProjectRequest_unstable, zRenameSessionRequest_unstable, zArchiveSessionRequest_unstable, @@ -1210,3 +1300,14 @@ export const zExtResponse = z.union([ id: z.string() }) ]); + +export const zExtNotification = z.object({ + method: z.string(), + params: z.union([ + zGooseSessionNotification_unstable, + z.union([ + z.record(z.unknown()), + z.null() + ]) + ]).optional() +}); diff --git a/ui/sdk/src/goose-client.ts b/ui/sdk/src/goose-client.ts index c697dfcc..f969f7a5 100644 --- a/ui/sdk/src/goose-client.ts +++ b/ui/sdk/src/goose-client.ts @@ -1,6 +1,5 @@ import { ClientSideConnection, - type Client, type Stream, type InitializeRequest, type InitializeResponse, @@ -26,19 +25,28 @@ import { type SetSessionModelRequest, type SetSessionModelResponse, } from "@agentclientprotocol/sdk"; -import { GooseExtClient } from "./generated/client.gen.js"; +import { + GooseExtClient, + installGooseExtNotificationDispatcher, + type GooseClientCallbacks, +} from "./generated/client.gen.js"; import { createHttpStream } from "./http-stream.js"; export class GooseClient { private conn: ClientSideConnection; private ext: GooseExtClient; - constructor(toClient: () => Client, streamOrUrl: Stream | string) { + constructor( + toClient: () => GooseClientCallbacks, + streamOrUrl: Stream | string, + ) { const stream = typeof streamOrUrl === "string" ? createHttpStream(streamOrUrl) : streamOrUrl; - this.conn = new ClientSideConnection(toClient, stream); + const toAcpClient = () => + installGooseExtNotificationDispatcher(toClient()); + this.conn = new ClientSideConnection(toAcpClient, stream); this.ext = new GooseExtClient(this.conn); } diff --git a/ui/sdk/src/index.ts b/ui/sdk/src/index.ts index 5e587ed9..aa4cbe96 100644 --- a/ui/sdk/src/index.ts +++ b/ui/sdk/src/index.ts @@ -1,5 +1,9 @@ export * from "./generated/types.gen.js"; export * from "./generated/zod.gen.js"; +export { + type GooseClientCallbacks, + type GooseExtNotifications, +} from "./generated/client.gen.js"; export { GooseClient } from "./goose-client.js"; export { createHttpStream } from "./http-stream.js"; export * from "./mcp-apps.js";