Lifei/acp session setup refactor (#9488)

This commit is contained in:
Lifei Zhou
2026-06-04 13:12:04 +10:00
committed by GitHub
parent 1cc5aa690a
commit dc59e41945
38 changed files with 3096 additions and 1980 deletions
@@ -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: "<snake_case>"`).
///
/// `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<f64>,
}
/// 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<serde_json::Value>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
requested_schema: Option<serde_json::Value>,
},
}
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<T>(generator: &mut SchemaGenerator) -> CustomMethodSchema
where
T: Default + JsonRpcMessage + JsonSchema,
{
let dummy = T::default();
let type_name = std::any::type_name::<T>()
.rsplit("::")
.next()
.unwrap_or(std::any::type_name::<T>())
.to_string();
CustomMethodSchema {
method: dummy.method().to_string(),
params_schema: Some(generator.subschema_for::<T>()),
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<CustomMethodSchema> {
vec![notification_schema::<GooseSessionNotification>(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"
}
}
})
);
}
}
+11
View File
@@ -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 {
+1
View File
@@ -1 +1,2 @@
pub mod custom_notifications;
pub mod custom_requests;
+15 -56
View File
@@ -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<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 get_all_recipes_manifests() -> Result<Vec<RecipeManifest>> {
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::<chrono::Utc>::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<Recipe, ErrorResponse> {
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(
+11
View File
@@ -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"
}
]
}
+278
View File
@@ -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: \"<snake_case>\"`).\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"
}
]
}
+2
View File
@@ -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,
+400
View File
@@ -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::<Vec<_>>();
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<ProviderOptionEntry> {
let mut providers = crate::providers::providers()
.await
.into_iter()
.map(|(metadata, _)| ProviderOptionEntry {
id: metadata.name,
label: metadata.display_name,
})
.collect::<Vec<_>>();
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<SessionConfigSelectOption> {
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<SessionModeState, agent_client_protocol::Error> {
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<SessionModelState>,
Option<Vec<SessionConfigOption>>,
),
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<SessionConfigSelectOption>,
) -> Vec<SessionConfigOption> {
let mode_options: Vec<SessionConfigSelectOption> = 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<SessionConfigSelectOption> = 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<Client>,
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<String>) -> 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<SessionModeState, agent_client_protocol::Error> {
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<SessionConfigSelectOption>,
model_state: SessionModelState,
) -> Vec<SessionConfigOption> {
build_config_options(&mode_state, &model_state, provider_name, provider_options)
}
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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 {
@@ -306,6 +306,15 @@ impl GooseAcpAgent {
self.on_import_session(req).await
}
#[custom_method(ElicitationRespondRequest)]
async fn dispatch_elicitation_respond(
&self,
_req: ElicitationRespondRequest,
) -> Result<EmptyResponse, agent_client_protocol::Error> {
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,
+20
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::providers::inventory::ensure_refresh_identity_current;
impl HandleDispatchFrom<Client> for GooseAcpHandler {
fn describe_chain(&self) -> impl std::fmt::Debug {
@@ -16,6 +17,12 @@ impl HandleDispatchFrom<Client> 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<Client> for GooseAcpHandler {
Ok(())
})
.await
.if_request({
let agent = agent.clone();
let cx = cx.clone();
|req: ElicitationRespondRequest, responder: Responder<EmptyResponse>| 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();
@@ -0,0 +1,72 @@
use super::*;
impl GooseAcpAgent {
#[allow(dead_code)]
pub(super) async fn handle_fork_session(
&self,
cx: &ConnectionTo<Client>,
args: ForkSessionRequest,
) -> Result<ForkSessionResponse, agent_client_protocol::Error> {
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)
}
}
+288
View File
@@ -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::<Vec<_>>(),
)
}
fn send_replay_content_chunk(
cx: &ConnectionTo<Client>,
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<Client>,
session: &Session,
) -> Result<HashMap<String, crate::conversation::message::ToolRequest>, 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::<String, crate::conversation::message::ToolRequest>::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<String> {
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<Client>,
args: LoadSessionRequest,
) -> Result<LoadSessionResponse, agent_client_protocol::Error> {
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)
}
}
@@ -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 {})
}
+108
View File
@@ -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<Client>,
args: NewSessionRequest,
) -> Result<NewSessionResponse, agent_client_protocol::Error> {
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)
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ impl GooseAcpAgent {
) -> Result<OnboardingImportApplyResponse, agent_client_protocol::Error> {
let config = self.config()?;
Ok(apply_onboarding_import_candidates(
&config,
config,
&self.config_dir,
&req,
))
+1
View File
@@ -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 {
+1 -10
View File
@@ -23,15 +23,7 @@ impl AcpServer {
}
pub async fn create_agent(&self) -> Result<Arc<GooseAcpAgent>> {
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(),
+10 -4
View File
@@ -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| {
+21 -9
View File
@@ -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<Option<Message>> {
@@ -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<Option<Message>> {
@@ -425,9 +419,14 @@ impl Agent {
}
}
fn user_only_assistant_text(text: impl Into<String>) -> 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"
));
}
}
+55 -2
View File
@@ -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<String, Value> = 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<String, Vec<String>> = 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<Value> = Vec::new();
let mut response_variants: Vec<Value> = Vec::new();
let mut notification_variants: Vec<Value> = Vec::new();
let mut seen_response_types: BTreeSet<String> = BTreeSet::new();
for m in &methods {
@@ -113,6 +116,17 @@ fn main() {
}
}
for n in &notifications {
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<Value> = 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");
+111 -61
View File
@@ -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<Arc<AgentManager>> = OnceCell::const_new();
#[derive(Clone, Default)]
pub struct RuntimeContext {
pub mcp_host_info: Option<GooseMcpHostInfo>,
pub use_login_shell_path: Option<bool>,
pub session_name_update_tx: Option<mpsc::UnboundedSender<SessionNameUpdate>>,
}
pub struct AgentManagerGetResult {
pub agent: Arc<Agent>,
pub agent_created: bool,
pub extension_results: Vec<ExtensionLoadResult>,
}
pub struct AgentManager {
sessions: Arc<RwLock<LruCache<String, Arc<Agent>>>>,
scheduler: Arc<dyn SchedulerTrait>,
session_manager: Arc<SessionManager>,
agent_config: AgentConfig,
default_provider: Arc<RwLock<Option<Arc<dyn crate::providers::base::Provider>>>>,
default_mode: GooseMode,
cancel_tokens: Arc<RwLock<HashMap<String, CancellationToken>>>,
/// 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<SessionManager>,
schedule_file_path: std::path::PathBuf,
max_sessions: Option<usize>,
default_mode: GooseMode,
) -> Result<Self> {
let scheduler = Scheduler::new(schedule_file_path, session_manager.clone()).await?;
pub async fn new(agent_config: AgentConfig, max_sessions: Option<usize>) -> Result<Self> {
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<dyn SchedulerTrait>)?;
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<dyn SchedulerTrait> {
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<dyn crate::providers::base::Provider>) {
@@ -99,11 +112,26 @@ impl AgentManager {
}
pub async fn get_or_create_agent(&self, session_id: String) -> Result<Arc<Agent>> {
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<AgentManagerGetResult> {
// 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<Arc<Agent>> {
async fn create_agent_locked(
&self,
session_id: &str,
runtime_context: RuntimeContext,
) -> Result<AgentManagerGetResult> {
// 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();
+1
View File
@@ -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;
+133 -1
View File
@@ -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<ProviderInventoryEntry> {
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<Vec<ProviderInventoryEntry>> {
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<dyn Provider>,
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<Vec<String>> =
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,
+140
View File
@@ -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<Vec<RecipeFileManifest>> {
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::<chrono::Utc>::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<PathBuf> {
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<Recipe> {
let path = get_recipe_file_path_by_id(id)?;
load_recipe_from_path(&path)
}
pub fn load_recipe_from_path(path: &Path) -> Result<Recipe> {
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()
);
}
}
+1
View File
@@ -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;
+59 -50
View File
@@ -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<C: Connection> {
conn: C,
@@ -58,46 +61,6 @@ async fn new_basic_session<C: Connection>(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<MessageStream, ProviderError> {
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<dyn Provider>) })
})
}
pub async fn run_list_sessions<C: Connection>() {
let BasicSession { conn, session } =
new_basic_session::<C>(TestConnectionConfig::default()).await;
@@ -119,6 +82,7 @@ pub async fn run_list_sessions<C: Connection>() {
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<C: Connection>() {
pub async fn run_session_name_update_notification<C: Connection>() {
let expected_session_id = C::expected_session_id();
let openai = OpenAiFixture::new(vec![], expected_session_id.clone()).await;
let openai = OpenAiFixture::new(
vec![
(
r#"</info-msg>\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<C: Connection>() {
assert!(!models.available_models.is_empty());
}
pub async fn run_new_session_uses_current_config_mode<C: Connection>() {
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<C: Connection>() {
run_model_set_impl::<C>(SetModelVia::ConfigOption).await;
}
@@ -1328,11 +1337,11 @@ pub async fn run_prompt_model_mismatch<C: Connection>() {
// 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::<C>(config).await;
+60 -130
View File
@@ -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<tempfile::TempDir> =
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::<Option<PathBuf>>::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<dyn Provider>)
})
},
);
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::<Option<PathBuf>>::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<dyn Provider>)
})
},
);
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::<String>));
@@ -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 =
+29 -4
View File
@@ -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<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
static ACP_CONFIG_ROOT: LazyLock<tempfile::TempDir> =
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<F>(fut: F)
where
F: Future<Output = ()> + 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()
+9 -3
View File
@@ -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::<AcpServerConnection>().await });
}
#[test]
fn test_new_session_uses_current_config_mode() {
run_test(async { run_new_session_uses_current_config_mode::<AcpServerConnection>().await });
}
#[test]
fn test_model_set() {
run_test(async { run_model_set::<AcpServerConnection>().await });
+107 -2
View File
@@ -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<string>();
const zodImports = new Set<string>();
const upstreamTypeImports = new Set<string>(["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<string, unknown>) => Promise<void>;`,
);
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<void>;`,
);
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<string, unknown>): Promise<Record<string, unknown>>;
}
${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" });
+42
View File
@@ -7,6 +7,7 @@ export interface ExtMethodProvider {
): Promise<Record<string, unknown>>;
}
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<void> {
await this.conn.extMethod("_goose/unstable/elicitation/respond", params);
}
async sessionProjectUpdate_unstable(
params: UpdateSessionProjectRequest_unstable,
): Promise<void> {
@@ -716,3 +726,35 @@ export class GooseExtClient {
);
}
}
export interface GooseExtNotifications {
unstable_sessionUpdate?: (
notification: GooseSessionNotification_unstable,
) => Promise<void>;
}
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;
}
},
};
}
+15 -1
View File
@@ -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];
+84 -1
View File
@@ -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: "<snake_case>"`).
*
* `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;
};
+101
View File
@@ -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: "<snake_case>"`).
*
* `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()
});
+12 -4
View File
@@ -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);
}
+4
View File
@@ -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";