diff --git a/.github/workflows/pr-website-preview.yml b/.github/workflows/pr-website-preview.yml index b7db3e4d6548..97d001b91e82 100644 --- a/.github/workflows/pr-website-preview.yml +++ b/.github/workflows/pr-website-preview.yml @@ -17,6 +17,10 @@ jobs: build: if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.PAGES_PR_PREVIEW_CF_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.PAGES_PR_PREVIEW_CF_API_TOKEN }} + CLOUDFLARE_PAGES_PROJECT_NAME: ${{ secrets.PAGES_PR_PREVIEW_CF_PAGES_PROJECT_NAME }} permissions: contents: read pull-requests: write @@ -52,12 +56,10 @@ jobs: node-version: 22 - name: Deploy preview to Cloudflare Pages + if: env.CLOUDFLARE_ACCOUNT_ID != '' && env.CLOUDFLARE_API_TOKEN != '' && env.CLOUDFLARE_PAGES_PROJECT_NAME != '' id: cloudflare-pages working-directory: ./documentation env: - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.PAGES_PR_PREVIEW_CF_ACCOUNT_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.PAGES_PR_PREVIEW_CF_API_TOKEN }} - CLOUDFLARE_PAGES_PROJECT_NAME: ${{ secrets.PAGES_PR_PREVIEW_CF_PAGES_PROJECT_NAME }} PR_NUMBER: ${{ github.event.number }} run: | set -euo pipefail @@ -76,6 +78,7 @@ jobs: echo "preview-url=$preview_url" >> "$GITHUB_OUTPUT" - name: Comment preview URL + if: steps.cloudflare-pages.outputs.preview-url != '' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: PREVIEW_URL: ${{ steps.cloudflare-pages.outputs.preview-url }} diff --git a/Cargo.lock b/Cargo.lock index 6ce7a570c9ed..e6b6caf14421 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4633,6 +4633,7 @@ dependencies = [ "smithy-transport-reqwest", "sqlx", "strum 0.28.0", + "subtle", "symphonia", "sys-info", "tempfile", @@ -4644,6 +4645,7 @@ dependencies = [ "tokio-cron-scheduler", "tokio-stream", "tokio-util", + "tower", "tower-http", "tracing", "tracing-appender", @@ -4847,7 +4849,6 @@ dependencies = [ "serde_path_to_error", "serde_yaml", "socket2", - "subtle", "thiserror 1.0.69", "tokio", "tokio-stream", diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 6bf45d6da2f0..7ef8d9134994 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -1326,7 +1326,7 @@ async fn handle_serve_command(host: String, port: u16, builtins: Vec) -> use goose::config::paths::Paths; use std::net::SocketAddr; use std::sync::Arc; - use tracing::info; + use tracing::{info, warn}; let builtins = if builtins.is_empty() { vec!["developer".to_string()] @@ -1353,12 +1353,18 @@ async fn handle_serve_command(host: String, port: u16, builtins: Vec) -> goose_platform: GoosePlatform::GooseCli, additional_source_roots, })); - let secret_key = std::env::var(GOOSE_SERVER_SECRET_KEY_ENV) + let env_secret = std::env::var(GOOSE_SERVER_SECRET_KEY_ENV) .ok() .map(|secret| secret.trim().to_string()) - .filter(|secret| !secret.is_empty()) - .unwrap_or_else(generate_serve_secret_key); - let router = create_router(server, secret_key); + .filter(|secret| !secret.is_empty()); + let require_token = env_secret.is_some(); + if !require_token { + warn!( + "{GOOSE_SERVER_SECRET_KEY_ENV} is not set; the ACP endpoint will accept unauthenticated connections" + ); + } + let secret_key = env_secret.unwrap_or_else(generate_serve_secret_key); + let router = create_router(server, secret_key, require_token); let addr: SocketAddr = format!("{}:{}", host, port).parse()?; info!("Starting ACP server on {}", addr); diff --git a/crates/goose-providers/src/conversation/message.rs b/crates/goose-providers/src/conversation/message.rs index a89d4760b1e5..5b0a1d047dc7 100644 --- a/crates/goose-providers/src/conversation/message.rs +++ b/crates/goose-providers/src/conversation/message.rs @@ -667,6 +667,11 @@ pub struct MessageMetadata { pub agent_visible: bool, #[serde(skip_serializing_if = "Option::is_none")] pub inference: Option, + /// Whether this message is a steer injected into an active run. UI-only: + /// surfaced as `_meta.goose.steer` so clients can mark the steer boundary + /// without matching user-visible text. Never sent to providers. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub steer: bool, } impl Default for MessageMetadata { @@ -675,6 +680,7 @@ impl Default for MessageMetadata { user_visible: true, agent_visible: true, inference: None, + steer: false, } } } @@ -743,6 +749,11 @@ impl MessageMetadata { self.inference = Some(inference); self } + + pub fn with_steer(mut self) -> Self { + self.steer = true; + self + } } #[derive(ToSchema, Clone, PartialEq, Serialize, Deserialize, Debug)] @@ -1028,6 +1039,11 @@ impl Message { self } + pub fn with_steer(mut self) -> Self { + self.metadata.steer = true; + self + } + pub fn with_inference_if_assistant(self, inference: InferenceMetadata) -> Self { if self.role == Role::Assistant && self.metadata.inference.is_none() { self.with_inference(inference) diff --git a/crates/goose-providers/src/errors.rs b/crates/goose-providers/src/errors.rs index 46a2aa4ca051..c5f60dd78b89 100644 --- a/crates/goose-providers/src/errors.rs +++ b/crates/goose-providers/src/errors.rs @@ -42,6 +42,12 @@ pub enum ProviderError { details: String, top_up_url: Option, }, + + #[error("Provider refused request: {details}")] + Refusal { + details: String, + category: Option, + }, } impl ProviderError { @@ -58,12 +64,21 @@ impl ProviderError { ProviderError::NotImplemented(_) => "not_implemented", ProviderError::EndpointNotFound(_) => "endpoint_not_found", ProviderError::CreditsExhausted { .. } => "credits_exhausted", + ProviderError::Refusal { .. } => "refusal", } } pub fn is_endpoint_not_found(&self) -> bool { matches!(self, ProviderError::EndpointNotFound(_)) } + + /// Recover a typed `ProviderError` from a streaming decode error, falling + /// back to `RequestFailed` for errors that did not originate as one. + pub fn from_stream_error(error: anyhow::Error) -> Self { + error + .downcast() + .unwrap_or_else(|e| ProviderError::RequestFailed(format!("Stream decode error: {e}"))) + } } fn is_network_error(err: &reqwest::Error) -> bool { diff --git a/crates/goose-sdk-types/src/custom_requests.rs b/crates/goose-sdk-types/src/custom_requests.rs index 3ef2df1ce76e..f9b0bd8924a1 100644 --- a/crates/goose-sdk-types/src/custom_requests.rs +++ b/crates/goose-sdk-types/src/custom_requests.rs @@ -1,4 +1,4 @@ -use agent_client_protocol::schema::McpServer; +use agent_client_protocol::schema::{ContentBlock, McpServer, SessionInfo}; use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -140,6 +140,30 @@ pub struct SetSessionSystemPromptRequest { pub text: String, } +/// Add user input to the currently active prompt without starting a new prompt. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/session/steer", + response = SteerSessionResponse +)] +#[serde(rename_all = "camelCase")] +pub struct SteerSessionRequest { + pub session_id: String, + #[serde(default)] + pub prompt: Vec, + pub expected_run_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct SteerSessionResponse { + pub run_id: String, + /// Stable id of the queued steer message. The same id later appears as + /// `messageId` on the streamed `UserMessageChunk` (with `_meta.goose.steer`), + /// letting clients correlate a queued steer with its pickup. + pub message_id: String, +} + /// Delete a session. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "session/delete", response = EmptyResponse)] @@ -314,6 +338,7 @@ pub struct PreferencesRemoveRequest { pub enum PreferenceKey { #[default] AutoCompactThreshold, + GooseThinkingEffort, VoiceAutoSubmitPhrases, VoiceDictationProvider, VoiceDictationPreferredMic, @@ -449,6 +474,23 @@ pub struct DictationSecretDeleteRequest { pub provider: String, } +/// Return list-style metadata for a single session without loading the conversation. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/session/info", + response = GetSessionInfoResponse +)] +#[serde(rename_all = "camelCase")] +pub struct GetSessionInfoRequest { + pub session_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct GetSessionInfoResponse { + pub session: SessionInfo, +} + /// Update the project association for a session. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/unstable/session/project/update", response = EmptyResponse)] diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml index 68ac9df48cd5..add298a07d62 100644 --- a/crates/goose-server/Cargo.toml +++ b/crates/goose-server/Cargo.toml @@ -81,7 +81,6 @@ tokio-tungstenite = { version = "0.29", default-features = false, features = ["c url = { workspace = true } rand = { workspace = true } hex = { version = "0.4.3", default-features = false, features = ["std"] } -subtle = { version = "2.5", default-features = false, features = ["std"] } socket2 = { version = "0.6", default-features = false } fs2 = { workspace = true } rustls = { workspace = true, optional = true } diff --git a/crates/goose-server/src/auth.rs b/crates/goose-server/src/auth.rs index 7503b4dae178..690e1cfa4094 100644 --- a/crates/goose-server/src/auth.rs +++ b/crates/goose-server/src/auth.rs @@ -4,13 +4,8 @@ use axum::{ middleware::Next, response::Response, }; -use subtle::ConstantTimeEq; - -fn token_matches(candidate: Option<&str>, expected: &str) -> bool { - candidate - .map(|key| bool::from(key.as_bytes().ct_eq(expected.as_bytes()))) - .unwrap_or(false) -} +pub use goose::acp::transport::auth::check_acp_token; +use goose::acp::transport::auth::token_matches; pub async fn check_token( State(state): State, @@ -36,26 +31,3 @@ pub async fn check_token( Err(StatusCode::UNAUTHORIZED) } } - -pub async fn check_acp_token( - State(state): State, - request: Request, - next: Next, -) -> Result { - let header_token = request - .headers() - .get("X-Secret-Key") - .and_then(|value| value.to_str().ok()); - - let query_token = request.uri().query().and_then(|query| { - url::form_urlencoded::parse(query.as_bytes()) - .find(|(key, _)| key == "token") - .map(|(_, value)| value.into_owned()) - }); - - if token_matches(header_token, &state) || token_matches(query_token.as_deref(), &state) { - Ok(next.run(request).await) - } else { - Err(StatusCode::UNAUTHORIZED) - } -} diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index b4973f9aa937..27824489830a 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -8,7 +8,7 @@ use goose::download_manager::{DownloadProgress, DownloadStatus}; use goose::model::ModelConfig; use goose::permission::permission_confirmation::{Permission, PrincipalType}; use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata, ProviderType}; -use goose::session::{Session, SessionInsights, SessionType, SystemInfo}; +use goose::session::{Session, SessionType, SystemInfo}; use goose_providers::thinking::ThinkingEffort; use rmcp::model::{ Annotations, Content, EmbeddedResource, Icon, IconTheme, ImageContent, JsonObject, @@ -440,14 +440,8 @@ derive_utoipa!(IconTheme as IconThemeSchema); super::routes::session_events::session_events, super::routes::session_events::session_reply, super::routes::session_events::session_cancel, - super::routes::session::list_sessions, - super::routes::session::search_sessions, super::routes::session::get_session, - super::routes::session::get_session_insights, super::routes::session::update_session_name, - super::routes::session::delete_session, - super::routes::session::export_session, - super::routes::session::import_session, super::routes::session::share_session_nostr, super::routes::session::import_session_nostr, super::routes::session::update_session_user_recipe_values, @@ -522,11 +516,9 @@ derive_utoipa!(IconTheme as IconThemeSchema); super::routes::session_events::SessionReplyRequest, super::routes::session_events::SessionReplyResponse, super::routes::session_events::CancelRequest, - super::routes::session::ImportSessionRequest, super::routes::session::ShareSessionNostrRequest, super::routes::session::ShareSessionNostrResponse, super::routes::session::ImportSessionNostrRequest, - super::routes::session::SessionListResponse, super::routes::session::UpdateSessionNameRequest, super::routes::session::UpdateSessionUserRecipeValuesRequest, super::routes::session::UpdateSessionUserRecipeValuesResponse, @@ -587,7 +579,6 @@ derive_utoipa!(IconTheme as IconThemeSchema); super::routes::config_management::ProviderModelInfoQuery, Session, goose::config::goose_mode::GooseMode, - SessionInsights, SessionType, SystemInfo, Conversation, diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index 96bbc9590c52..da7d52347710 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -6,27 +6,21 @@ use axum::routing::post; use axum::{ extract::Path, http::StatusCode, - routing::{delete, get, put}, + routing::{get, put}, Json, Router, }; use goose::agents::ExtensionConfig; use goose::recipe::Recipe; #[cfg(feature = "nostr")] use goose::session::nostr_share; -use goose::session::session_manager::{SessionInsights, SessionType}; +#[cfg(feature = "nostr")] +use goose::session::session_manager::SessionType; use goose::session::{EnabledExtensionsState, Session}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use utoipa::ToSchema; -#[derive(Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct SessionListResponse { - /// List of available session information objects - sessions: Vec, -} - #[derive(Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UpdateSessionNameRequest { @@ -46,12 +40,6 @@ pub struct UpdateSessionUserRecipeValuesResponse { recipe: Recipe, } -#[derive(Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ImportSessionRequest { - json: String, -} - #[cfg_attr(not(feature = "nostr"), allow(dead_code))] #[derive(Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] @@ -92,31 +80,6 @@ pub struct ForkResponse { const MAX_NAME_LENGTH: usize = 200; -#[utoipa::path( - get, - path = "/sessions", - responses( - (status = 200, description = "List of available sessions retrieved successfully", body = SessionListResponse), - (status = 401, description = "Unauthorized - Invalid or missing API key"), - (status = 500, description = "Internal server error") - ), - security( - ("api_key" = []) - ), - tag = "Session Management" -)] -async fn list_sessions( - State(state): State>, -) -> Result, StatusCode> { - let sessions = state - .session_manager() - .list_sessions() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(Json(SessionListResponse { sessions })) -} - #[utoipa::path( get, path = "/sessions/{session_id}", @@ -146,29 +109,6 @@ async fn get_session( Ok(Json(session)) } -#[utoipa::path( - get, - path = "/sessions/insights", - responses( - (status = 200, description = "Session insights retrieved successfully", body = SessionInsights), - (status = 401, description = "Unauthorized - Invalid or missing API key"), - (status = 500, description = "Internal server error") - ), - security( - ("api_key" = []) - ), - tag = "Session Management" -)] -async fn get_session_insights( - State(state): State>, -) -> Result, StatusCode> { - let insights = state - .session_manager() - .get_insights() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(Json(insights)) -} #[utoipa::path( put, @@ -289,107 +229,6 @@ async fn update_session_user_recipe_values( } } -#[utoipa::path( - delete, - path = "/sessions/{session_id}", - params( - ("session_id" = String, Path, description = "Unique identifier for the session") - ), - responses( - (status = 200, description = "Session deleted successfully"), - (status = 401, description = "Unauthorized - Invalid or missing API key"), - (status = 404, description = "Session not found"), - (status = 500, description = "Internal server error") - ), - security( - ("api_key" = []) - ), - tag = "Session Management" -)] -async fn delete_session( - State(state): State>, - Path(session_id): Path, -) -> Result { - state - .session_manager() - .delete_session(&session_id) - .await - .map_err(|e| { - if e.to_string().contains("not found") { - StatusCode::NOT_FOUND - } else { - StatusCode::INTERNAL_SERVER_ERROR - } - })?; - - // Cancel any in-flight replies before dropping the bus, so spawned - // agent tasks stop consuming tokens for a deleted session. - if let Some(bus) = state.get_event_bus(&session_id).await { - bus.cancel_all_requests().await; - } - state.remove_event_bus(&session_id).await; - - Ok(StatusCode::OK) -} - -#[utoipa::path( - get, - path = "/sessions/{session_id}/export", - params( - ("session_id" = String, Path, description = "Unique identifier for the session") - ), - responses( - (status = 200, description = "Session exported successfully", body = String), - (status = 401, description = "Unauthorized - Invalid or missing API key"), - (status = 404, description = "Session not found"), - (status = 500, description = "Internal server error") - ), - security( - ("api_key" = []) - ), - tag = "Session Management" -)] -async fn export_session( - State(state): State>, - Path(session_id): Path, -) -> Result, StatusCode> { - let exported = state - .session_manager() - .export_session(&session_id) - .await - .map_err(|_| StatusCode::NOT_FOUND)?; - - Ok(Json(exported)) -} - -#[utoipa::path( - post, - path = "/sessions/import", - request_body = ImportSessionRequest, - responses( - (status = 200, description = "Session imported successfully", body = Session), - (status = 401, description = "Unauthorized - Invalid or missing API key"), - (status = 400, description = "Bad request - Invalid JSON"), - (status = 500, description = "Internal server error") - ), - security( - ("api_key" = []) - ), - tag = "Session Management" -)] -async fn import_session( - State(state): State>, - Json(request): Json, -) -> Result, StatusCode> { - let session = state - .session_manager() - .import_session(&request.json, Some(SessionType::User)) - .await - .map_err(|_| StatusCode::BAD_REQUEST)?; - - Ok(Json(session)) -} - #[cfg_attr(not(feature = "nostr"), allow(unused_variables))] #[utoipa::path( post, @@ -613,24 +452,15 @@ async fn get_session_extensions( pub fn routes(state: Arc) -> Router { Router::new() - .route("/sessions", get(list_sessions)) - .route("/sessions/search", get(search_sessions)) .route("/sessions/{session_id}", get(get_session)) - .route("/sessions/{session_id}", delete(delete_session)) - .route("/sessions/{session_id}/export", get(export_session)) .route( "/sessions/{session_id}/share/nostr", post(share_session_nostr).layer(DefaultBodyLimit::max(25 * 1024 * 1024)), ) - .route( - "/sessions/import", - post(import_session).layer(DefaultBodyLimit::max(25 * 1024 * 1024)), - ) .route( "/sessions/import/nostr", post(import_session_nostr).layer(DefaultBodyLimit::max(25 * 1024 * 1024)), ) - .route("/sessions/insights", get(get_session_insights)) .route("/sessions/{session_id}/name", put(update_session_name)) .route( "/sessions/{session_id}/user_recipe_values", @@ -643,77 +473,3 @@ pub fn routes(state: Arc) -> Router { ) .with_state(state) } -#[derive(Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct SearchSessionsQuery { - /// Search query string (keywords separated by spaces) - query: String, - /// Maximum number of results to return (default: 10, max: 50) - #[serde(default = "default_limit")] - limit: usize, - /// Filter results to sessions after this date (ISO 8601 format) - after_date: Option, - /// Filter results to sessions before this date (ISO 8601 format) - before_date: Option, -} - -fn default_limit() -> usize { - 10 -} - -#[utoipa::path( - get, - path = "/sessions/search", - params( - ("query" = String, Query, description = "Search query string"), - ("limit" = Option, Query, description = "Maximum results (default: 10, max: 50)"), - ("after_date" = Option, Query, description = "Filter after date (ISO 8601)"), - ("before_date" = Option, Query, description = "Filter before date (ISO 8601)") - ), - responses( - (status = 200, description = "Matching sessions", body = Vec), - (status = 400, description = "Bad request - Invalid query"), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error") - ), - security( - ("api_key" = []) - ), - tag = "Session Management" -)] -async fn search_sessions( - State(state): State>, - axum::extract::Query(params): axum::extract::Query, -) -> Result>, StatusCode> { - let query = params.query.trim(); - if query.is_empty() { - return Err(StatusCode::BAD_REQUEST); - } - - let limit = params.limit.min(50); - - let after_date = params - .after_date - .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok()) - .map(|dt| dt.with_timezone(&chrono::Utc)); - - let before_date = params - .before_date - .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok()) - .map(|dt| dt.with_timezone(&chrono::Utc)); - - let sessions = state - .session_manager() - .search_chat_sessions( - query, - Some(limit), - after_date, - before_date, - None, - vec![SessionType::User, SessionType::Scheduled], - ) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(Json(sessions)) -} diff --git a/crates/goose-server/src/session_event_bus.rs b/crates/goose-server/src/session_event_bus.rs index aa94231c82f8..c8880de11ee3 100644 --- a/crates/goose-server/src/session_event_bus.rs +++ b/crates/goose-server/src/session_event_bus.rs @@ -150,14 +150,6 @@ impl SessionEventBus { } } - /// Cancel all active requests (e.g. when deleting a session). - pub async fn cancel_all_requests(&self) { - let requests = self.active_requests.lock().await; - for token in requests.values() { - token.cancel(); - } - } - /// Remove the cancellation token for a completed request. pub async fn cleanup_request(&self, request_id: &str) { let mut requests = self.active_requests.lock().await; diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index a9dee95f65ce..6d109c51e50e 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -149,12 +149,6 @@ impl AppState { buses.get(session_id).cloned() } - /// Remove the event bus for a session, freeing its replay buffer. - pub async fn remove_event_bus(&self, session_id: &str) { - let mut buses = self.session_buses.lock().await; - buses.remove(session_id); - } - pub async fn get_agent(&self, session_id: String) -> anyhow::Result> { self.agent_manager.get_or_create_agent(session_id).await } diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index af827e430443..b7846e7898f5 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -215,6 +215,7 @@ icu_calendar = { version = "=2.1.1", default-features = false } icu_locale = { version = "=2.1.1", default-features = false } llama-cpp-sys-2 = { workspace = true, optional = true } image = { version = "0.24.9", default-features = false, features = ["png", "jpeg", "gif", "webp"] } +subtle = { version = "2.5", default-features = false, features = ["std"] } [target.'cfg(target_os = "windows")'.dependencies] winapi = { workspace = true } @@ -249,6 +250,7 @@ http = { workspace = true } goose-mcp = { path = "../goose-mcp", default-features = false } insta = { version = "1", default-features = false } dtor = { version = "1.0.5", default-features = false, features = ["proc_macro"] } +tower = { version = "0.5.2", default-features = false, features = ["util"] } [[example]] name = "agent" diff --git a/crates/goose/acp-meta.json b/crates/goose/acp-meta.json index 7be80cc9d4cc..27294da66c62 100644 --- a/crates/goose/acp-meta.json +++ b/crates/goose/acp-meta.json @@ -35,6 +35,11 @@ "requestType": "SetSessionSystemPromptRequest_unstable", "responseType": "EmptyResponse" }, + { + "method": "_goose/unstable/session/steer", + "requestType": "SteerSessionRequest_unstable", + "responseType": "SteerSessionResponse_unstable" + }, { "method": "session/delete", "requestType": "DeleteSessionRequest", @@ -190,6 +195,11 @@ "requestType": "ImportSessionRequest_unstable", "responseType": "ImportSessionResponse_unstable" }, + { + "method": "_goose/unstable/session/info", + "requestType": "GetSessionInfoRequest_unstable", + "responseType": "GetSessionInfoResponse_unstable" + }, { "method": "_goose/unstable/elicitation/respond", "requestType": "ElicitationRespondRequest_unstable", diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index ebad7d1bc9c3..97732c520210 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -210,6 +210,434 @@ ], "description": "How a session system prompt update should be applied." }, + "SteerSessionRequest_unstable": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "prompt": { + "type": "array", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "default": [] + }, + "expectedRunId": { + "type": "string" + } + }, + "required": [ + "sessionId", + "expectedRunId" + ], + "description": "Add user input to the currently active prompt without starting a new prompt.", + "x-side": "agent", + "x-method": "_goose/unstable/session/steer" + }, + "ContentBlock": { + "oneOf": [ + { + "$ref": "#/$defs/TextContent", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "required": [ + "type" + ], + "description": "Text content. May be plain text or formatted with Markdown.\n\nAll agents MUST support text content blocks in prompts.\nClients SHOULD render this text as Markdown." + }, + { + "$ref": "#/$defs/ImageContent", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, + "required": [ + "type" + ], + "description": "Images for visual context or analysis.\n\nRequires the `image` prompt capability when included in prompts." + }, + { + "$ref": "#/$defs/AudioContent", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "audio" + } + }, + "required": [ + "type" + ], + "description": "Audio data for transcription or analysis.\n\nRequires the `audio` prompt capability when included in prompts." + }, + { + "$ref": "#/$defs/ResourceLink", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource_link" + } + }, + "required": [ + "type" + ], + "description": "References to resources that the agent can access.\n\nAll agents MUST support resource links in prompts." + }, + { + "$ref": "#/$defs/EmbeddedResource", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource" + } + }, + "required": [ + "type" + ], + "description": "Complete resource contents embedded directly in the message.\n\nPreferred for including context as it avoids extra round-trips.\n\nRequires the `embeddedContext` prompt capability when included in prompts." + } + ], + "description": "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)", + "discriminator": { + "propertyName": "type" + } + }, + "Annotations": { + "type": "object", + "properties": { + "audience": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/Role" + } + }, + "lastModified": { + "type": [ + "string", + "null" + ] + }, + "priority": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + } + }, + "description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed" + }, + "Role": { + "type": "string", + "enum": [ + "assistant", + "user" + ], + "description": "The sender or recipient of messages and data in a conversation." + }, + "TextContent": { + "type": "object", + "properties": { + "annotations": { + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ] + }, + "text": { + "type": "string" + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + } + }, + "required": [ + "text" + ], + "description": "Text provided to or from an LLM." + }, + "ImageContent": { + "type": "object", + "properties": { + "annotations": { + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "uri": { + "type": [ + "string", + "null" + ] + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + } + }, + "required": [ + "data", + "mimeType" + ], + "description": "An image provided to or from an LLM." + }, + "AudioContent": { + "type": "object", + "properties": { + "annotations": { + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ] + }, + "data": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + } + }, + "required": [ + "data", + "mimeType" + ], + "description": "Audio provided to or from an LLM." + }, + "ResourceLink": { + "type": "object", + "properties": { + "annotations": { + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "size": { + "type": [ + "integer", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": "string" + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + } + }, + "required": [ + "name", + "uri" + ], + "description": "A resource that the server is capable of reading, included in a prompt or tool call result." + }, + "EmbeddedResourceResource": { + "anyOf": [ + { + "$ref": "#/$defs/TextResourceContents" + }, + { + "$ref": "#/$defs/BlobResourceContents" + } + ], + "description": "Resource content that can be embedded in a message." + }, + "TextResourceContents": { + "type": "object", + "properties": { + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + } + }, + "required": [ + "text", + "uri" + ], + "description": "Text-based resource contents." + }, + "BlobResourceContents": { + "type": "object", + "properties": { + "blob": { + "type": "string" + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": "string" + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + } + }, + "required": [ + "blob", + "uri" + ], + "description": "Binary resource contents." + }, + "EmbeddedResource": { + "type": "object", + "properties": { + "annotations": { + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ] + }, + "resource": { + "$ref": "#/$defs/EmbeddedResourceResource" + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + } + }, + "required": [ + "resource" + ], + "description": "The contents of a resource, embedded into a prompt or tool call result." + }, + "SteerSessionResponse_unstable": { + "type": "object", + "properties": { + "runId": { + "type": "string" + }, + "messageId": { + "type": "string", + "description": "Stable id of the queued steer message. The same id later appears as\n`messageId` on the streamed `UserMessageChunk` (with `_meta.goose.steer`),\nletting clients correlate a queued steer with its pickup." + } + }, + "required": [ + "runId", + "messageId" + ], + "x-side": "agent", + "x-method": "_goose/unstable/session/steer" + }, "DeleteSessionRequest": { "type": "object", "properties": { @@ -1915,6 +2343,7 @@ "type": "string", "enum": [ "autoCompactThreshold", + "gooseThinkingEffort", "voiceAutoSubmitPhrases", "voiceDictationProvider", "voiceDictationPreferredMic" @@ -2261,6 +2690,84 @@ "x-side": "agent", "x-method": "_goose/unstable/session/import" }, + "GetSessionInfoRequest_unstable": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "description": "Return list-style metadata for a single session without loading the conversation.", + "x-side": "agent", + "x-method": "_goose/unstable/session/info" + }, + "GetSessionInfoResponse_unstable": { + "type": "object", + "properties": { + "session": { + "$ref": "#/$defs/SessionInfo" + } + }, + "required": [ + "session" + ], + "x-side": "agent", + "x-method": "_goose/unstable/session/info" + }, + "SessionInfo": { + "type": "object", + "properties": { + "sessionId": { + "$ref": "#/$defs/SessionId", + "description": "Unique identifier for the session" + }, + "cwd": { + "type": "string", + "description": "The working directory for this session. Must be an absolute path." + }, + "additionalDirectories": { + "type": "array", + "items": { + "type": "string" + }, + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthoritative ordered additional workspace roots for this session. Each path must be absolute.\n\nWhen omitted or empty, there are no additional roots for the session." + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Human-readable title for the session" + }, + "updatedAt": { + "type": [ + "string", + "null" + ], + "description": "ISO 8601 timestamp of last activity" + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + } + }, + "required": [ + "sessionId", + "cwd" + ], + "description": "Information about a session returned by session/list" + }, + "SessionId": { + "type": "string", + "description": "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)" + }, "ElicitationRespondRequest_unstable": { "type": "object", "properties": { @@ -3324,6 +3831,15 @@ "description": "Params for _goose/unstable/session/system-prompt/set", "title": "SetSessionSystemPromptRequest_unstable" }, + { + "allOf": [ + { + "$ref": "#/$defs/SteerSessionRequest_unstable" + } + ], + "description": "Params for _goose/unstable/session/steer", + "title": "SteerSessionRequest_unstable" + }, { "allOf": [ { @@ -3603,6 +4119,15 @@ "description": "Params for _goose/unstable/session/import", "title": "ImportSessionRequest_unstable" }, + { + "allOf": [ + { + "$ref": "#/$defs/GetSessionInfoRequest_unstable" + } + ], + "description": "Params for _goose/unstable/session/info", + "title": "GetSessionInfoRequest_unstable" + }, { "allOf": [ { @@ -3854,6 +4379,14 @@ ], "title": "ReadResourceResponse_unstable" }, + { + "allOf": [ + { + "$ref": "#/$defs/SteerSessionResponse_unstable" + } + ], + "title": "SteerSessionResponse_unstable" + }, { "allOf": [ { @@ -4030,6 +4563,14 @@ ], "title": "ImportSessionResponse_unstable" }, + { + "allOf": [ + { + "$ref": "#/$defs/GetSessionInfoResponse_unstable" + } + ], + "title": "GetSessionInfoResponse_unstable" + }, { "allOf": [ { diff --git a/crates/goose/src/acp/response_builder.rs b/crates/goose/src/acp/response_builder.rs index d108aa117dbc..41789bb81b72 100644 --- a/crates/goose/src/acp/response_builder.rs +++ b/crates/goose/src/acp/response_builder.rs @@ -1,13 +1,15 @@ use crate::config::GooseMode; +use crate::model::ModelConfig; 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, + SessionInfo, SessionMode, SessionModeId, SessionModeState, SessionModelState, + SessionNotification, SessionUpdate, UnstructuredCommandInput, }; use agent_client_protocol::{Client, ConnectionTo}; +use goose_providers::thinking::ThinkingEffort; use strum::{EnumMessage, VariantNames}; use super::server::{build_usage_updates, DEFAULT_PROVIDER_ID, DEFAULT_PROVIDER_LABEL}; @@ -19,6 +21,64 @@ pub(super) fn session_provider_selection(session: &Session) -> &str { .unwrap_or(DEFAULT_PROVIDER_ID) } +pub(super) fn session_meta(session: &Session) -> serde_json::Map { + let mut meta = serde_json::Map::new(); + meta.insert( + "messageCount".to_string(), + serde_json::Value::Number(session.message_count.into()), + ); + meta.insert( + "createdAt".to_string(), + serde_json::Value::String(session.created_at.to_rfc3339()), + ); + if let Some(ref archived_at) = session.archived_at { + meta.insert( + "archivedAt".to_string(), + serde_json::Value::String(archived_at.to_rfc3339()), + ); + } + meta.insert( + "userSetName".to_string(), + serde_json::Value::Bool(session.user_set_name), + ); + meta.insert( + "hasRecipe".to_string(), + serde_json::Value::Bool(session.recipe.is_some()), + ); + + if let Some(ref pid) = session.project_id { + meta.insert( + "projectId".to_string(), + serde_json::Value::String(pid.clone()), + ); + } + if let Some(ref provider) = session.provider_name { + meta.insert( + "providerId".to_string(), + serde_json::Value::String(provider.clone()), + ); + } + if let Some(ref mc) = session.model_config { + meta.insert( + "modelId".to_string(), + serde_json::Value::String(mc.model_name.clone()), + ); + } + meta +} + +pub(super) fn build_session_info(session: Session) -> SessionInfo { + let meta = session_meta(&session); + let title = session.display_title(); + let mut info = SessionInfo::new(SessionId::new(session.id), session.working_dir) + .updated_at(session.updated_at.to_rfc3339()) + .meta(meta); + if let Some(title) = title { + info = info.title(title); + } + info +} + pub(super) fn build_model_state( current_model: &str, inventory: &ProviderInventoryEntry, @@ -146,6 +206,7 @@ pub(super) async fn build_session_setup_config( let config_options = build_config_options( &mode_state, &model_state, + model_config, provider_selection, provider_options, ); @@ -155,6 +216,7 @@ pub(super) async fn build_session_setup_config( pub(super) fn build_config_options( mode_state: &SessionModeState, model_state: &SessionModelState, + model_config: &ModelConfig, provider_selection: &str, provider_options: Vec, ) -> Vec { @@ -171,6 +233,14 @@ pub(super) fn build_config_options( .iter() .map(|m| SessionConfigSelectOption::new(m.model_id.0.clone(), m.name.clone())) .collect(); + let thinking_effort_options = thinking_effort_values(model_config) + .iter() + .map(|effort| { + let effort = effort.to_string(); + SessionConfigSelectOption::new(effort.clone(), effort) + }) + .collect::>(); + let current_thinking_effort = current_thinking_effort_value(model_config); vec![ SessionConfigOption::select( "provider", @@ -192,9 +262,42 @@ pub(super) fn build_config_options( model_options, ) .category(SessionConfigOptionCategory::Model), + SessionConfigOption::select( + "thinking_effort", + "Thinking effort", + current_thinking_effort, + thinking_effort_options, + ) + .description("Controls reasoning effort for models that support extended thinking.") + .category(SessionConfigOptionCategory::ThoughtLevel), ] } +fn thinking_effort_values(model_config: &ModelConfig) -> &'static [ThinkingEffort] { + if model_config.is_reasoning_model() { + &[ + ThinkingEffort::Off, + ThinkingEffort::Low, + ThinkingEffort::Medium, + ThinkingEffort::High, + ThinkingEffort::Max, + ] + } else { + &[ThinkingEffort::Off] + } +} + +fn current_thinking_effort_value(model_config: &ModelConfig) -> String { + if model_config.is_reasoning_model() { + model_config + .thinking_effort() + .map(|effort| effort.to_string()) + .unwrap_or_else(|| "off".to_string()) + } else { + "off".to_string() + } +} + 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() @@ -236,6 +339,7 @@ pub(super) fn send_session_setup_notifications( #[cfg(test)] mod tests { use super::*; + use agent_client_protocol::schema::SessionConfigKind; use test_case::test_case; #[test_case( @@ -363,6 +467,12 @@ mod tests { SessionConfigSelectOption::new("gpt-3.5", "gpt-3.5"), ], ).category(SessionConfigOptionCategory::Model), + SessionConfigOption::select( + "thinking_effort", "Thinking effort", "off", + vec![SessionConfigSelectOption::new("off", "off")], + ) + .description("Controls reasoning effort for models that support extended thinking.") + .category(SessionConfigOptionCategory::ThoughtLevel), ] ; "auto mode with multiple models" )] @@ -389,6 +499,12 @@ mod tests { "model", "Model", "only-model", vec![SessionConfigSelectOption::new("only-model", "only-model")], ).category(SessionConfigOptionCategory::Model), + SessionConfigOption::select( + "thinking_effort", "Thinking effort", "off", + vec![SessionConfigSelectOption::new("off", "off")], + ) + .description("Controls reasoning effort for models that support extended thinking.") + .category(SessionConfigOptionCategory::ThoughtLevel), ] ; "approve mode with single model" )] @@ -398,6 +514,100 @@ mod tests { provider_options: Vec, model_state: SessionModelState, ) -> Vec { - build_config_options(&mode_state, &model_state, provider_name, provider_options) + let model_config = ModelConfig { + model_name: model_state.current_model_id.0.to_string(), + request_params: Some(std::collections::HashMap::from([( + "thinking_effort".to_string(), + serde_json::json!("off"), + )])), + ..Default::default() + }; + build_config_options( + &mode_state, + &model_state, + &model_config, + provider_name, + provider_options, + ) + } + + #[test] + fn test_build_config_options_uses_current_thinking_effort() { + let mode_state = build_mode_state(GooseMode::Auto).unwrap(); + let model_state = SessionModelState::new( + ModelId::new("claude-sonnet-4"), + vec![ModelInfo::new( + ModelId::new("claude-sonnet-4"), + "claude-sonnet-4", + )], + ); + let model_config = ModelConfig { + model_name: "claude-sonnet-4".to_string(), + request_params: Some(std::collections::HashMap::from([( + "thinking_effort".to_string(), + serde_json::json!("high"), + )])), + ..Default::default() + }; + + let options = build_config_options( + &mode_state, + &model_state, + &model_config, + "openai", + vec![SessionConfigSelectOption::new("openai", "openai")], + ); + let option = options + .iter() + .find(|option| option.id.0.as_ref() == "thinking_effort") + .expect("thinking_effort option"); + let select = match &option.kind { + SessionConfigKind::Select(select) => select, + _ => panic!("thinking_effort should be a select option"), + }; + + assert_eq!(select.current_value.0.as_ref(), "high"); + } + + #[test] + fn test_build_config_options_masks_non_reasoning_thinking_effort() { + let mode_state = build_mode_state(GooseMode::Auto).unwrap(); + let model_state = SessionModelState::new( + ModelId::new("gpt-4"), + vec![ModelInfo::new(ModelId::new("gpt-4"), "gpt-4")], + ); + let model_config = ModelConfig { + model_name: "gpt-4".to_string(), + request_params: Some(std::collections::HashMap::from([( + "thinking_effort".to_string(), + serde_json::json!("high"), + )])), + reasoning: Some(false), + ..Default::default() + }; + + let options = build_config_options( + &mode_state, + &model_state, + &model_config, + "openai", + vec![SessionConfigSelectOption::new("openai", "openai")], + ); + let option = options + .iter() + .find(|option| option.id.0.as_ref() == "thinking_effort") + .expect("thinking_effort option"); + let select = match &option.kind { + SessionConfigKind::Select(select) => select, + _ => panic!("thinking_effort should be a select option"), + }; + + assert_eq!(select.current_value.0.as_ref(), "off"); + assert_eq!( + select.options, + agent_client_protocol::schema::SessionConfigSelectOptions::Ungrouped(vec![ + SessionConfigSelectOption::new("off", "off") + ]) + ); } } diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 783ef7dd7f9c..1883fec3b66a 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -3,8 +3,8 @@ use crate::acp::custom_requests::*; use crate::acp::fs::AcpTools; pub(super) use crate::acp::response_builder::{ build_config_options, build_mode_state, build_model_state, build_provider_options, - build_session_setup_config, send_session_setup_notifications, session_provider_selection, - should_refresh_inventory_for_session_init, + build_session_info, build_session_setup_config, send_session_setup_notifications, session_meta, + session_provider_selection, should_refresh_inventory_for_session_init, }; use crate::acp::tools::AcpAwareToolMeta; use crate::acp::{PermissionDecision, ACP_CURRENT_MODEL}; @@ -34,7 +34,6 @@ use crate::providers::inventory::{ ProviderInventoryEntry, ProviderInventoryService, RefreshJobPlan, RefreshPlan, RefreshSkipReason, }; -use crate::session::session_manager::{SessionListCursor, SessionType}; use crate::session::{ EnabledExtensionsState, ExtensionData, ExtensionState, Session, SessionManager, }; @@ -50,7 +49,7 @@ use agent_client_protocol::schema::{ McpCapabilities, McpServer, Meta, NewSessionRequest, NewSessionResponse, PermissionOption, PermissionOptionKind, PromptCapabilities, PromptRequest, PromptResponse, RequestPermissionOutcome, RequestPermissionRequest, ResourceLink, SessionCapabilities, - SessionCloseCapabilities, SessionConfigOption, SessionId, SessionInfo, SessionInfoUpdate, + SessionCloseCapabilities, SessionConfigOption, SessionId, SessionInfoUpdate, SessionListCapabilities, SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, StopReason, TextContent, TextResourceContents, @@ -63,7 +62,6 @@ use agent_client_protocol::{ Responder, }; use anyhow::Result; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use fs_err as fs; use futures::future::BoxFuture; use futures::stream::{self, StreamExt}; @@ -71,8 +69,7 @@ use futures::FutureExt; use rmcp::model::{ AnnotateAble, CallToolResult, RawContent, RawTextContent, ResourceContents, Role, }; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; +use serde::Deserialize; use std::collections::{HashMap, HashSet}; use std::panic::AssertUnwindSafe; use std::path::{Path, PathBuf}; @@ -82,6 +79,7 @@ use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; use url::Url; +use uuid::Uuid; mod config; mod custom_dispatch; @@ -89,6 +87,7 @@ mod dictation; mod dispatch; mod extensions; mod fork_session; +mod list_sessions; mod load_session; mod manage_sessions; mod new_session; @@ -109,10 +108,6 @@ pub type AcpProviderFactory = Arc< + Sync, >; -const SESSION_LIST_PAGE_SIZE: usize = 50; -const ACP_SESSION_LIST_TYPES: [SessionType; 3] = - [SessionType::User, SessionType::Scheduled, SessionType::Acp]; - /// Convenience conversions from any `Display` error into an `agent_client_protocol::Error`. /// /// Replaces the repetitive `.internal_err()` @@ -176,6 +171,7 @@ struct GooseAcpSession { /// Idempotence guard so we summarize each chain at most once. summarized_chains: HashSet, cancel_token: Option, + active_run_id: Option, } /// A run of consecutive ToolRequest blocks within one assistant message, @@ -226,144 +222,22 @@ pub(super) fn sid_short(id: &str) -> String { id.chars().take(8).collect() } -#[derive(Debug, Serialize, Deserialize)] -struct SessionListCursorToken { - updated_at: chrono::DateTime, - // Goose stores updated_at with second precision in common write paths, so the - // cursor needs the full (updated_at, id) sort key to avoid skipping tied rows. - session_id: String, - filter_hash: String, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct SessionListCursorFilters { - cwd: Option, - session_types: Vec, - non_empty: bool, -} - -fn invalid_session_list_cursor(message: &'static str) -> agent_client_protocol::Error { - agent_client_protocol::Error::invalid_params().data(message) -} - -// bind cursors to the effective filters so they cannot be reused for a different list. -fn session_list_filter_hash( - cwd: Option<&std::path::Path>, - session_types: &[SessionType], -) -> Result { - let mut session_type_names = session_types - .iter() - .map(ToString::to_string) - .collect::>(); - session_type_names.sort(); - let filters = SessionListCursorFilters { - cwd: cwd.map(|path| path.to_string_lossy().to_string()), - session_types: session_type_names, - non_empty: true, - }; - let bytes = - serde_json::to_vec(&filters).internal_err_ctx("Failed to encode session list filters")?; - Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(bytes))) -} - -fn decode_session_list_cursor( - cursor: Option<&str>, - cwd: Option<&std::path::Path>, - session_types: &[SessionType], -) -> Result, agent_client_protocol::Error> { - let Some(cursor) = cursor else { +fn meta_string( + meta: Option<&Meta>, + key: &str, +) -> Result, agent_client_protocol::Error> { + let Some(value) = meta.and_then(|m| m.get(key)) else { return Ok(None); }; - - let bytes = URL_SAFE_NO_PAD - .decode(cursor) - .map_err(|_| invalid_session_list_cursor("malformed session list cursor"))?; - let token: SessionListCursorToken = serde_json::from_slice(&bytes) - .map_err(|_| invalid_session_list_cursor("malformed session list cursor"))?; - - if token.session_id.is_empty() || token.filter_hash.is_empty() { - return Err(invalid_session_list_cursor("malformed session list cursor")); - } - - let expected_filter_hash = session_list_filter_hash(cwd, session_types)?; - if token.filter_hash != expected_filter_hash { - return Err(invalid_session_list_cursor( - "session list cursor does not match filters", - )); - } - - Ok(Some(SessionListCursor { - updated_at: token.updated_at, - session_id: token.session_id, - })) -} - -fn encode_session_list_cursor( - cursor: &SessionListCursor, - cwd: Option<&std::path::Path>, - session_types: &[SessionType], -) -> Result { - let token = SessionListCursorToken { - updated_at: cursor.updated_at, - session_id: cursor.session_id.clone(), - filter_hash: session_list_filter_hash(cwd, session_types)?, - }; - let bytes = - serde_json::to_vec(&token).internal_err_ctx("Failed to encode session list cursor")?; - Ok(URL_SAFE_NO_PAD.encode(bytes)) -} - -pub(super) fn session_meta(session: &Session) -> serde_json::Map { - let mut meta = serde_json::Map::new(); - meta.insert( - "messageCount".to_string(), - serde_json::Value::Number(session.message_count.into()), - ); - meta.insert( - "createdAt".to_string(), - serde_json::Value::String(session.created_at.to_rfc3339()), - ); - if let Some(ref archived_at) = session.archived_at { - meta.insert( - "archivedAt".to_string(), - serde_json::Value::String(archived_at.to_rfc3339()), - ); - } - meta.insert( - "userSetName".to_string(), - serde_json::Value::Bool(session.user_set_name), - ); - meta.insert( - "hasRecipe".to_string(), - serde_json::Value::Bool(session.recipe.is_some()), - ); - - if let Some(ref pid) = session.project_id { - meta.insert( - "projectId".to_string(), - serde_json::Value::String(pid.clone()), - ); - } - if let Some(ref provider) = session.provider_name { - meta.insert( - "providerId".to_string(), - serde_json::Value::String(provider.clone()), - ); + if value.is_null() { + return Ok(None); } - if let Some(ref mc) = session.model_config { - meta.insert( - "modelId".to_string(), - serde_json::Value::String(mc.model_name.clone()), + let Some(value) = value.as_str() else { + return Err( + agent_client_protocol::Error::invalid_params().data(format!("{key} must be a string")) ); - } - meta -} - -fn meta_string(meta: Option<&Meta>, key: &str) -> Option { - meta.and_then(|m| m.get(key)) - .and_then(|v| v.as_str()) - .map(ToString::to_string) + }; + Ok(Some(value.to_string())) } fn spawn_session_name_update_notifier( @@ -908,34 +782,6 @@ fn builtin_to_extension_config(name: &str) -> ExtensionConfig { } } -fn with_preserved_session_request_params( - mut model_config: crate::model::ModelConfig, - current_model_config: Option<&crate::model::ModelConfig>, - request_params: Option>, -) -> crate::model::ModelConfig { - let has_model_effort = model_config - .request_params - .as_ref() - .and_then(|params| params.get("thinking_effort")) - .is_some(); - if !has_model_effort { - if let Some(thinking_effort) = current_model_config - .and_then(|config| config.request_params.as_ref()) - .and_then(|params| params.get("thinking_effort")) - .cloned() - { - model_config = model_config.with_merged_request_params(HashMap::from([( - "thinking_effort".into(), - thinking_effort, - )])); - } - } - if let Some(request_params) = request_params { - model_config = model_config.with_merged_request_params(request_params); - } - model_config -} - fn to_nonnegative_u64(value: Option) -> Option { value.and_then(|v| u64::try_from(v).ok()) } @@ -1299,6 +1145,7 @@ impl GooseAcpAgent { responded_tool_ids: HashSet::new(), summarized_chains: HashSet::new(), cancel_token: None, + active_run_id: None, }; self.sessions.lock().await.insert(session_id, acp_session); } @@ -1396,19 +1243,22 @@ impl GooseAcpAgent { session_id_str: &str, message_id: Option<&str>, message_created: i64, + role: &Role, + steer: bool, agent: &Arc, session: &mut GooseAcpSession, cx: &ConnectionTo, ) -> Result<(), agent_client_protocol::Error> { match content_item { MessageContent::Text(text) => { - cx.send_notification(SessionNotification::new( - session_id.clone(), - SessionUpdate::AgentMessageChunk( - ContentChunk::new(ContentBlock::Text(TextContent::new(text.text.clone()))) - .meta(message_update_meta(message_id, message_created)), - ), - ))?; + let chunk = + ContentChunk::new(ContentBlock::Text(TextContent::new(text.text.clone()))) + .meta(message_update_meta(message_id, message_created, steer)); + let update = match role { + Role::User => SessionUpdate::UserMessageChunk(chunk), + Role::Assistant => SessionUpdate::AgentMessageChunk(chunk), + }; + cx.send_notification(SessionNotification::new(session_id.clone(), update))?; } MessageContent::ToolRequest(tool_request) => { self.handle_tool_request( @@ -1439,7 +1289,11 @@ impl GooseAcpAgent { ContentChunk::new(ContentBlock::Text(TextContent::new( thinking.thinking.clone(), ))) - .meta(message_update_meta(message_id, message_created)), + .meta(message_update_meta( + message_id, + message_created, + steer, + )), ), ))?; } @@ -2105,15 +1959,18 @@ fn send_elicitation_interaction_update( } fn interaction_update_meta(message_id: Option<&str>, created: i64) -> serde_json::Value { - serde_json::Value::Object(message_update_meta(message_id, created)) + serde_json::Value::Object(message_update_meta(message_id, created, false)) } -fn message_update_meta(message_id: Option<&str>, created: i64) -> Meta { +fn message_update_meta(message_id: Option<&str>, created: i64, steer: bool) -> Meta { let mut goose = serde_json::Map::new(); goose.insert("created".to_string(), serde_json::json!(created)); if let Some(id) = message_id { goose.insert("messageId".to_string(), serde_json::json!(id)); } + if steer { + goose.insert("steer".to_string(), serde_json::json!(true)); + } let mut meta = serde_json::Map::new(); meta.insert("goose".to_string(), serde_json::Value::Object(goose)); @@ -2150,6 +2007,9 @@ fn replay_message_goose_meta(message: &Message) -> serde_json::Map Result<(), agent_client_protocol::Error> { + let mut sessions = self.sessions.lock().await; + let session = sessions.get_mut(session_id).ok_or_else(|| { + agent_client_protocol::Error::resource_not_found(Some(session_id.to_string())) + .data(format!("Session not found: {}", session_id)) + })?; + + if let Some(active_run_id) = &session.active_run_id { + return Err(agent_client_protocol::Error::invalid_params().data(format!( + "session already has active run `{active_run_id}`; use _goose/unstable/session/steer" + ))); + } + + session.cancel_token = Some(cancel_token); + session.active_run_id = Some(run_id); + Ok(()) + } + + async fn clear_active_run(&self, session_id: &str, run_id: &str) { + let agent = { + let mut sessions = self.sessions.lock().await; + let Some(session) = sessions.get_mut(session_id) else { + return; + }; + if session.active_run_id.as_deref() != Some(run_id) { + return; + } + session.cancel_token = None; + session.active_run_id = None; + session.agent.clone() + }; + agent.discard_pending_steers(session_id).await; + } + + async fn require_active_run( + &self, + session_id: &str, + expected_run_id: &str, + ) -> Result { + if expected_run_id.is_empty() { + return Err(agent_client_protocol::Error::invalid_params() + .data("expectedRunId must not be empty")); + } + + let sessions = self.sessions.lock().await; + let session = sessions.get(session_id).ok_or_else(|| { + agent_client_protocol::Error::resource_not_found(Some(session_id.to_string())) + .data(format!("Session not found: {}", session_id)) + })?; + let active_run_id = session.active_run_id.as_ref().ok_or_else(|| { + agent_client_protocol::Error::invalid_params().data("no active run to steer") + })?; + if active_run_id != expected_run_id { + return Err( + agent_client_protocol::Error::invalid_params().data(serde_json::json!({ + "message": format!( + "expected active run id `{expected_run_id}` but found `{active_run_id}`" + ), + "expectedRunId": expected_run_id, + "actualRunId": active_run_id, + })), + ); + } + Ok(active_run_id.clone()) + } + + fn active_run_meta(active_run_id: Option<&str>) -> Meta { + let mut goose = serde_json::Map::new(); + goose.insert( + "activeRunId".to_string(), + active_run_id + .map(|run_id| serde_json::Value::String(run_id.to_string())) + .unwrap_or(serde_json::Value::Null), + ); + + let mut meta = serde_json::Map::new(); + meta.insert("goose".to_string(), serde_json::Value::Object(goose)); + meta + } + + fn send_active_run_update( + cx: &ConnectionTo, + session_id: &SessionId, + active_run_id: Option<&str>, + ) -> Result<(), agent_client_protocol::Error> { + cx.send_notification(SessionNotification::new( + session_id.clone(), + SessionUpdate::SessionInfoUpdate( + SessionInfoUpdate::new().meta(Self::active_run_meta(active_run_id)), + ), + )) + } + + fn send_queued_steer_update( + cx: &ConnectionTo, + session_id: &SessionId, + message_id: &str, + run_id: &str, + ) -> Result<(), agent_client_protocol::Error> { + let mut goose = serde_json::Map::new(); + goose.insert( + "queuedSteer".to_string(), + serde_json::json!({ + "messageId": message_id, + "runId": run_id, + }), + ); + let mut meta = serde_json::Map::new(); + meta.insert("goose".to_string(), serde_json::Value::Object(goose)); + + cx.send_notification(SessionNotification::new( + session_id.clone(), + SessionUpdate::SessionInfoUpdate(SessionInfoUpdate::new().meta(meta)), + )) + } + #[allow(dead_code)] async fn add_mcp_extensions( agent: &Arc, @@ -2373,10 +2354,23 @@ impl GooseAcpAgent { let sid = sid_short(&session_id); let t_start = std::time::Instant::now(); + let run_id = format!("run_{}", Uuid::new_v4()); let cancel_token = CancellationToken::new(); - let agent = self - .get_session_agent(&session_id, Some(cancel_token.clone())) + self.start_active_run(&session_id, run_id.clone(), cancel_token.clone()) .await?; + if let Err(error) = Self::send_active_run_update(cx, &args.session_id, Some(&run_id)) { + self.clear_active_run(&session_id, &run_id).await; + return Err(error); + } + + let agent = match self.get_session_agent(&session_id, None).await { + Ok(agent) => agent, + Err(error) => { + self.clear_active_run(&session_id, &run_id).await; + let _ = Self::send_active_run_update(cx, &args.session_id, None); + return Err(error); + } + }; let user_message = Self::convert_acp_prompt_to_message(&args.prompt); @@ -2391,7 +2385,7 @@ impl GooseAcpAgent { ) { if recipe_path.exists() { - cx.send_notification(SessionNotification::new( + if let Err(error) = cx.send_notification(SessionNotification::new( args.session_id.clone(), SessionUpdate::AgentMessageChunk(ContentChunk::new( ContentBlock::Text(TextContent::new(format!( @@ -2399,7 +2393,11 @@ impl GooseAcpAgent { full_command ))), )), - ))?; + )) { + self.clear_active_run(&session_id, &run_id).await; + let _ = Self::send_active_run_update(cx, &args.session_id, None); + return Err(error); + } } } } @@ -2412,10 +2410,18 @@ impl GooseAcpAgent { retry_config: None, }; - let mut stream = agent + let mut stream = match agent .reply(user_message, session_config, Some(cancel_token.clone())) .await - .internal_err_ctx("Error getting agent reply")?; + { + Ok(stream) => stream, + Err(error) => { + self.clear_active_run(&session_id, &run_id).await; + let _ = Self::send_active_run_update(cx, &args.session_id, None); + return Err(agent_client_protocol::Error::internal_error() + .data(format!("Error getting agent reply: {error}"))); + } + }; let mut was_cancelled = false; let mut first_event_logged = false; @@ -2431,6 +2437,7 @@ impl GooseAcpAgent { // `handle_tool_response` finds the chain when subsequent responses // are processed. let mut chain_buffer: Vec<(String, String)> = Vec::new(); + let mut stream_error = None; while let Some(event) = stream.next().await { if cancel_token.is_cancelled() { @@ -2454,15 +2461,18 @@ impl GooseAcpAgent { let stored_message_id = message.id.clone(); let mut sessions = self.sessions.lock().await; - let session = sessions.get_mut(&session_id).ok_or_else(|| { - agent_client_protocol::Error::invalid_params() - .data(format!("Session not found: {}", session_id)) - })?; + let Some(session) = sessions.get_mut(&session_id) else { + stream_error = Some( + agent_client_protocol::Error::invalid_params() + .data(format!("Session not found: {}", session_id)), + ); + break; + }; for content_item in &message.content { if let Some(error) = prompt_error_from_message_content(content_item) { - session.cancel_token = None; - return Err(error); + stream_error = Some(error); + break; } match content_item { @@ -2492,23 +2502,36 @@ impl GooseAcpAgent { } } - self.handle_message_content( - content_item, - &args.session_id, - &session_id, - stored_message_id.as_deref(), - message.created, - &agent, - session, - cx, - ) - .await?; + if let Err(error) = self + .handle_message_content( + content_item, + &args.session_id, + &session_id, + stored_message_id.as_deref(), + message.created, + &message.role, + message.metadata.steer, + &agent, + session, + cx, + ) + .await + { + stream_error = Some(error); + break; + } + } + if stream_error.is_some() { + break; } } Ok(_) => {} Err(e) => { - return Err(agent_client_protocol::Error::internal_error() - .data(format!("Error in agent response stream: {}", e))); + stream_error = Some( + agent_client_protocol::Error::internal_error() + .data(format!("Error in agent response stream: {}", e)), + ); + break; } } } @@ -2521,9 +2544,13 @@ impl GooseAcpAgent { // registered. (Eager registration during the loop usually // covers this.) extend_chain_membership(&chain_buffer, &mut session.chain_membership); - session.cancel_token = None; } } + self.clear_active_run(&session_id, &run_id).await; + Self::send_active_run_update(cx, &args.session_id, None)?; + if let Some(error) = stream_error { + return Err(error); + } let session = self .session_manager @@ -2564,6 +2591,48 @@ impl GooseAcpAgent { Ok(response) } + async fn on_steer_session( + &self, + req: SteerSessionRequest, + ) -> Result { + if req.prompt.is_empty() { + return Err( + agent_client_protocol::Error::invalid_params().data("prompt must not be empty") + ); + } + + self.require_active_run(&req.session_id, &req.expected_run_id) + .await?; + let agent = self.get_session_agent(&req.session_id, None).await?; + let active_run_id = self + .require_active_run(&req.session_id, &req.expected_run_id) + .await?; + + let message = Self::convert_acp_prompt_to_message(&req.prompt); + if message.content.is_empty() { + return Err(agent_client_protocol::Error::invalid_params() + .data("prompt must contain steerable content")); + } + + let message_id = format!("steer_{}", Uuid::new_v4()); + let message = message.with_id(message_id.clone()); + agent.steer(&req.session_id, message).await; + + if let Some(cx) = self.client_cx.get() { + let _ = Self::send_queued_steer_update( + cx, + &SessionId::new(req.session_id.clone()), + &message_id, + &active_run_id, + ); + } + + Ok(SteerSessionResponse { + run_id: active_run_id, + message_id, + }) + } + async fn on_cancel( &self, args: CancelNotification, @@ -2634,7 +2703,6 @@ impl GooseAcpAgent { session_id: &str, model_id: &str, ) -> Result { - let config = self.config()?; let agent = self.get_session_agent(session_id, None).await?; let current_provider = agent .provider() @@ -2642,36 +2710,15 @@ impl GooseAcpAgent { .internal_err_ctx("Failed to get provider")?; let provider_name = current_provider.get_name().to_string(); let current_model_config = current_provider.get_model_config(); - let extensions = - EnabledExtensionsState::for_session(&self.session_manager, session_id, config).await; let model_config = crate::model::ModelConfig::new(model_id) .invalid_params_err_ctx("Invalid model config")? .with_canonical_limits(&provider_name); let model_config = - with_preserved_session_request_params(model_config, Some(¤t_model_config), None); - let session = self - .session_manager - .get_session(session_id, false) - .await - .internal_err_ctx("Failed to get session")?; - let provider = self - .create_provider( - &provider_name, - model_config, - extensions, - Some(session.working_dir), - ) - .await - .internal_err_ctx("Failed to create provider")?; + model_config.with_inherited_session_settings_from(Some(¤t_model_config), None); agent - .update_provider(provider, session_id) - .await - .internal_err_ctx("Failed to update provider")?; - let mode = agent.goose_mode().await; - agent - .update_goose_mode(mode, session_id) + .recreate_provider_for_session(session_id, &provider_name, model_config) .await - .internal_err_ctx("Failed to propagate mode")?; + .internal_err_ctx("Failed to recreate provider")?; // model_config is already updated on the session by the agent's update_provider call. Ok(SetSessionModelResponse::new()) } @@ -2691,7 +2738,8 @@ impl GooseAcpAgent { .await .internal_err_ctx("Failed to get provider")?; let provider_name = provider.get_name().to_string(); - let current_model = provider.get_model_config().model_name.clone(); + let current_model_config = provider.get_model_config(); + let current_model = current_model_config.model_name.clone(); let goose_mode = agent.goose_mode().await; let inventory = self .provider_inventory @@ -2708,6 +2756,7 @@ impl GooseAcpAgent { let config_options = build_config_options( &mode_state, &model_state, + ¤t_model_config, session_provider_selection(&session), provider_options, ); @@ -2739,6 +2788,26 @@ impl GooseAcpAgent { Ok(SetSessionModeResponse::new()) } + async fn on_set_thinking_effort( + &self, + session_id: &str, + effort_id: &str, + ) -> Result<(), agent_client_protocol::Error> { + let effort = effort_id + .parse::() + .map_err(|_| { + agent_client_protocol::Error::invalid_params() + .data(format!("Invalid thinking effort: {}", effort_id)) + })?; + let agent = self.get_session_agent(session_id, None).await?; + agent + .update_thinking_effort(session_id, effort) + .await + .internal_err_ctx("Failed to update thinking effort")?; + + Ok(()) + } + async fn update_provider( &self, session_id: &str, @@ -2781,91 +2850,18 @@ impl GooseAcpAgent { .invalid_params_err_ctx("Invalid model config")? .with_canonical_limits(&resolved_provider_name) .with_context_limit(context_limit); - model_config = with_preserved_session_request_params( - model_config, - (!is_changing_provider).then_some(¤t_model_config), - request_params, - ); + model_config = model_config + .with_inherited_session_settings_from(Some(¤t_model_config), request_params); - let extensions = - EnabledExtensionsState::for_session(&self.session_manager, session_id, config).await; - let session = self - .session_manager - .get_session(session_id, false) - .await - .internal_err_ctx("Failed to get session")?; - let new_provider = self - .create_provider( - &resolved_provider_name, - model_config, - extensions, - Some(session.working_dir), - ) - .await - .internal_err_ctx("Failed to create provider")?; - agent - .update_provider(new_provider, session_id) - .await - .internal_err_ctx("Failed to update provider")?; - let mode = agent.goose_mode().await; agent - .update_goose_mode(mode, session_id) + .recreate_provider_for_session(session_id, &resolved_provider_name, model_config) .await - .internal_err_ctx("Failed to propagate mode")?; + .internal_err_ctx("Failed to recreate provider")?; // provider_name is already updated on the session by the agent's update_provider call. Ok(()) } - async fn on_list_sessions( - &self, - req: ListSessionsRequest, - ) -> Result { - if let Some(cwd) = req.cwd.as_deref() { - if !cwd.is_absolute() { - return Err(agent_client_protocol::Error::invalid_params() - .data("cwd must be an absolute path")); - } - } - - let cwd = req.cwd.as_deref(); - let cursor = - decode_session_list_cursor(req.cursor.as_deref(), cwd, &ACP_SESSION_LIST_TYPES)?; - - // ACP clients see their own (Acp) sessions plus legacy User/Scheduled ones. - let page = self - .session_manager - .list_nonempty_sessions_by_types_paged( - &ACP_SESSION_LIST_TYPES, - cwd, - cursor.as_ref(), - SESSION_LIST_PAGE_SIZE, - ) - .await - .internal_err()?; - let session_infos: Vec = page - .sessions - .into_iter() - .map(|s| { - let meta = session_meta(&s); - let title = s.display_title(); - let mut info = SessionInfo::new(SessionId::new(s.id), s.working_dir) - .updated_at(s.updated_at.to_rfc3339()) - .meta(meta); - if let Some(t) = title { - info = info.title(t); - } - info - }) - .collect(); - let next_cursor = page - .next_cursor - .as_ref() - .map(|cursor| encode_session_list_cursor(cursor, cwd, &ACP_SESSION_LIST_TYPES)) - .transpose()?; - Ok(ListSessionsResponse::new(session_infos).next_cursor(next_cursor)) - } - async fn on_fork_session( &self, cx: &ConnectionTo, @@ -2944,6 +2940,7 @@ pub async fn run(builtins: Vec) -> Result<()> { mod tests { use super::*; use crate::conversation::message::{ToolRequest, ToolResponse}; + use crate::session::session_manager::SessionType; use agent_client_protocol::schema::{ EnvVariable, HttpHeader, McpServer, McpServerHttp, McpServerSse, McpServerStdio, PermissionOptionId, ResourceLink, SelectedPermissionOutcome, @@ -3564,9 +3561,37 @@ print(\"hello, world\") ); } + #[test] + fn test_merge_replay_message_meta_includes_steer_marker() { + let message = Message::new(Role::User, 1_700_000_000, vec![]) + .with_id("msg_steer") + .with_steer(); + + let merged = merge_replay_message_meta(None, &message); + + assert_eq!( + merged.get("goose"), + Some(&serde_json::json!({ + "created": 1_700_000_000, + "messageId": "msg_steer", + "steer": true, + })), + "replay must carry the steer marker so the boundary survives reload" + ); + } + + #[test] + fn test_merge_replay_message_meta_omits_steer_when_not_set() { + let message = Message::new(Role::Assistant, 1_700_000_000, vec![]).with_id("msg_plain"); + + let merged = merge_replay_message_meta(None, &message); + + assert_eq!(merged.get("goose").and_then(|g| g.get("steer")), None); + } + #[test] fn test_message_update_meta_includes_created_and_message_id() { - let meta = message_update_meta(Some("msg_live"), 1_700_000_000); + let meta = message_update_meta(Some("msg_live"), 1_700_000_000, false); assert_eq!( meta.get("goose"), diff --git a/crates/goose/src/acp/server/config.rs b/crates/goose/src/acp/server/config.rs index 1657e79e583f..654af78c4ab4 100644 --- a/crates/goose/src/acp/server/config.rs +++ b/crates/goose/src/acp/server/config.rs @@ -1,4 +1,5 @@ use super::*; +use goose_providers::thinking::ThinkingEffort; impl GooseAcpAgent { pub(super) async fn on_preferences_read( @@ -37,8 +38,8 @@ impl GooseAcpAgent { for preference in &req.values { let def = preference_def(preference.key)?; - (def.validate)(&preference.value)?; - updates.push((def.config_key.to_string(), preference.value.clone())); + let value = (def.prepare)(&preference.value)?; + updates.push((def.config_key.to_string(), value)); } config.set_param_values(&updates).internal_err()?; @@ -131,29 +132,34 @@ impl GooseAcpAgent { struct PreferenceDef { key: PreferenceKey, config_key: &'static str, - validate: fn(&serde_json::Value) -> Result<(), agent_client_protocol::Error>, + prepare: fn(&serde_json::Value) -> Result, } const PREFERENCE_DEFS: &[PreferenceDef] = &[ PreferenceDef { key: PreferenceKey::AutoCompactThreshold, config_key: "GOOSE_AUTO_COMPACT_THRESHOLD", - validate: validate_auto_compact_threshold, + prepare: prepare_auto_compact_threshold, + }, + PreferenceDef { + key: PreferenceKey::GooseThinkingEffort, + config_key: "GOOSE_THINKING_EFFORT", + prepare: prepare_thinking_effort, }, PreferenceDef { key: PreferenceKey::VoiceAutoSubmitPhrases, config_key: "VOICE_AUTO_SUBMIT_PHRASES", - validate: validate_voice_auto_submit_phrases, + prepare: prepare_voice_auto_submit_phrases, }, PreferenceDef { key: PreferenceKey::VoiceDictationProvider, config_key: "VOICE_DICTATION_PROVIDER", - validate: validate_voice_dictation_provider, + prepare: prepare_voice_dictation_provider, }, PreferenceDef { key: PreferenceKey::VoiceDictationPreferredMic, config_key: "VOICE_DICTATION_PREFERRED_MIC", - validate: validate_voice_dictation_preferred_mic, + prepare: prepare_voice_dictation_preferred_mic, }, ]; @@ -169,35 +175,50 @@ fn preference_def( }) } -fn validate_auto_compact_threshold( +fn prepare_auto_compact_threshold( value: &serde_json::Value, -) -> Result<(), agent_client_protocol::Error> { - let Some(value) = value.as_f64() else { +) -> Result { + let Some(threshold) = value.as_f64() else { return Err(agent_client_protocol::Error::invalid_params() .data("autoCompactThreshold must be a number")); }; - if !value.is_finite() || value <= 0.0 || value > 1.0 { + if !threshold.is_finite() || threshold <= 0.0 || threshold > 1.0 { return Err(agent_client_protocol::Error::invalid_params() .data("autoCompactThreshold must be greater than 0 and at most 1")); } - Ok(()) + Ok(value.clone()) +} + +fn prepare_thinking_effort( + value: &serde_json::Value, +) -> Result { + let Some(value) = value.as_str() else { + return Err(agent_client_protocol::Error::invalid_params() + .data("gooseThinkingEffort must be a string")); + }; + let effort = value.parse::().map_err(|err| { + agent_client_protocol::Error::invalid_params() + .data(format!("Invalid gooseThinkingEffort: {err}")) + })?; + + Ok(serde_json::Value::String(effort.to_string())) } -fn validate_voice_auto_submit_phrases( +fn prepare_voice_auto_submit_phrases( value: &serde_json::Value, -) -> Result<(), agent_client_protocol::Error> { +) -> Result { if !value.is_string() { return Err(agent_client_protocol::Error::invalid_params() .data("voiceAutoSubmitPhrases must be a string")); } - Ok(()) + Ok(value.clone()) } -fn validate_voice_dictation_provider( +fn prepare_voice_dictation_provider( value: &serde_json::Value, -) -> Result<(), agent_client_protocol::Error> { +) -> Result { let Some(value) = value.as_str() else { return Err(agent_client_protocol::Error::invalid_params() .data("voiceDictationProvider must be a string")); @@ -207,12 +228,12 @@ fn validate_voice_dictation_provider( .data("voiceDictationProvider is not supported")); } - Ok(()) + Ok(serde_json::Value::String(value.to_string())) } -fn validate_voice_dictation_preferred_mic( +fn prepare_voice_dictation_preferred_mic( value: &serde_json::Value, -) -> Result<(), agent_client_protocol::Error> { +) -> Result { let Some(value) = value.as_str() else { return Err(agent_client_protocol::Error::invalid_params() .data("voiceDictationPreferredMic must be a string")); @@ -222,7 +243,7 @@ fn validate_voice_dictation_preferred_mic( .data("voiceDictationPreferredMic must be non-empty")); } - Ok(()) + Ok(serde_json::Value::String(value.to_string())) } fn is_supported_voice_dictation_provider(value: &str) -> bool { diff --git a/crates/goose/src/acp/server/custom_dispatch.rs b/crates/goose/src/acp/server/custom_dispatch.rs index fe0df9040662..943685b5d01b 100644 --- a/crates/goose/src/acp/server/custom_dispatch.rs +++ b/crates/goose/src/acp/server/custom_dispatch.rs @@ -67,6 +67,14 @@ impl GooseAcpAgent { self.on_set_session_system_prompt(req).await } + #[custom_method(SteerSessionRequest)] + async fn dispatch_steer_session( + &self, + req: SteerSessionRequest, + ) -> Result { + self.on_steer_session(req).await + } + #[custom_method(DeleteSessionRequest)] async fn dispatch_delete_session( &self, @@ -313,6 +321,14 @@ impl GooseAcpAgent { self.on_import_session(req).await } + #[custom_method(GetSessionInfoRequest)] + async fn dispatch_get_session_info( + &self, + req: GetSessionInfoRequest, + ) -> Result { + self.on_get_session_info(req).await + } + #[custom_method(ElicitationRespondRequest)] async fn dispatch_elicitation_respond( &self, diff --git a/crates/goose/src/acp/server/dispatch.rs b/crates/goose/src/acp/server/dispatch.rs index b02e03511734..2e622f3893f8 100644 --- a/crates/goose/src/acp/server/dispatch.rs +++ b/crates/goose/src/acp/server/dispatch.rs @@ -143,6 +143,12 @@ impl HandleDispatchFrom for GooseAcpHandler { Err(e) => { responder.respond_with_error(e)?; return Ok(()); } } } + "thinking_effort" => { + match agent.on_set_thinking_effort(&session_id.0, &value_id.0).await { + Ok(_) => {} + Err(e) => { responder.respond_with_error(e)?; return Ok(()); } + } + } other => { responder.respond_with_error( agent_client_protocol::Error::invalid_params().data(format!("Unsupported config option: {}", other)) diff --git a/crates/goose/src/acp/server/list_sessions.rs b/crates/goose/src/acp/server/list_sessions.rs new file mode 100644 index 000000000000..ada46233c8b9 --- /dev/null +++ b/crates/goose/src/acp/server/list_sessions.rs @@ -0,0 +1,192 @@ +use super::{build_session_info, meta_string, GooseAcpAgent, ResultExt}; +use crate::session::session_manager::{ + SessionListCursor, SessionListFilters, SessionListPageQuery, SessionType, +}; +use agent_client_protocol::schema::{ListSessionsRequest, ListSessionsResponse, Meta, SessionInfo}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const SESSION_LIST_PAGE_SIZE: usize = 50; +const ACP_SESSION_LIST_TYPES: [SessionType; 3] = + [SessionType::User, SessionType::Scheduled, SessionType::Acp]; + +#[derive(Debug, Serialize, Deserialize)] +struct SessionListCursorToken { + updated_at: chrono::DateTime, + // Goose stores updated_at with second precision in common write paths, so the + // cursor needs the full (updated_at, id) sort key to avoid skipping tied rows. + session_id: String, + filter_hash: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SessionListCursorFilters { + cwd: Option, + session_types: Vec, + keyword: Option, + only_sessions_with_messages: bool, +} + +fn invalid_session_list_cursor(message: &'static str) -> agent_client_protocol::Error { + agent_client_protocol::Error::invalid_params().data(message) +} + +fn session_keyword_from_meta( + meta: Option<&Meta>, +) -> Result, agent_client_protocol::Error> { + Ok(meta_string(meta, "query")? + .map(|keyword| keyword.trim().to_string()) + .filter(|keyword| !keyword.is_empty())) +} + +fn session_types_from_meta( + meta: Option<&Meta>, +) -> Result, agent_client_protocol::Error> { + let Some(value) = meta.and_then(|meta| meta.get("types")) else { + return Ok(ACP_SESSION_LIST_TYPES.to_vec()); + }; + if value.is_null() { + return Ok(ACP_SESSION_LIST_TYPES.to_vec()); + } + + let session_types = + serde_json::from_value::>(value.clone()).map_err(|_| { + agent_client_protocol::Error::invalid_params() + .data("types must be an array of session type strings") + })?; + if session_types.is_empty() { + Ok(ACP_SESSION_LIST_TYPES.to_vec()) + } else { + if session_types + .iter() + .any(|session_type| !ACP_SESSION_LIST_TYPES.contains(session_type)) + { + return Err(agent_client_protocol::Error::invalid_params() + .data("types may only include user, scheduled, or acp")); + } + Ok(session_types) + } +} + +// bind cursors to the effective filters so they cannot be reused for a different list. +fn session_list_filter_hash( + cwd: Option<&std::path::Path>, + session_types: &[SessionType], + keyword: Option<&str>, +) -> Result { + let mut session_type_names = session_types + .iter() + .map(ToString::to_string) + .collect::>(); + session_type_names.sort(); + let filters = SessionListCursorFilters { + cwd: cwd.map(|path| path.to_string_lossy().to_string()), + session_types: session_type_names, + keyword: keyword.map(ToString::to_string), + only_sessions_with_messages: true, + }; + let bytes = + serde_json::to_vec(&filters).internal_err_ctx("Failed to encode session list filters")?; + Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(bytes))) +} + +fn decode_session_list_cursor( + cursor: Option<&str>, + cwd: Option<&std::path::Path>, + session_types: &[SessionType], + keyword: Option<&str>, +) -> Result, agent_client_protocol::Error> { + let Some(cursor) = cursor else { + return Ok(None); + }; + + let bytes = URL_SAFE_NO_PAD + .decode(cursor) + .map_err(|_| invalid_session_list_cursor("malformed session list cursor"))?; + let token: SessionListCursorToken = serde_json::from_slice(&bytes) + .map_err(|_| invalid_session_list_cursor("malformed session list cursor"))?; + + if token.session_id.is_empty() || token.filter_hash.is_empty() { + return Err(invalid_session_list_cursor("malformed session list cursor")); + } + + let expected_filter_hash = session_list_filter_hash(cwd, session_types, keyword)?; + if token.filter_hash != expected_filter_hash { + return Err(invalid_session_list_cursor( + "session list cursor does not match filters", + )); + } + + Ok(Some(SessionListCursor { + updated_at: token.updated_at, + session_id: token.session_id, + })) +} + +fn encode_session_list_cursor( + cursor: &SessionListCursor, + cwd: Option<&std::path::Path>, + session_types: &[SessionType], + keyword: Option<&str>, +) -> Result { + let token = SessionListCursorToken { + updated_at: cursor.updated_at, + session_id: cursor.session_id.clone(), + filter_hash: session_list_filter_hash(cwd, session_types, keyword)?, + }; + let bytes = + serde_json::to_vec(&token).internal_err_ctx("Failed to encode session list cursor")?; + Ok(URL_SAFE_NO_PAD.encode(bytes)) +} + +impl GooseAcpAgent { + pub(super) async fn on_list_sessions( + &self, + req: ListSessionsRequest, + ) -> Result { + if let Some(cwd) = req.cwd.as_deref() { + if !cwd.is_absolute() { + return Err(agent_client_protocol::Error::invalid_params() + .data("cwd must be an absolute path")); + } + } + + let cwd = req.cwd.as_deref(); + let keyword = session_keyword_from_meta(req.meta.as_ref())?; + let session_types = session_types_from_meta(req.meta.as_ref())?; + let cursor = decode_session_list_cursor( + req.cursor.as_deref(), + cwd, + &session_types, + keyword.as_deref(), + )?; + + // ACP clients see their own (Acp) sessions plus legacy User/Scheduled ones. + let page = self + .session_manager + .list_sessions_paged(SessionListPageQuery { + filters: SessionListFilters { + types: Some(&session_types), + working_dir: cwd, + keyword: keyword.as_deref(), + only_sessions_with_messages: true, + }, + cursor: cursor.as_ref(), + page_size: SESSION_LIST_PAGE_SIZE, + }) + .await + .internal_err()?; + let session_infos: Vec = + page.sessions.into_iter().map(build_session_info).collect(); + let next_cursor = page + .next_cursor + .as_ref() + .map(|cursor| { + encode_session_list_cursor(cursor, cwd, &session_types, keyword.as_deref()) + }) + .transpose()?; + Ok(ListSessionsResponse::new(session_infos).next_cursor(next_cursor)) + } +} diff --git a/crates/goose/src/acp/server/manage_sessions.rs b/crates/goose/src/acp/server/manage_sessions.rs index 8e5163bd17ac..a3382c208be5 100644 --- a/crates/goose/src/acp/server/manage_sessions.rs +++ b/crates/goose/src/acp/server/manage_sessions.rs @@ -117,6 +117,31 @@ impl GooseAcpAgent { }) } + pub(super) async fn on_get_session_info( + &self, + req: GetSessionInfoRequest, + ) -> Result { + let session_id = req.session_id.trim(); + if session_id.is_empty() { + return Err( + agent_client_protocol::Error::invalid_params().data("sessionId cannot be empty") + ); + } + + let session = self + .session_manager + .get_session(session_id, false) + .await + .map_err(|_| { + agent_client_protocol::Error::resource_not_found(Some(session_id.to_string())) + .data(format!("Session not found: {}", session_id)) + })?; + + Ok(GetSessionInfoResponse { + session: build_session_info(session), + }) + } + pub(super) async fn on_update_session_project( &self, req: UpdateSessionProjectRequest, diff --git a/crates/goose/src/acp/server/new_session.rs b/crates/goose/src/acp/server/new_session.rs index c1a6d5c5354e..a17d568de30c 100644 --- a/crates/goose/src/acp/server/new_session.rs +++ b/crates/goose/src/acp/server/new_session.rs @@ -18,14 +18,14 @@ impl GooseAcpAgent { 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") { + 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") { + match meta_string(args.meta.as_ref(), "provider")? { Some(provider) => { let model_config = super::resolve_provider_default_model_config(&provider).await?; diff --git a/crates/goose/src/acp/transport/auth.rs b/crates/goose/src/acp/transport/auth.rs new file mode 100644 index 000000000000..9becc6e3b12e --- /dev/null +++ b/crates/goose/src/acp/transport/auth.rs @@ -0,0 +1,36 @@ +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::Response, +}; +use subtle::ConstantTimeEq; + +pub fn token_matches(candidate: Option<&str>, expected: &str) -> bool { + candidate + .map(|key| bool::from(key.as_bytes().ct_eq(expected.as_bytes()))) + .unwrap_or(false) +} + +pub async fn check_acp_token( + State(state): State, + request: Request, + next: Next, +) -> Result { + let header_token = request + .headers() + .get("X-Secret-Key") + .and_then(|value| value.to_str().ok()); + + let query_token = request.uri().query().and_then(|query| { + url::form_urlencoded::parse(query.as_bytes()) + .find(|(key, _)| key == "token") + .map(|(_, value)| value.into_owned()) + }); + + if token_matches(header_token, &state) || token_matches(query_token.as_deref(), &state) { + Ok(next.run(request).await) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} diff --git a/crates/goose/src/acp/transport/mod.rs b/crates/goose/src/acp/transport/mod.rs index ab0ca41da3ac..1a3dbcc0b0ad 100644 --- a/crates/goose/src/acp/transport/mod.rs +++ b/crates/goose/src/acp/transport/mod.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod connection; pub mod http; pub mod websocket; @@ -101,6 +102,7 @@ fn acp_cors_layer() -> CorsLayer { .allow_headers([ header::CONTENT_TYPE, header::ACCEPT, + HeaderName::from_static("x-secret-key"), HeaderName::from_static("acp-connection-id"), HeaderName::from_static("acp-session-id"), header::SEC_WEBSOCKET_VERSION, @@ -127,8 +129,15 @@ pub fn create_acp_router(server: Arc) -> Router { create_acp_routes(server).layer(acp_cors_layer()) } -pub fn create_router(server: Arc, secret_key: String) -> Router { - create_acp_routes(server) +pub fn create_router(server: Arc, secret_key: String, require_token: bool) -> Router { + let mut acp_routes = create_acp_routes(server); + if require_token { + acp_routes = acp_routes.layer(axum::middleware::from_fn_with_state( + secret_key.clone(), + auth::check_acp_token, + )); + } + acp_routes .route("/health", get(health)) .route("/status", get(health)) .merge(super::mcp_app_proxy::routes(secret_key)) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index d9d381d49711..540fe9edb003 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::fmt; use std::future::Future; use std::pin::Pin; @@ -54,6 +54,7 @@ use crate::tool_inspection::ToolInspectionManager; use crate::tool_monitor::RepetitionInspector; use crate::utils::is_token_cancelled; use goose_providers::errors::ProviderError; +use goose_providers::thinking::ThinkingEffort; use regex::Regex; use rmcp::model::{ CallToolRequestParams, CallToolResult, Content, ErrorCode, ErrorData, GetPromptResult, Prompt, @@ -246,6 +247,7 @@ pub struct Agent { container: Mutex>, goal: Mutex>, grind: Mutex>, + pending_steers: Mutex>>, } #[derive(Clone, Debug)] @@ -367,6 +369,7 @@ impl Agent { container: Mutex::new(None), goal: Mutex::new(None), grind: Mutex::new(None), + pending_steers: Mutex::new(HashMap::new()), } } @@ -402,6 +405,36 @@ impl Agent { .await; } + pub async fn steer(&self, session_id: &str, message: Message) { + self.pending_steers + .lock() + .await + .entry(session_id.to_string()) + .or_default() + .push_back(message); + } + + pub async fn discard_pending_steers(&self, session_id: &str) { + self.pending_steers.lock().await.remove(session_id); + } + + async fn has_pending_steers(&self, session_id: &str) -> bool { + self.pending_steers + .lock() + .await + .get(session_id) + .is_some_and(|messages| !messages.is_empty()) + } + + async fn drain_pending_steers(&self, session_id: &str) -> Vec { + self.pending_steers + .lock() + .await + .remove(session_id) + .map(|messages| messages.into_iter().map(Message::with_steer).collect()) + .unwrap_or_default() + } + async fn emit_pre_tool_extended_hooks( &self, tool_name: &str, @@ -1702,12 +1735,35 @@ impl Agent { let mut retrying_after_stop_hook_denial = false; let mut consecutive_stop_hook_blocks = 0u32; let stop_hook_block_cap = self.stop_hook_block_cap(); + let mut can_drain_pending_steers = false; loop { if is_token_cancelled(&cancel_token) { break; } + if can_drain_pending_steers { + for message in self.drain_pending_steers(&session_config.id).await { + let message_text = message.as_concat_text(); + if self + .hook_manager + .has_hooks(crate::hooks::HookEvent::UserPromptSubmit) + { + let ctx = crate::hooks::HookContext::new( + crate::hooks::HookEvent::UserPromptSubmit, + &session_config.id, + ) + .with_message(message_text); + self.hook_manager + .emit(crate::hooks::HookEvent::UserPromptSubmit, ctx) + .await; + } + session_manager.add_message(&session_config.id, &message).await?; + conversation.push(message.clone()); + yield AgentEvent::Message(message); + } + } + let final_output = { let mut guard = self.final_output_tool.lock().await; guard.as_mut().and_then(|fot| fot.final_output.take()) @@ -2188,6 +2244,21 @@ impl Agent { ); break; } + Err(ref provider_err @ ProviderError::Refusal { ref details, ref category }) => { + #[cfg(feature = "telemetry")] + crate::posthog::emit_error(provider_err.telemetry_type(), &provider_err.to_string()); + error!("Error: {}", provider_err); + + let category = category.as_deref().map(|c| format!("\n\nCategory: {c}")).unwrap_or_default(); + yield AgentEvent::Message(Message::assistant().with_text(format!( + "The provider refused this request.\n\n{details}{category}\n\nPlease start a new session to continue — resending this conversation is likely to be refused again." + ))); + // A refusal is terminal: skip goal/grind nudges and + // recipe retry_config, which would resend the same + // refused conversation. + exit_chat = true; + break; + } Err(ref provider_err @ ProviderError::NetworkError(_)) => { #[cfg(feature = "telemetry")] crate::posthog::emit_error(provider_err.telemetry_type(), &provider_err.to_string()); @@ -2212,6 +2283,8 @@ impl Agent { } } } + can_drain_pending_steers = true; + if tools_updated { (tools, toolshim_tools, system_prompt) = self.prepare_tools_and_prompt(&session_config.id, &session.working_dir).await?; @@ -2229,7 +2302,7 @@ impl Agent { } } - if no_tools_called { + if no_tools_called && !exit_chat { // Lock, extract state, drop guard before branching — handle_retry_logic // also locks final_output_tool and tokio::sync::Mutex is not reentrant. let final_output = { @@ -2251,6 +2324,7 @@ impl Agent { None if did_recovery_compact_this_iteration => { // continue from last user message after recovery compact } + None if self.has_pending_steers(&session_config.id).await => {} None if self.goal.lock().await.is_some() && !goal_check_pending => { goal_check_pending = true; let goal = self.goal.lock().await.clone().unwrap(); @@ -2374,6 +2448,10 @@ impl Agent { } conversation.extend(messages_to_add); + if exit_chat && self.has_pending_steers(&session_config.id).await { + exit_chat = false; + } + if exit_chat { let ctx = crate::hooks::HookContext::new( crate::hooks::HookEvent::Stop, @@ -2490,6 +2568,54 @@ impl Agent { *self.current_goose_mode.lock().await } + pub async fn recreate_provider_for_session( + &self, + session_id: &str, + provider_name: &str, + model_config: crate::model::ModelConfig, + ) -> Result<()> { + let session = self + .config + .session_manager + .get_session(session_id, false) + .await + .context("Failed to get session")?; + + let extensions = EnabledExtensionsState::extensions_or_default( + Some(&session.extension_data), + Config::global(), + ); + + let provider = 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))?; + + self.update_provider(provider, session_id).await?; + + let mode = self.goose_mode().await; + self.update_goose_mode(mode, session_id).await + } + + pub async fn update_thinking_effort( + &self, + session_id: &str, + effort: ThinkingEffort, + ) -> Result<()> { + let current_provider = self.provider().await?; + let provider_name = current_provider.get_name().to_string(); + let model_config = current_provider + .get_model_config() + .with_thinking_effort(effort); + + self.recreate_provider_for_session(session_id, &provider_name, model_config) + .await + } + /// Restore the provider from session data or fall back to global config /// This is used when resuming a session to restore the provider state /// Returns true if the session's provider was replaced with a fallback. @@ -3168,12 +3294,86 @@ exit 0 } } - async fn create_stop_hook_test_agent( - env: &StopHookTestEnv, - stop_hook_block_cap: u32, - ) -> Result<(Agent, String, Arc)> { - let session_manager = Arc::new(SessionManager::new(env.data_dir())); - let permission_manager = Arc::new(PermissionManager::new(env.data_dir())); + struct RefusingProvider { + call_count: AtomicUsize, + } + + #[async_trait::async_trait] + impl crate::providers::base::Provider for RefusingProvider { + async fn stream( + &self, + _model_config: &crate::model::ModelConfig, + _session_id: &str, + _system_prompt: &str, + _messages: &[Message], + _tools: &[Tool], + ) -> Result { + self.call_count.fetch_add(1, Ordering::SeqCst); + Ok(Box::pin(futures::stream::once(async { + Err(ProviderError::Refusal { + details: "This request was declined.".to_string(), + category: Some("cyber".to_string()), + }) + }))) + } + + fn get_model_config(&self) -> crate::model::ModelConfig { + crate::model::ModelConfig::new("mock-model").unwrap() + } + + fn get_name(&self) -> &str { + "refusing" + } + } + + #[tokio::test] + async fn refusal_exits_turn_without_recipe_retry() -> Result<()> { + let temp_dir = tempfile::tempdir()?; + let provider = Arc::new(RefusingProvider { + call_count: AtomicUsize::new(0), + }); + let hook_manager = crate::hooks::HookManager::from_plugins_for_test(vec![]); + let (agent, session_id) = + create_test_agent(temp_dir.path().join("data"), hook_manager, provider.clone()).await?; + + let session_config = SessionConfig { + id: session_id, + schedule_id: None, + max_turns: Some(10), + retry_config: Some(crate::agents::types::RetryConfig { + max_retries: 3, + checks: vec![crate::agents::types::SuccessCheck::Shell { + command: "false".to_string(), + }], + on_failure: None, + timeout_seconds: None, + on_failure_timeout_seconds: None, + }), + }; + + let reply_stream = agent + .reply(Message::user().with_text("hi"), session_config, None) + .await?; + tokio::pin!(reply_stream); + while let Some(event) = reply_stream.next().await { + event?; + } + + assert_eq!( + provider.call_count.load(Ordering::SeqCst), + 1, + "a refused request must not be resent" + ); + Ok(()) + } + + async fn create_test_agent( + data_dir: PathBuf, + hook_manager: crate::hooks::HookManager, + provider: Arc, + ) -> Result<(Agent, String)> { + let session_manager = Arc::new(SessionManager::new(data_dir.clone())); + let permission_manager = Arc::new(PermissionManager::new(data_dir)); let config = AgentConfig::new( session_manager.clone(), permission_manager, @@ -3183,19 +3383,28 @@ exit 0 GoosePlatform::GooseCli, ); let mut agent = Agent::with_config(config); - agent.set_hook_manager_for_test(env.hook_manager()); - agent.set_stop_hook_block_cap_for_test(stop_hook_block_cap); - let provider = Arc::new(CountingTextProvider::new()); + agent.set_hook_manager_for_test(hook_manager); let session = session_manager .create_session( PathBuf::default(), - "stop-hook-test".to_string(), + "test".to_string(), SessionType::Hidden, GooseMode::Auto, ) .await?; - agent.update_provider(provider.clone(), &session.id).await?; - Ok((agent, session.id, provider)) + agent.update_provider(provider, &session.id).await?; + Ok((agent, session.id)) + } + + async fn create_stop_hook_test_agent( + env: &StopHookTestEnv, + stop_hook_block_cap: u32, + ) -> Result<(Agent, String, Arc)> { + let provider = Arc::new(CountingTextProvider::new()); + let (mut agent, session_id) = + create_test_agent(env.data_dir(), env.hook_manager(), provider.clone()).await?; + agent.set_stop_hook_block_cap_for_test(stop_hook_block_cap); + Ok((agent, session_id, provider)) } async fn run_stop_hook_test_turn( @@ -3363,6 +3572,25 @@ exit 0 Ok(()) } + #[tokio::test] + async fn discard_pending_steers_clears_queued_messages() { + let agent = Agent::new(); + let session_id = "session-discard"; + + agent + .steer(session_id, Message::user().with_text("queued steer")) + .await; + assert!(agent.has_pending_steers(session_id).await); + + agent.discard_pending_steers(session_id).await; + + assert!( + !agent.has_pending_steers(session_id).await, + "discarding must drop steers orphaned by a cancelled run so they cannot leak into a later prompt" + ); + assert!(agent.drain_pending_steers(session_id).await.is_empty()); + } + #[test] fn categorize_tool_recognizes_conventional_names() { assert_eq!(categorize_tool("developer__shell"), ToolCategory::Shell); diff --git a/crates/goose/src/agents/platform_extensions/analyze/mod.rs b/crates/goose/src/agents/platform_extensions/analyze/mod.rs index a65874317ea6..c044e0a9cbe2 100644 --- a/crates/goose/src/agents/platform_extensions/analyze/mod.rs +++ b/crates/goose/src/agents/platform_extensions/analyze/mod.rs @@ -58,10 +58,15 @@ impl AnalyzeClient { let info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) .with_server_info(Implementation::new(EXTENSION_NAME, "1.0.0").with_title("Analyze")) .with_instructions(indoc! {" - Analyze code structure using tree-sitter AST parsing. Three auto-selected modes: - - Directory path → structure overview (file tree with function/class counts) - - File path → semantic details (functions, classes, imports, call counts) - - Any path + focus parameter → symbol call graph (incoming/outgoing chains) + Index code structure via tree-sitter. Use to navigate or summarize an unfamiliar + and large codebase. Returns one of three views: + + 1) Directory path → file tree with LOC, function, and class counts + (depth-limited). + 2) File path → list of functions (with signatures), classes, imports, + and call counts. Functions called >3x are marked •N. + 3) Any path + `focus` → incoming/outgoing call graph for a symbol + (case-sensitive). For large codebases, delegate analysis to a subagent and retain only the summary. "}); diff --git a/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__all_platform_extensions.snap b/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__all_platform_extensions.snap index 1ffd74ec38df..70e3f6932d13 100644 --- a/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__all_platform_extensions.snap +++ b/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__all_platform_extensions.snap @@ -39,10 +39,15 @@ Use list_resources and read_resource to work with extension data and resources. ## analyze ### Instructions -Analyze code structure using tree-sitter AST parsing. Three auto-selected modes: -- Directory path → structure overview (file tree with function/class counts) -- File path → semantic details (functions, classes, imports, call counts) -- Any path + focus parameter → symbol call graph (incoming/outgoing chains) +Index code structure via tree-sitter. Use to navigate or summarize an unfamiliar +and large codebase. Returns one of three views: + + 1) Directory path → file tree with LOC, function, and class counts + (depth-limited). + 2) File path → list of functions (with signatures), classes, imports, + and call counts. Functions called >3x are marked •N. + 3) Any path + `focus` → incoming/outgoing call graph for a symbol + (case-sensitive). For large codebases, delegate analysis to a subagent and retain only the summary. diff --git a/crates/goose/src/model.rs b/crates/goose/src/model.rs index 739777cad882..3c1c08a720c1 100644 --- a/crates/goose/src/model.rs +++ b/crates/goose/src/model.rs @@ -359,6 +359,47 @@ impl ModelConfig { self } + pub fn with_thinking_effort(mut self, effort: ThinkingEffort) -> Self { + let params = self.request_params.get_or_insert_with(HashMap::new); + params.insert( + "thinking_effort".to_string(), + serde_json::json!(effort.to_string()), + ); + self + } + + pub fn with_inherited_session_settings_from( + mut self, + previous: Option<&ModelConfig>, + request_params: Option>, + ) -> Self { + if let Some(previous) = previous { + let has_thinking_effort = self + .request_params + .as_ref() + .and_then(|params| params.get("thinking_effort")) + .is_some(); + + if !has_thinking_effort { + if let Some(thinking_effort) = previous + .request_params + .as_ref() + .and_then(|params| params.get("thinking_effort")) + .cloned() + { + let params = self.request_params.get_or_insert_with(HashMap::new); + params.insert("thinking_effort".to_string(), thinking_effort); + } + } + } + + if let Some(request_params) = request_params { + self = self.with_merged_request_params(request_params); + } + + self + } + pub fn use_fast_model(&self) -> Self { if let Some(fast_config) = &self.fast_model_config { *fast_config.clone() @@ -665,6 +706,143 @@ mod tests { assert_eq!(config.thinking_effort(), Some(ThinkingEffort::Low)); } + #[test] + fn with_thinking_effort_sets_request_param() { + let config = ModelConfig { + model_name: "test".to_string(), + ..Default::default() + } + .with_thinking_effort(ThinkingEffort::High); + + assert_eq!( + config + .request_params + .as_ref() + .and_then(|params| params.get("thinking_effort")), + Some(&serde_json::json!("high")) + ); + } + + #[test] + fn preserves_explicit_thinking_effort() { + let previous = ModelConfig { + model_name: "previous".to_string(), + request_params: Some(HashMap::from([( + "thinking_effort".to_string(), + serde_json::json!("high"), + )])), + ..Default::default() + }; + let config = ModelConfig { + model_name: "next".to_string(), + ..Default::default() + } + .with_inherited_session_settings_from(Some(&previous), None); + + assert_eq!( + config + .request_params + .as_ref() + .and_then(|params| params.get("thinking_effort")), + Some(&serde_json::json!("high")) + ); + } + + #[test] + fn does_not_override_existing_thinking_effort() { + let previous = ModelConfig { + model_name: "previous".to_string(), + request_params: Some(HashMap::from([( + "thinking_effort".to_string(), + serde_json::json!("high"), + )])), + ..Default::default() + }; + let config = ModelConfig { + model_name: "next".to_string(), + request_params: Some(HashMap::from([( + "thinking_effort".to_string(), + serde_json::json!("low"), + )])), + ..Default::default() + } + .with_inherited_session_settings_from(Some(&previous), None); + + assert_eq!( + config + .request_params + .as_ref() + .and_then(|params| params.get("thinking_effort")), + Some(&serde_json::json!("low")) + ); + } + + #[test] + fn does_not_preserve_unrelated_request_params() { + let previous = ModelConfig { + model_name: "previous".to_string(), + request_params: Some(HashMap::from([( + "provider_specific".to_string(), + serde_json::json!("old"), + )])), + ..Default::default() + }; + let config = ModelConfig { + model_name: "next".to_string(), + ..Default::default() + } + .with_inherited_session_settings_from(Some(&previous), None); + + assert!(config.request_params.is_none()); + } + + #[test] + fn does_not_materialize_env_thinking_effort() { + let _guard = env_lock::lock_env([("GOOSE_THINKING_EFFORT", Some("high"))]); + let previous = ModelConfig { + model_name: "previous".to_string(), + ..Default::default() + }; + let config = ModelConfig { + model_name: "next".to_string(), + ..Default::default() + } + .with_inherited_session_settings_from(Some(&previous), None); + + assert!(config.request_params.is_none()); + } + + #[test] + fn explicit_request_params_override_preserved_session_settings() { + let previous = ModelConfig { + model_name: "previous".to_string(), + request_params: Some(HashMap::from([( + "thinking_effort".to_string(), + serde_json::json!("high"), + )])), + ..Default::default() + }; + let config = ModelConfig { + model_name: "next".to_string(), + ..Default::default() + } + .with_inherited_session_settings_from( + Some(&previous), + Some(HashMap::from([( + "thinking_effort".to_string(), + serde_json::json!("low"), + )])), + ); + + assert_eq!( + config + .request_params + .as_ref() + .and_then(|params| params.get("thinking_effort")), + Some(&serde_json::json!("low")) + ); + } + #[test] fn legacy_claude_thinking_type_fallback() { for value in ["enabled", "adaptive"] { diff --git a/crates/goose/src/providers/anthropic.rs b/crates/goose/src/providers/anthropic.rs index 37f1290578b7..0a659fee3359 100644 --- a/crates/goose/src/providers/anthropic.rs +++ b/crates/goose/src/providers/anthropic.rs @@ -379,7 +379,7 @@ impl Provider for AnthropicProvider { let message_stream = response_to_streaming_message(framed); pin!(message_stream); while let Some(message) = futures::StreamExt::next(&mut message_stream).await { - let (message, usage) = message.map_err(|e| ProviderError::RequestFailed(format!("Stream decode error: {}", e)))?; + let (message, usage) = message.map_err(ProviderError::from_stream_error)?; log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?; yield (message, usage); } diff --git a/crates/goose/src/providers/databricks_v2.rs b/crates/goose/src/providers/databricks_v2.rs index 15b715ef4dc4..56a20e24b94c 100644 --- a/crates/goose/src/providers/databricks_v2.rs +++ b/crates/goose/src/providers/databricks_v2.rs @@ -328,7 +328,7 @@ impl DatabricksV2Provider { let message_stream = anthropic::response_to_streaming_message(framed); pin!(message_stream); while let Some(message) = futures::StreamExt::next(&mut message_stream).await { - let (message, usage) = message.map_err(|e| ProviderError::RequestFailed(format!("Stream decode error: {e}")))?; + let (message, usage) = message.map_err(ProviderError::from_stream_error)?; log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?; yield (message, usage); } diff --git a/crates/goose/src/providers/formats/anthropic.rs b/crates/goose/src/providers/formats/anthropic.rs index ac7b50675d7a..b71788cf7843 100644 --- a/crates/goose/src/providers/formats/anthropic.rs +++ b/crates/goose/src/providers/formats/anthropic.rs @@ -118,6 +118,8 @@ const EVENT_MESSAGE_STOP: &str = "message_stop"; const EVENT_CONTENT_BLOCK_START: &str = "content_block_start"; const EVENT_CONTENT_BLOCK_DELTA: &str = "content_block_delta"; const EVENT_CONTENT_BLOCK_STOP: &str = "content_block_stop"; +const STOP_REASON_REFUSAL: &str = "refusal"; +const REFUSAL_FALLBACK_DETAILS: &str = "No additional details were provided."; /// Coerce a tool call's optional arguments into the JSON value Anthropic /// expects for the `input` field of a `tool_use` content block. @@ -890,6 +892,33 @@ where final_usage = Some(ProviderUsage::new(model, delta_usage)); } } + if let Some(delta) = event.data.get("delta") { + let stop_details = delta.get("stop_details").filter(|d| !d.is_null()); + if delta.get("stop_reason").and_then(|v| v.as_str()) == Some(STOP_REASON_REFUSAL) { + let str_field = |key: &str| stop_details + .and_then(|d| d.get(key)) + .and_then(|v| v.as_str()) + .map(str::to_string); + let details = str_field("explanation") + .or_else(|| stop_details.map(|d| d.to_string())) + .unwrap_or_else(|| REFUSAL_FALLBACK_DETAILS.to_string()); + let category = str_field("category"); + // The refusal delta carries the request's usage; + // flush it so refused turns are still accounted. + if let Some(usage) = final_usage.take() { + yield (None, Some(usage)); + } + Err(ProviderError::Refusal { details, category })?; + } else if let Some(details) = stop_details { + // No specific handling for these stop details yet — + // forward them rather than silently dropping the turn. + let mut message = Message::assistant().with_text(format!( + "The provider ended the response with: {details}" + )); + message.id = message_id.clone(); + yield (Some(message), None); + } + } continue; } EVENT_MESSAGE_STOP => { @@ -1576,16 +1605,10 @@ mod tests { } async fn collect_stream(events: &str) -> StreamedParts { - use futures::StreamExt; - - let lines: Vec> = - events.lines().map(|l| Ok(l.to_string())).collect(); - let stream = Box::pin(futures::stream::iter(lines)); - let mut msg_stream = std::pin::pin!(response_to_streaming_message(stream)); let mut parts = StreamedParts::default(); - while let Some(Ok((message, _usage))) = msg_stream.next().await { - if let Some(msg) = message { + for result in collect_stream_results(events).await { + if let Ok((Some(msg), _usage)) = result { for c in &msg.content { match c { MessageContent::Thinking(t) => { @@ -1734,4 +1757,89 @@ mod tests { assert_eq!(parts.text, vec!["Let me search for that."]); assert_eq!(parts.tool_calls, vec!["search"]); } + + async fn collect_stream_results( + events: &str, + ) -> Vec, Option)>> { + use futures::StreamExt; + + let lines: Vec> = + events.lines().map(|l| Ok(l.to_string())).collect(); + let stream = Box::pin(futures::stream::iter(lines)); + response_to_streaming_message(stream).collect().await + } + + fn expect_refusal( + results: Vec, Option)>>, + ) -> (String, Option) { + let err = results + .into_iter() + .find_map(|r| r.err()) + .expect("refusal should surface as a stream error"); + match err.downcast_ref::() { + Some(ProviderError::Refusal { details, category }) => { + (details.clone(), category.clone()) + } + other => panic!("expected ProviderError::Refusal, got {:?}", other), + } + } + + #[tokio::test] + async fn test_streaming_refusal() { + let events = concat!( + r#"data: {"type":"message_start","message":{"id":"msg_1","role":"assistant","content":[],"model":"claude-opus-4-6","usage":{"input_tokens":10,"output_tokens":0}}}"#, + "\n", + r#"data: {"type":"message_delta","delta":{"stop_reason":"refusal","stop_details":{"explanation":"This request violates the usage policy.","category":"cyber"}},"usage":{"output_tokens":5}}"#, + "\n", + r#"data: {"type":"message_stop"}"#, + ); + + let results = collect_stream_results(events).await; + let usage = results + .iter() + .filter_map(|r| r.as_ref().ok()) + .find_map(|(_, usage)| usage.clone()) + .expect("a refused request should still yield its usage"); + assert_eq!(usage.usage.input_tokens, Some(10)); + assert_eq!(usage.usage.output_tokens, Some(5)); + + let (details, category) = expect_refusal(results); + assert_eq!(details, "This request violates the usage policy."); + assert_eq!(category.as_deref(), Some("cyber")); + } + + #[tokio::test] + async fn test_streaming_refusal_forwards_unrecognized_stop_details() { + let events = concat!( + r#"data: {"type":"message_start","message":{"id":"msg_1","role":"assistant","content":[],"model":"claude-opus-4-6","usage":{"input_tokens":10,"output_tokens":0}}}"#, + "\n", + r#"data: {"type":"message_delta","delta":{"stop_reason":"refusal","stop_details":{"code":42}},"usage":{"output_tokens":5}}"#, + "\n", + r#"data: {"type":"message_stop"}"#, + ); + + let (details, category) = expect_refusal(collect_stream_results(events).await); + assert!(details.contains("\"code\":42"), "details: {details}"); + assert_eq!(category, None); + } + + #[tokio::test] + async fn test_streaming_forwards_unhandled_stop_details() { + let events = concat!( + r#"data: {"type":"message_start","message":{"id":"msg_1","role":"assistant","content":[],"model":"claude-opus-4-6","usage":{"input_tokens":10,"output_tokens":0}}}"#, + "\n", + r#"data: {"type":"message_delta","delta":{"stop_reason":"model_context_window_exceeded","stop_details":{"reason":"context_window"}},"usage":{"output_tokens":5}}"#, + "\n", + r#"data: {"type":"message_stop"}"#, + ); + + let parts = collect_stream(events).await; + assert_eq!(parts.text.len(), 1); + assert!( + parts.text[0].starts_with("The provider ended the response with:"), + "text: {}", + parts.text[0] + ); + assert!(parts.text[0].contains("context_window")); + } } diff --git a/crates/goose/src/providers/gcpvertexai.rs b/crates/goose/src/providers/gcpvertexai.rs index 8f215b925aea..7aba01fa3cfe 100644 --- a/crates/goose/src/providers/gcpvertexai.rs +++ b/crates/goose/src/providers/gcpvertexai.rs @@ -618,8 +618,7 @@ impl Provider for GcpVertexAIProvider { let mut message_stream = response_to_streaming_message(framed, &context_clone); while let Some(message) = message_stream.next().await { - let (message, usage) = message - .map_err(|e| ProviderError::RequestFailed(format!("Stream decode error: {}", e)))?; + let (message, usage) = message.map_err(ProviderError::from_stream_error)?; log.write(&message, usage.as_ref().map(|u| &u.usage))?; yield (message, usage); } diff --git a/crates/goose/src/providers/kimicode.rs b/crates/goose/src/providers/kimicode.rs index 5cb6e4275419..5f00521cef06 100644 --- a/crates/goose/src/providers/kimicode.rs +++ b/crates/goose/src/providers/kimicode.rs @@ -426,9 +426,7 @@ impl Provider for KimiCodeProvider { let message_stream = response_to_streaming_message(framed); pin!(message_stream); while let Some(message) = futures::StreamExt::next(&mut message_stream).await { - let (message, usage) = message.map_err(|e| { - ProviderError::RequestFailed(format!("Stream decode error: {}", e)) - })?; + let (message, usage) = message.map_err(ProviderError::from_stream_error)?; log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?; yield (message, usage); } diff --git a/crates/goose/src/session/chat_history_search.rs b/crates/goose/src/session/chat_history_search.rs index 22a89807e792..c06abb846459 100644 --- a/crates/goose/src/session/chat_history_search.rs +++ b/crates/goose/src/session/chat_history_search.rs @@ -302,137 +302,3 @@ impl<'a> ChatHistorySearch<'a> { } } } - -pub(crate) struct ChatSessionSearch<'a> { - pool: &'a Pool, - query: &'a str, - limit: usize, - after_date: Option>, - before_date: Option>, - exclude_session_id: Option, - session_types: Vec, -} - -impl<'a> ChatSessionSearch<'a> { - pub fn new( - pool: &'a Pool, - query: &'a str, - limit: Option, - after_date: Option>, - before_date: Option>, - exclude_session_id: Option, - session_types: Vec, - ) -> Self { - Self { - pool, - query, - limit: limit.unwrap_or(10), - after_date, - before_date, - exclude_session_id, - session_types, - } - } - - pub async fn execute(self) -> Result> { - let keywords = self.parse_keywords(); - if keywords.is_empty() { - return Ok(Vec::new()); - } - - let sql = self.build_sql(&keywords); - let mut query_builder = sqlx::query_scalar::<_, String>(&sql); - - for keyword in &keywords { - query_builder = query_builder.bind(keyword); - } - - if let Some(after) = self.after_date { - query_builder = query_builder.bind(after); - } - if let Some(before) = self.before_date { - query_builder = query_builder.bind(before); - } - - if let Some(exclude_id) = &self.exclude_session_id { - query_builder = query_builder.bind(exclude_id); - } - - for t in &self.session_types { - query_builder = query_builder.bind(t.to_string()); - } - - query_builder = query_builder.bind(self.limit as i64); - - Ok(query_builder.fetch_all(self.pool).await?) - } - - fn parse_keywords(&self) -> Vec { - self.query - .split_whitespace() - .map(|word| format!("%{}%", word.to_lowercase())) - .collect() - } - - fn build_sql(&self, keywords: &[String]) -> String { - let mut sql = String::from( - r#" - SELECT s.id - FROM sessions s - WHERE EXISTS ( - SELECT 1 - FROM messages m - WHERE m.session_id = s.id - AND EXISTS ( - SELECT 1 FROM json_each(m.content_json) - WHERE json_extract(value, '$.type') = 'text' - AND ( - "#, - ); - - for (i, _) in keywords.iter().enumerate() { - if i > 0 { - sql.push_str(" OR "); - } - sql.push_str("LOWER(json_extract(value, '$.text')) LIKE ?"); - } - - sql.push_str( - r#" - ) - ) - "#, - ); - - if self.after_date.is_some() { - sql.push_str(" AND m.timestamp >= ?"); - } - if self.before_date.is_some() { - sql.push_str(" AND m.timestamp <= ?"); - } - - sql.push_str( - r#" - ) - "#, - ); - - if self.exclude_session_id.is_some() { - sql.push_str(" AND s.id != ?"); - } - - if !self.session_types.is_empty() { - let placeholders: String = self - .session_types - .iter() - .map(|_| "?") - .collect::>() - .join(", "); - sql.push_str(&format!(" AND s.session_type IN ({})", placeholders)); - } - - sql.push_str(" ORDER BY s.updated_at DESC, s.id DESC LIMIT ?"); - - sql - } -} diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index f7205654ca6a..c216ada06dd1 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -301,13 +301,57 @@ pub(crate) struct SessionListPage { pub(crate) next_cursor: Option, } +#[derive(Debug, Default, Clone)] +pub(crate) struct SessionListFilters<'a> { + pub(crate) types: Option<&'a [SessionType]>, + pub(crate) working_dir: Option<&'a Path>, + pub(crate) keyword: Option<&'a str>, + pub(crate) only_sessions_with_messages: bool, +} + +#[derive(Debug, Clone)] +pub(crate) struct SessionListPageQuery<'a> { + pub(crate) filters: SessionListFilters<'a>, + pub(crate) cursor: Option<&'a SessionListCursor>, + pub(crate) page_size: usize, +} + #[derive(Debug, Default)] struct SessionListQuery<'a> { - types: Option<&'a [SessionType]>, - working_dir: Option<&'a Path>, + filters: SessionListFilters<'a>, cursor: Option<&'a SessionListCursor>, limit: Option, - require_messages: bool, +} + +fn keyword_terms(query: Option<&str>) -> Vec { + query + .unwrap_or_default() + .split_whitespace() + .map(|word| word.to_lowercase()) + .collect() +} + +fn message_keyword_clause(keyword_count: usize) -> String { + let keyword_clauses = (0..keyword_count) + .map(|_| "instr(LOWER(json_extract(value, '$.text')), ?) > 0") + .collect::>() + .join(" OR "); + + format!( + r#" + EXISTS ( + SELECT 1 + FROM messages mq + WHERE mq.session_id = s.id + AND EXISTS ( + SELECT 1 + FROM json_each(mq.content_json) + WHERE json_extract(value, '$.type') = 'text' + AND ({keyword_clauses}) + ) + ) + "# + ) } #[derive(Debug, Clone)] @@ -376,16 +420,11 @@ impl SessionManager { self.storage.list_sessions_by_types(Some(types)).await } - pub(crate) async fn list_nonempty_sessions_by_types_paged( + pub(crate) async fn list_sessions_paged( &self, - types: &[SessionType], - working_dir: Option<&Path>, - cursor: Option<&SessionListCursor>, - page_size: usize, + query: SessionListPageQuery<'_>, ) -> Result { - self.storage - .list_nonempty_sessions_by_types_paged(types, working_dir, cursor, page_size) - .await + self.storage.list_sessions_paged(query).await } pub async fn list_all_sessions(&self) -> Result> { @@ -487,27 +526,6 @@ impl SessionManager { .await } - pub async fn search_chat_sessions( - &self, - query: &str, - limit: Option, - after_date: Option>, - before_date: Option>, - exclude_session_id: Option, - session_types: Vec, - ) -> Result> { - self.storage - .search_chat_sessions( - query, - limit, - after_date, - before_date, - exclude_session_id, - session_types, - ) - .await - } - pub async fn update_message_metadata(id: &str, message_id: &str, f: F) -> Result<()> where F: FnOnce( @@ -1547,20 +1565,25 @@ impl SessionStorage { Self::replace_conversation_inner(pool, session_id, conversation).await } - async fn list_sessions_matching(&self, options: SessionListQuery<'_>) -> Result> { - if matches!(options.types, Some(types) if types.is_empty()) { + async fn list_sessions_matching(&self, query: SessionListQuery<'_>) -> Result> { + let filters = &query.filters; + if matches!(filters.types, Some(types) if types.is_empty()) { return Ok(Vec::new()); } + let keywords = keyword_terms(filters.keyword); let mut where_clauses = Vec::new(); - if let Some(types) = options.types { + if let Some(types) = filters.types { let placeholders = types.iter().map(|_| "?").collect::>().join(", "); where_clauses.push(format!("s.session_type IN ({})", placeholders)); } - if options.working_dir.is_some() { + if filters.working_dir.is_some() { where_clauses.push("s.working_dir = ?".to_string()); } - if options.cursor.is_some() { + if !keywords.is_empty() { + where_clauses.push(message_keyword_clause(keywords.len())); + } + if query.cursor.is_some() { where_clauses.push( "(datetime(s.updated_at) < datetime(?) \ OR (datetime(s.updated_at) = datetime(?) AND s.id < ?))" @@ -1573,23 +1596,19 @@ impl SessionStorage { } else { format!("WHERE {}", where_clauses.join(" AND ")) }; - let message_join = if options.require_messages { + let message_join = if filters.only_sessions_with_messages { "JOIN messages m ON s.id = m.session_id" } else { "LEFT JOIN messages m ON s.id = m.session_id" }; - let order_by = if options.cursor.is_some() || options.limit.is_some() { + let order_by = if query.cursor.is_some() || query.limit.is_some() { "ORDER BY datetime(s.updated_at) DESC, s.id DESC" } else { "ORDER BY s.updated_at DESC" }; - let limit_clause = if options.limit.is_some() { - "LIMIT ?" - } else { - "" - }; + let limit_clause = if query.limit.is_some() { "LIMIT ?" } else { "" }; - let query = format!( + let sql = format!( r#" SELECT s.id, s.working_dir, s.name, s.description, s.user_set_name, s.session_type, s.created_at, s.updated_at, s.extension_data, s.total_tokens, s.input_tokens, s.output_tokens, @@ -1609,23 +1628,26 @@ impl SessionStorage { message_join, where_clause, order_by, limit_clause ); - let mut q = sqlx::query_as::<_, Session>(&query); - if let Some(types) = options.types { + let mut q = sqlx::query_as::<_, Session>(&sql); + if let Some(types) = filters.types { for session_type in types { q = q.bind(session_type.to_string()); } } - if let Some(working_dir) = options.working_dir { + if let Some(working_dir) = filters.working_dir { q = q.bind(working_dir.to_string_lossy().to_string()); } - if let Some(cursor) = options.cursor { + for term in keywords { + q = q.bind(term); + } + if let Some(cursor) = query.cursor { let updated_at = cursor.updated_at.to_rfc3339(); // Normalize mixed SQLite CURRENT_TIMESTAMP and RFC3339 stored values. q = q.bind(updated_at.clone()); q = q.bind(updated_at); q = q.bind(&cursor.session_id); } - if let Some(limit) = options.limit { + if let Some(limit) = query.limit { q = q.bind(limit as i64); } @@ -1635,33 +1657,32 @@ impl SessionStorage { async fn list_sessions_by_types(&self, types: Option<&[SessionType]>) -> Result> { self.list_sessions_matching(SessionListQuery { - types, + filters: SessionListFilters { + types, + ..Default::default() + }, ..Default::default() }) .await } - async fn list_nonempty_sessions_by_types_paged( + async fn list_sessions_paged( &self, - types: &[SessionType], - working_dir: Option<&Path>, - cursor: Option<&SessionListCursor>, - page_size: usize, + query: SessionListPageQuery<'_>, ) -> Result { - if types.is_empty() || page_size == 0 { + if matches!(query.filters.types, Some(types) if types.is_empty()) || query.page_size == 0 { return Ok(SessionListPage { sessions: Vec::new(), next_cursor: None, }); } + let page_size = query.page_size; let mut sessions = self .list_sessions_matching(SessionListQuery { - types: Some(types), - working_dir, - cursor, + filters: query.filters, + cursor: query.cursor, limit: Some(page_size + 1), - require_messages: true, }) .await?; let has_next_page = sessions.len() > page_size; @@ -1882,41 +1903,6 @@ impl SessionStorage { .await } - async fn search_chat_sessions( - &self, - query: &str, - limit: Option, - after_date: Option>, - before_date: Option>, - exclude_session_id: Option, - session_types: Vec, - ) -> Result> { - use crate::session::chat_history_search::ChatSessionSearch; - - let pool = self.pool().await?; - let session_ids = ChatSessionSearch::new( - pool, - query, - limit, - after_date, - before_date, - exclude_session_id, - session_types, - ) - .execute() - .await?; - - let mut sessions = Vec::with_capacity(session_ids.len()); - for session_id in session_ids { - match self.get_session(&session_id, false).await { - Ok(session) => sessions.push(session), - Err(err) if err.to_string() == "Session not found" => continue, - Err(err) => return Err(err), - } - } - Ok(sessions) - } - async fn update_message_metadata( &self, session_id: &str, @@ -2068,6 +2054,18 @@ mod tests { session.id } + async fn create_session_for_list_with_message( + sm: &SessionManager, + working_dir: &str, + message: &str, + ) -> String { + let session_id = create_session_for_list(sm, working_dir, false).await; + sm.add_message(&session_id, &Message::user().with_text(message)) + .await + .unwrap(); + session_id + } + async fn set_sessions_updated_at( sm: &SessionManager, session_ids: &[String], @@ -2174,132 +2172,6 @@ mod tests { assert_eq!(results.results[0].messages.len(), 2); } - #[tokio::test] - async fn test_search_chat_sessions_limits_distinct_sessions() { - let temp_dir = TempDir::new().unwrap(); - let sm = SessionManager::new(temp_dir.path().to_path_buf()); - - let older_target = create_search_session( - &sm, - "Older target", - SessionType::User, - "2026-05-01T00:00:00Z", - &[( - "does Acme have an email address for John Doe", - "2026-05-01T00:00:00Z", - )], - ) - .await; - - let newer_noise = create_search_session( - &sm, - "Newer noise", - SessionType::User, - "2026-05-22T00:00:00Z", - &[ - ("Acme person name looking for Acme", "2026-05-22T00:00:00Z"), - ( - "another Acme person name looking for Acme", - "2026-05-22T00:01:00Z", - ), - ], - ) - .await; - - let results = sm - .search_chat_sessions("Acme", Some(2), None, None, None, vec![SessionType::User]) - .await - .unwrap(); - let ids = results - .iter() - .map(|session| session.id.clone()) - .collect::>(); - - assert_eq!(ids, vec![newer_noise, older_target]); - } - - #[tokio::test] - async fn test_search_chat_sessions_applies_all_filters() { - let temp_dir = TempDir::new().unwrap(); - let sm = SessionManager::new(temp_dir.path().to_path_buf()); - - let excluded = create_search_session( - &sm, - "Excluded user", - SessionType::User, - "2026-05-20T00:00:00Z", - &[("Acme John excluded session", "2026-05-15T00:00:00Z")], - ) - .await; - - let scheduled_target = create_search_session( - &sm, - "Scheduled target", - SessionType::Scheduled, - "2026-05-19T00:00:00Z", - &[( - "John appears in scheduled Acme work", - "2026-05-16T00:00:00Z", - )], - ) - .await; - - let user_target = create_search_session( - &sm, - "User target", - SessionType::User, - "2026-05-18T00:00:00Z", - &[( - "Acme has an email address question for John Doe", - "2026-05-14T00:00:00Z", - )], - ) - .await; - - let _before_window = create_search_session( - &sm, - "Before window", - SessionType::User, - "2026-05-17T00:00:00Z", - &[("Acme John before date window", "2026-05-09T00:00:00Z")], - ) - .await; - - let _wrong_type = create_search_session( - &sm, - "ACP target", - SessionType::Acp, - "2026-05-16T00:00:00Z", - &[("Acme John wrong session type", "2026-05-15T00:00:00Z")], - ) - .await; - - let after = chrono::DateTime::parse_from_rfc3339("2026-05-10T00:00:00Z") - .unwrap() - .with_timezone(&chrono::Utc); - let before = chrono::DateTime::parse_from_rfc3339("2026-05-17T00:00:00Z") - .unwrap() - .with_timezone(&chrono::Utc); - - let results = sm - .search_chat_sessions( - "Acme John", - Some(10), - Some(after), - Some(before), - Some(excluded), - vec![SessionType::User, SessionType::Scheduled], - ) - .await - .unwrap(); - let ids = results - .iter() - .map(|session| session.id.clone()) - .collect::>(); - - assert_eq!(ids, vec![scheduled_target, user_target]); - } - async fn expected_session_list_ids(sm: &SessionManager, session_ids: &[String]) -> Vec { let mut sessions = Vec::new(); for session_id in session_ids { @@ -2321,13 +2193,18 @@ mod tests { expected_ids: &[String], expected_next_cursor: bool, ) -> Option { + let types = [SessionType::User]; let page = sm - .list_nonempty_sessions_by_types_paged( - &[SessionType::User], - working_dir.map(Path::new), + .list_sessions_paged(SessionListPageQuery { + filters: SessionListFilters { + types: Some(&types), + working_dir: working_dir.map(Path::new), + only_sessions_with_messages: true, + ..Default::default() + }, cursor, page_size, - ) + }) .await .unwrap(); let ids = page @@ -2496,6 +2373,229 @@ mod tests { .await; } + #[tokio::test] + async fn test_session_list_paged_filters_by_keyword() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let target = create_session_for_list_with_message( + &sm, + "/tmp/session-list", + "Discuss Postgres migrations", + ) + .await; + create_session_for_list_with_message(&sm, "/tmp/session-list", "Plan the mobile release") + .await; + + let types = [SessionType::User]; + let page = sm + .list_sessions_paged(SessionListPageQuery { + filters: SessionListFilters { + types: Some(&types), + keyword: Some("postgres"), + only_sessions_with_messages: true, + ..Default::default() + }, + cursor: None, + page_size: 10, + }) + .await + .unwrap(); + let ids = page + .sessions + .iter() + .map(|session| session.id.clone()) + .collect::>(); + + assert_eq!(ids, vec![target]); + assert!(page.next_cursor.is_none()); + } + + #[tokio::test] + async fn test_session_list_paged_keyword_uses_or_terms() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let postgres = create_session_for_list_with_message( + &sm, + "/tmp/session-list", + "Postgres migration plan", + ) + .await; + let sqlite = + create_session_for_list_with_message(&sm, "/tmp/session-list", "SQLite backup notes") + .await; + create_session_for_list_with_message(&sm, "/tmp/session-list", "Mobile release notes") + .await; + let expected_ids = expected_session_list_ids(&sm, &[postgres, sqlite]).await; + + let types = [SessionType::User]; + let page = sm + .list_sessions_paged(SessionListPageQuery { + filters: SessionListFilters { + types: Some(&types), + keyword: Some("postgres sqlite"), + only_sessions_with_messages: true, + ..Default::default() + }, + cursor: None, + page_size: 10, + }) + .await + .unwrap(); + let ids = page + .sessions + .iter() + .map(|session| session.id.clone()) + .collect::>(); + + assert_eq!(ids, expected_ids); + assert!(page.next_cursor.is_none()); + } + + #[tokio::test] + async fn test_session_list_paged_empty_keyword_matches_plain_list() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let expected_ids = vec![ + create_session_for_list_with_message(&sm, "/tmp/session-list", "first message").await, + create_session_for_list_with_message(&sm, "/tmp/session-list", "second message").await, + ]; + let expected_ids = expected_session_list_ids(&sm, &expected_ids).await; + + let types = [SessionType::User]; + let page = sm + .list_sessions_paged(SessionListPageQuery { + filters: SessionListFilters { + types: Some(&types), + keyword: Some(" "), + only_sessions_with_messages: true, + ..Default::default() + }, + cursor: None, + page_size: 10, + }) + .await + .unwrap(); + let ids = page + .sessions + .iter() + .map(|session| session.id.clone()) + .collect::>(); + + assert_eq!(ids, expected_ids); + } + + #[tokio::test] + async fn test_session_list_paged_keyword_treats_like_wildcards_as_literals() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let percent_id = + create_session_for_list_with_message(&sm, "/tmp/session-list", "Deploy is 100% done") + .await; + let underscore_id = create_session_for_list_with_message( + &sm, + "/tmp/session-list", + "feature_flag is enabled", + ) + .await; + create_session_for_list_with_message(&sm, "/tmp/session-list", "plain message").await; + + let types = [SessionType::User]; + let percent_page = sm + .list_sessions_paged(SessionListPageQuery { + filters: SessionListFilters { + types: Some(&types), + keyword: Some("%"), + only_sessions_with_messages: true, + ..Default::default() + }, + cursor: None, + page_size: 10, + }) + .await + .unwrap(); + let percent_ids = percent_page + .sessions + .iter() + .map(|session| session.id.clone()) + .collect::>(); + assert_eq!(percent_ids, vec![percent_id]); + + let underscore_page = sm + .list_sessions_paged(SessionListPageQuery { + filters: SessionListFilters { + types: Some(&types), + keyword: Some("_"), + only_sessions_with_messages: true, + ..Default::default() + }, + cursor: None, + page_size: 10, + }) + .await + .unwrap(); + let underscore_ids = underscore_page + .sessions + .iter() + .map(|session| session.id.clone()) + .collect::>(); + assert_eq!(underscore_ids, vec![underscore_id]); + } + + #[tokio::test] + async fn test_session_list_paged_keyword_combines_with_cwd_and_pagination() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let expected_ids = vec![ + create_session_for_list_with_message(&sm, "/tmp/session-list/a", "Postgres plan one") + .await, + create_session_for_list_with_message(&sm, "/tmp/session-list/a", "Postgres plan two") + .await, + ]; + create_session_for_list_with_message(&sm, "/tmp/session-list/a", "Mobile release").await; + create_session_for_list_with_message(&sm, "/tmp/session-list/b", "Postgres plan other") + .await; + let expected_ids = expected_session_list_ids(&sm, &expected_ids).await; + + let types = [SessionType::User]; + let filters = SessionListFilters { + types: Some(&types), + working_dir: Some(Path::new("/tmp/session-list/a")), + keyword: Some("postgres"), + only_sessions_with_messages: true, + }; + let cursor = sm + .list_sessions_paged(SessionListPageQuery { + filters: filters.clone(), + cursor: None, + page_size: 1, + }) + .await + .unwrap(); + let ids = cursor + .sessions + .iter() + .map(|session| session.id.clone()) + .collect::>(); + assert_eq!(ids, expected_ids[0..1]); + assert!(cursor.next_cursor.is_some()); + + let page = sm + .list_sessions_paged(SessionListPageQuery { + filters, + cursor: cursor.next_cursor.as_ref(), + page_size: 1, + }) + .await + .unwrap(); + let ids = page + .sessions + .iter() + .map(|session| session.id.clone()) + .collect::>(); + assert_eq!(ids, expected_ids[1..2]); + assert!(page.next_cursor.is_none()); + } + #[tokio::test] async fn test_concurrent_session_creation() { let temp_dir = TempDir::new().unwrap(); diff --git a/crates/goose/tests/acp_custom_requests_test.rs b/crates/goose/tests/acp_custom_requests_test.rs index cf3388417e67..5b8a336b9f67 100644 --- a/crates/goose/tests/acp_custom_requests_test.rs +++ b/crates/goose/tests/acp_custom_requests_test.rs @@ -2,6 +2,9 @@ #[path = "acp_common_tests/mod.rs"] mod common_tests; +use agent_client_protocol::schema::{ + ContentBlock, PromptRequest, SessionUpdate, StopReason, TextContent, +}; use common_tests::fixtures::server::AcpServerConnection; use common_tests::fixtures::{ run_test, send_custom, Connection, PermissionDecision, Session, SessionData, @@ -15,6 +18,7 @@ use goose_test_support::{EnforceSessionId, IgnoreSessionId}; use serial_test::serial; use std::path::PathBuf; use std::sync::{Arc, LazyLock, Mutex}; +use std::time::Duration; use common_tests::fixtures::OpenAiFixture; @@ -78,24 +82,86 @@ impl Provider for MockProvider { } } -fn mock_provider_factory() -> AcpProviderFactory { - Arc::new(|provider_name, model_config, _extensions, _working_dir| { - Box::pin(async move { - let recommended_models = match provider_name.as_str() { - "anthropic" => vec![ - "claude-3-7-sonnet-latest".to_string(), - "claude-3-5-haiku-latest".to_string(), - ], - _ => vec!["gpt-4o".to_string(), "o4-mini".to_string()], +fn active_run_id_from_update(update: &SessionUpdate) -> Option { + let SessionUpdate::SessionInfoUpdate(info) = update else { + return None; + }; + info.meta + .as_ref()? + .get("goose")? + .get("activeRunId")? + .as_str() + .map(ToString::to_string) +} + +fn queued_steer_message_ids(updates: &[SessionUpdate]) -> Vec { + updates + .iter() + .filter_map(|update| { + let SessionUpdate::SessionInfoUpdate(info) = update else { + return None; + }; + info.meta + .as_ref()? + .get("goose")? + .get("queuedSteer")? + .get("messageId")? + .as_str() + .map(ToString::to_string) + }) + .collect() +} + +fn steer_chunk_message_ids(updates: &[SessionUpdate]) -> Vec { + updates + .iter() + .filter_map(|update| { + let SessionUpdate::UserMessageChunk(chunk) = update else { + return None; }; - Ok(Arc::new(MockProvider { - name: provider_name, - model_config, - supported_models: recommended_models.clone(), - recommended_models, - }) as Arc) + let goose = chunk.meta.as_ref()?.get("goose")?; + goose.get("steer")?.as_bool().filter(|b| *b)?; + goose.get("messageId")?.as_str().map(ToString::to_string) + }) + .collect() +} + +fn steer_chunk_texts(updates: &[SessionUpdate]) -> Vec { + updates + .iter() + .filter_map(|update| { + // A steered message is a user message injected mid-run, so it must + // arrive as a UserMessageChunk (matching the replay path), never an + // AgentMessageChunk. + let SessionUpdate::UserMessageChunk(chunk) = update else { + return None; + }; + let ContentBlock::Text(text) = &chunk.content else { + return None; + }; + let is_steer = chunk + .meta + .as_ref() + .and_then(|m| m.get("goose")) + .and_then(|g| g.get("steer")) + .and_then(|s| s.as_bool()) + .unwrap_or(false); + is_steer.then(|| text.text.clone()) + }) + .collect() +} + +fn collect_agent_text(updates: &[SessionUpdate]) -> String { + updates + .iter() + .filter_map(|update| match update { + SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content { + ContentBlock::Text(text) => Some(text.text.as_str()), + _ => None, + }, + _ => None, }) - }) + .collect() } #[test] @@ -293,6 +359,121 @@ fn test_custom_get_available_extensions() { }); } +#[test] +#[serial] +fn test_steer_session_adds_input_to_active_prompt() { + write_acp_global_config(DEFAULT_ACP_TEST_CONFIG); + run_test(async move { + // Two-turn exchange: the first turn ends the turn with plain text. A + // steer queued before the turn ends keeps the loop alive (it flips + // `exit_chat` back to false), so a second provider request fires whose + // body must now contain the steered text. + let openai = OpenAiFixture::new( + vec![ + ( + "start work".to_string(), + include_str!("acp_test_data/openai_steer_first.txt"), + ), + ( + "steer while active".to_string(), + include_str!("acp_test_data/openai_steer_second.txt"), + ), + ], + Arc::new(IgnoreSessionId), + ) + .await; + let mut conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await; + + let SessionData { session, .. } = conn.new_session().await.unwrap(); + let session_id = session.session_id().0.to_string(); + let acp_session_id = session.session_id().clone(); + + let mut prompt = Box::pin( + conn.cx() + .send_request(PromptRequest::new( + acp_session_id, + vec![ContentBlock::Text(TextContent::new("start work"))], + )) + .block_task(), + ); + let mut steer_sent = false; + let mut steer_message_id: Option = None; + let mut final_response = None; + let deadline = tokio::time::Instant::now() + Duration::from_secs(3); + + while tokio::time::Instant::now() < deadline { + tokio::select! { + response = &mut prompt => { + final_response = Some(response.unwrap()); + break; + } + _ = tokio::time::sleep(Duration::from_millis(10)), if !steer_sent => { + let updates = session.session_updates(); + if let Some(run_id) = updates.iter().find_map(active_run_id_from_update) { + let response = send_custom( + conn.cx(), + "_goose/unstable/session/steer", + serde_json::json!({ + "sessionId": session_id, + "expectedRunId": run_id, + "prompt": [ + { "type": "text", "text": "steer while active" } + ] + }), + ) + .await + .unwrap(); + assert_eq!(response["runId"], run_id); + let mid = response["messageId"].as_str(); + assert!( + mid.is_some_and(|id| !id.is_empty()), + "steer response must return a messageId for correlation, got: {response:?}" + ); + steer_message_id = mid.map(ToString::to_string); + steer_sent = true; + } + } + } + } + + let response = final_response.expect("prompt did not complete"); + assert_eq!(response.stop_reason, StopReason::EndTurn); + assert!(steer_sent, "test never observed an active run id"); + + let updates = session.session_updates(); + let agent_text = collect_agent_text(&updates); + assert!( + agent_text.contains("saw steer"), + "expected provider to receive steered input, got: {agent_text:?}" + ); + + // The echoed steer prompt must be marked structurally so the client + // can locate the boundary without matching user-visible text. + let steer_chunks = steer_chunk_texts(&updates); + assert!( + steer_chunks + .iter() + .any(|t| t.contains("steer while active")), + "expected a chunk marked _meta.goose.steer with the steer text, got: {steer_chunks:?}" + ); + + // The queued steer must be announced (so a UI can show it as pending) + // and carry the same messageId returned by the steer response and later + // stamped on the picked-up UserMessageChunk. + let steer_message_id = steer_message_id.expect("steer response had no messageId"); + let queued_ids = queued_steer_message_ids(&updates); + assert!( + queued_ids.contains(&steer_message_id), + "expected a queuedSteer SessionInfoUpdate with messageId {steer_message_id:?}, got: {queued_ids:?}" + ); + let picked_up_ids = steer_chunk_message_ids(&updates); + assert!( + picked_up_ids.contains(&steer_message_id), + "picked-up steer chunk must carry the queued messageId {steer_message_id:?} for correlation, got: {picked_up_ids:?}" + ); + }); +} + #[test] #[serial] fn test_custom_list_builtin_skill_sources() { @@ -366,7 +547,7 @@ fn test_custom_provider_inventory_includes_metadata() { #[serial] fn test_custom_preferences_read_save_remove() { 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", + "GOOSE_MODEL: gpt-4o\nGOOSE_PROVIDER: openai\nGOOSE_AUTO_COMPACT_THRESHOLD: 0.7\nGOOSE_THINKING_EFFORT: high\nVOICE_AUTO_SUBMIT_PHRASES: send it\n", ); run_test(async move { @@ -383,6 +564,7 @@ fn test_custom_preferences_read_save_remove() { serde_json::json!({ "keys": [ "autoCompactThreshold", + "gooseThinkingEffort", "voiceAutoSubmitPhrases", "voiceDictationPreferredMic" ], @@ -394,6 +576,7 @@ fn test_custom_preferences_read_save_remove() { response.get("values"), Some(&serde_json::json!([ { "key": "autoCompactThreshold", "value": 0.7 }, + { "key": "gooseThinkingEffort", "value": "high" }, { "key": "voiceAutoSubmitPhrases", "value": "send it" }, { "key": "voiceDictationPreferredMic", "value": null }, ])) @@ -404,6 +587,7 @@ fn test_custom_preferences_read_save_remove() { "_goose/unstable/preferences/save", serde_json::json!({ "values": [ + { "key": "gooseThinkingEffort", "value": "disabled" }, { "key": "voiceDictationProvider", "value": "__disabled__" }, { "key": "voiceDictationPreferredMic", "value": "mic-1" } ], @@ -426,7 +610,7 @@ fn test_custom_preferences_read_save_remove() { conn.cx(), "_goose/unstable/preferences/read", serde_json::json!({ - "keys": ["voiceDictationProvider", "voiceDictationPreferredMic"], + "keys": ["gooseThinkingEffort", "voiceDictationProvider", "voiceDictationPreferredMic"], }), ) .await @@ -434,6 +618,7 @@ fn test_custom_preferences_read_save_remove() { assert_eq!( response.get("values"), Some(&serde_json::json!([ + { "key": "gooseThinkingEffort", "value": "off" }, { "key": "voiceDictationProvider", "value": null }, { "key": "voiceDictationPreferredMic", "value": "mic-1" }, ])) @@ -456,6 +641,12 @@ fn test_custom_preferences_save_rejects_invalid_values() { serde_json::json!({ "values": [{ "key": "autoCompactThreshold", "value": 1.1 }], }), + serde_json::json!({ + "values": [{ "key": "gooseThinkingEffort", "value": "bogus" }], + }), + serde_json::json!({ + "values": [{ "key": "gooseThinkingEffort", "value": ["high"] }], + }), serde_json::json!({ "values": [{ "key": "voiceAutoSubmitPhrases", "value": ["send"] }], }), @@ -658,11 +849,11 @@ fn test_raw_config_and_secret_methods_are_removed() { #[test] #[serial] fn test_provider_switching_updates_session_state() { + let _env = env_lock::lock_env([("ANTHROPIC_API_KEY", Some("test-key"))]); write_acp_global_config(DEFAULT_ACP_TEST_CONFIG); run_test(async { let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; let config = TestConnectionConfig { - provider_factory: Some(mock_provider_factory()), current_model: "gpt-4o".to_string(), ..Default::default() }; diff --git a/crates/goose/tests/acp_fixtures/mod.rs b/crates/goose/tests/acp_fixtures/mod.rs index 55370be5dbd0..fd5677da7572 100644 --- a/crates/goose/tests/acp_fixtures/mod.rs +++ b/crates/goose/tests/acp_fixtures/mod.rs @@ -302,6 +302,13 @@ pub fn to_notifications(updates: &[SessionUpdate]) -> Vec { SessionUpdate::ConfigOptionUpdate(_) => out.push(Notification::ConfigOption), SessionUpdate::SessionInfoUpdate(update) => { let meta = update.meta.as_ref(); + let is_active_run_update = meta + .and_then(|m| m.get("goose")) + .and_then(|g| g.get("activeRunId")) + .is_some(); + if is_active_run_update { + continue; + } out.push(Notification::SessionInfoUpdate { title: update.title.value().cloned(), updated_at: update.updated_at.value().cloned(), diff --git a/crates/goose/tests/acp_server_test.rs b/crates/goose/tests/acp_server_test.rs index 22cbfa32d76d..2407d2062b9e 100644 --- a/crates/goose/tests/acp_server_test.rs +++ b/crates/goose/tests/acp_server_test.rs @@ -1,10 +1,13 @@ #[allow(dead_code)] #[path = "acp_common_tests/mod.rs"] mod common_tests; -use agent_client_protocol::schema::{ListSessionsRequest, ListSessionsResponse}; +use agent_client_protocol::schema::{ + ListSessionsRequest, ListSessionsResponse, SessionConfigKind, SessionConfigOptionCategory, + SessionConfigOptionValue, SetSessionConfigOptionRequest, +}; use agent_client_protocol::ErrorCode; use common_tests::fixtures::server::AcpServerConnection; -use common_tests::fixtures::{run_test, Connection, OpenAiFixture, TestConnectionConfig}; +use common_tests::fixtures::{run_test, Connection, OpenAiFixture, Session, TestConnectionConfig}; #[cfg(feature = "code-mode")] use common_tests::run_prompt_codemode; use common_tests::{ @@ -21,6 +24,7 @@ use common_tests::{ }; use goose::config::GooseMode; use goose::conversation::message::Message; +use goose::custom_requests::{GetSessionInfoRequest, GetSessionInfoResponse}; use goose::session::{SessionManager, SessionType}; use std::path::Path; @@ -46,6 +50,29 @@ async fn seed_list_sessions(data_root: &Path, working_dir: &Path, count: usize) } } +async fn seed_list_session_with_message( + data_root: &Path, + working_dir: &Path, + name: &str, + session_type: SessionType, + message: &str, +) { + let session_manager = SessionManager::new(data_root.to_path_buf()); + let session = session_manager + .create_session( + working_dir.to_path_buf(), + name.to_string(), + session_type, + GooseMode::default(), + ) + .await + .unwrap(); + session_manager + .add_message(&session.id, &Message::user().with_text(message)) + .await + .unwrap(); +} + async fn new_connection(data_root: &Path) -> AcpServerConnection { let openai = OpenAiFixture::new( vec![], @@ -73,6 +100,17 @@ async fn list_sessions_request( .map_err(Into::into) } +async fn get_session_info_request( + conn: &AcpServerConnection, + request: GetSessionInfoRequest, +) -> anyhow::Result { + conn.cx() + .send_request(request) + .block_task() + .await + .map_err(Into::into) +} + fn assert_invalid_params(error: anyhow::Error) { let acp_error = error.downcast::().unwrap(); assert_eq!(acp_error.code, ErrorCode::InvalidParams); @@ -122,6 +160,106 @@ fn test_list_sessions_pagination() { }); } +#[test] +fn test_list_sessions_query_filters_results() { + run_test(async { + let data_root = tempfile::tempdir().unwrap(); + let cwd = Path::new("/tmp/acp-session-list"); + seed_list_session_with_message( + data_root.path(), + cwd, + "Postgres session", + SessionType::Acp, + "Discuss Postgres migrations", + ) + .await; + seed_list_session_with_message( + data_root.path(), + cwd, + "Mobile session", + SessionType::Acp, + "Plan the mobile release", + ) + .await; + let conn = new_connection(data_root.path()).await; + + let mut meta = serde_json::Map::new(); + meta.insert( + "query".to_string(), + serde_json::Value::String("postgres".to_string()), + ); + let response = list_sessions_request(&conn, ListSessionsRequest::new().meta(meta)) + .await + .unwrap(); + + assert_eq!(response.sessions.len(), 1); + assert_eq!( + response.sessions[0].title.as_deref(), + Some("Postgres session") + ); + assert!(response.next_cursor.is_none()); + }); +} + +#[test] +fn test_list_sessions_types_override_filters_results() { + run_test(async { + let data_root = tempfile::tempdir().unwrap(); + let cwd = Path::new("/tmp/acp-session-list"); + seed_list_session_with_message( + data_root.path(), + cwd, + "ACP session", + SessionType::Acp, + "ACP message", + ) + .await; + seed_list_session_with_message( + data_root.path(), + cwd, + "User session", + SessionType::User, + "User message", + ) + .await; + let conn = new_connection(data_root.path()).await; + + let mut meta = serde_json::Map::new(); + meta.insert( + "types".to_string(), + serde_json::Value::Array(vec![serde_json::Value::String("user".to_string())]), + ); + let response = list_sessions_request(&conn, ListSessionsRequest::new().meta(meta)) + .await + .unwrap(); + + assert_eq!(response.sessions.len(), 1); + assert_eq!(response.sessions[0].title.as_deref(), Some("User session")); + assert!(response.next_cursor.is_none()); + }); +} + +#[test] +fn test_list_sessions_types_rejects_internal_session_types() { + run_test(async { + let data_root = tempfile::tempdir().unwrap(); + let conn = new_connection(data_root.path()).await; + + for session_type in ["hidden", "sub_agent"] { + let mut meta = serde_json::Map::new(); + meta.insert( + "types".to_string(), + serde_json::Value::Array(vec![serde_json::Value::String(session_type.to_string())]), + ); + + let error = list_sessions_request(&conn, ListSessionsRequest::new().meta(meta)) + .await + .unwrap_err(); + assert_invalid_params(error); + } + }); +} + #[test] fn test_list_sessions_invalid_params() { run_test(async { @@ -161,6 +299,55 @@ fn test_list_sessions_invalid_params() { }); } +#[test] +fn test_get_session_info() { + run_test(async { + let data_root = tempfile::tempdir().unwrap(); + let cwd = Path::new("/tmp/acp-session-info"); + let session_manager = SessionManager::new(data_root.path().to_path_buf()); + let session = session_manager + .create_session( + cwd.to_path_buf(), + "Session info".to_string(), + SessionType::Acp, + GooseMode::default(), + ) + .await + .unwrap(); + session_manager + .add_message(&session.id, &Message::user().with_text("hello")) + .await + .unwrap(); + let conn = new_connection(data_root.path()).await; + + let response = get_session_info_request( + &conn, + GetSessionInfoRequest { + session_id: session.id.clone(), + }, + ) + .await + .unwrap(); + + assert_eq!( + response.session.session_id, + agent_client_protocol::schema::SessionId::new(session.id) + ); + assert_eq!(response.session.cwd, cwd.to_path_buf()); + assert_eq!(response.session.title.as_deref(), Some("Session info")); + assert!(response.session.updated_at.is_some()); + + let meta = response + .session + .meta + .expect("session info should include meta"); + assert!(meta.get("createdAt").and_then(|v| v.as_str()).is_some()); + assert_eq!(meta.get("messageCount"), Some(&serde_json::json!(1))); + assert_eq!(meta.get("userSetName"), Some(&serde_json::json!(false))); + assert_eq!(meta.get("hasRecipe"), Some(&serde_json::json!(false))); + }); +} + #[test] fn test_session_name_update_notification() { run_test(async { run_session_name_update_notification::().await }); @@ -176,6 +363,53 @@ fn test_config_option_model_set() { run_test(async { run_config_option_model_set::().await }); } +#[test] +fn test_config_option_thinking_effort_set() { + run_test(async { + let openai = OpenAiFixture::new( + vec![], + ::expected_session_id(), + ) + .await; + let mut conn = ::new( + TestConnectionConfig { + current_model: "claude-sonnet-4".to_string(), + ..Default::default() + }, + openai, + ) + .await; + let data = conn.new_session().await.unwrap(); + + let response = conn + .cx() + .send_request(SetSessionConfigOptionRequest::new( + data.session.session_id().clone(), + "thinking_effort".to_string(), + SessionConfigOptionValue::value_id("high".to_string()), + )) + .block_task() + .await + .unwrap(); + + let option = response + .config_options + .iter() + .find(|option| option.id.0.as_ref() == "thinking_effort") + .expect("thinking_effort option"); + assert_eq!( + option.category, + Some(SessionConfigOptionCategory::ThoughtLevel) + ); + let select = match &option.kind { + SessionConfigKind::Select(select) => select, + _ => panic!("thinking_effort should be a select option"), + }; + + assert_eq!(select.current_value.0.as_ref(), "high"); + }); +} + #[test] fn test_delete_session() { run_test(async { run_delete_session::().await }); diff --git a/crates/goose/tests/acp_test_data/openai_steer_first.txt b/crates/goose/tests/acp_test_data/openai_steer_first.txt new file mode 100644 index 000000000000..0e0f156c10bc --- /dev/null +++ b/crates/goose/tests/acp_test_data/openai_steer_first.txt @@ -0,0 +1,9 @@ +data: {"id":"chatcmpl-steer1","object":"chat.completion.chunk","created":1766229303,"model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} + +data: {"id":"chatcmpl-steer1","object":"chat.completion.chunk","created":1766229303,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"first response"},"finish_reason":null}]} + +data: {"id":"chatcmpl-steer1","object":"chat.completion.chunk","created":1766229303,"model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"id":"chatcmpl-steer1","object":"chat.completion.chunk","created":1766229303,"model":"gpt-4o","choices":[],"usage":{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110}} + +data: [DONE] diff --git a/crates/goose/tests/acp_test_data/openai_steer_second.txt b/crates/goose/tests/acp_test_data/openai_steer_second.txt new file mode 100644 index 000000000000..79d3b8a733d4 --- /dev/null +++ b/crates/goose/tests/acp_test_data/openai_steer_second.txt @@ -0,0 +1,9 @@ +data: {"id":"chatcmpl-steer2","object":"chat.completion.chunk","created":1766229304,"model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} + +data: {"id":"chatcmpl-steer2","object":"chat.completion.chunk","created":1766229304,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"saw steer"},"finish_reason":null}]} + +data: {"id":"chatcmpl-steer2","object":"chat.completion.chunk","created":1766229304,"model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"id":"chatcmpl-steer2","object":"chat.completion.chunk","created":1766229304,"model":"gpt-4o","choices":[],"usage":{"prompt_tokens":120,"completion_tokens":10,"total_tokens":130}} + +data: [DONE] diff --git a/crates/goose/tests/acp_transport_auth_test.rs b/crates/goose/tests/acp_transport_auth_test.rs new file mode 100644 index 000000000000..dd2af706eadc --- /dev/null +++ b/crates/goose/tests/acp_transport_auth_test.rs @@ -0,0 +1,114 @@ +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Method, Request, StatusCode}; +use axum::Router; +use goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig}; +use goose::acp::transport::create_router; +use goose::agents::GoosePlatform; +use tower::ServiceExt; + +const SECRET: &str = "test-secret-token"; + +fn test_router(require_token: bool, dir: &tempfile::TempDir) -> Router { + let server = Arc::new(AcpServer::new(AcpServerFactoryConfig { + builtins: vec![], + data_dir: dir.path().join("data"), + config_dir: dir.path().join("config"), + goose_platform: GoosePlatform::GooseCli, + additional_source_roots: Vec::new(), + })); + create_router(server, SECRET.to_string(), require_token) +} + +async fn send(router: &Router, method: Method, uri: &str, headers: &[(&str, &str)]) -> StatusCode { + let mut builder = Request::builder().method(method).uri(uri); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + let request = builder.body(Body::empty()).unwrap(); + router.clone().oneshot(request).await.unwrap().status() +} + +#[tokio::test] +async fn acp_requests_without_token_are_unauthorized() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(true, &dir); + + for method in [Method::GET, Method::POST, Method::DELETE] { + let status = send(&router, method.clone(), "/acp", &[]).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "method: {method}"); + } +} + +#[tokio::test] +async fn websocket_handshake_without_token_is_unauthorized() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(true, &dir); + + let status = send( + &router, + Method::GET, + "/acp", + &[ + ("connection", "upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGVzdGtleTEyMzQ1Njc4OQ=="), + ], + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn header_token_is_accepted() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(true, &dir); + + // 406 (missing Accept: text/event-stream) proves the request passed auth. + let status = send(&router, Method::GET, "/acp", &[("X-Secret-Key", SECRET)]).await; + assert_eq!(status, StatusCode::NOT_ACCEPTABLE); +} + +#[tokio::test] +async fn query_token_is_accepted() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(true, &dir); + + let uri = format!("/acp?token={SECRET}"); + let status = send(&router, Method::GET, &uri, &[]).await; + assert_eq!(status, StatusCode::NOT_ACCEPTABLE); +} + +#[tokio::test] +async fn wrong_token_is_unauthorized() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(true, &dir); + + let status = send(&router, Method::GET, "/acp", &[("X-Secret-Key", "nope")]).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + + let status = send(&router, Method::GET, "/acp?token=nope", &[]).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn health_endpoints_skip_token_check() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(true, &dir); + + for path in ["/health", "/status"] { + let status = send(&router, Method::GET, path, &[]).await; + assert_eq!(status, StatusCode::OK, "path: {path}"); + } +} + +#[tokio::test] +async fn acp_open_when_no_secret_configured() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(false, &dir); + + let status = send(&router, Method::GET, "/acp", &[]).await; + assert_eq!(status, StatusCode::NOT_ACCEPTABLE); +} diff --git a/documentation/blog/2025-04-22-mcp-is-rewriting-the-rules-of-api-integration/index.md b/documentation/blog/2025-04-22-mcp-is-rewriting-the-rules-of-api-integration/index.md index ddb6b23558a6..9ac122dfe626 100644 --- a/documentation/blog/2025-04-22-mcp-is-rewriting-the-rules-of-api-integration/index.md +++ b/documentation/blog/2025-04-22-mcp-is-rewriting-the-rules-of-api-integration/index.md @@ -135,7 +135,7 @@ Q: **How will MCP help with APIs?**
A: Start with [this post by Angie Jones](/blog/2025/02/17/agentic-ai-mcp/#mcp-ecosystem). MCP provides context about your API, to give AI Agents more context and awareness of the capabilities of your API endpoints and responses. This can help the Agent understand the intent of the request, and dynamically invoke (or "call") to underlying API endpoint, handle data transformation, and return a response. No more manually writing the code, response validators, error handlers, and so on! Q: **What are some initial steps I can take as a developer to explore AI agents and MCP?**
-A: Start by researching the fundamental concepts, and use other existing MCP servers. We recommend starting with [Goose](/) to integrate an existing MCP server. We have a growing [listof tutorials](/docs/category/mcp-servers) to help you find some technologies like GitHub, PostgreSQL, Google Maps, and more. Once you feel comfortable with using MCP, you can start building your own MCP server for your own APIs. +A: Start by researching the fundamental concepts, and use other existing MCP servers. We recommend starting with [Goose](/) to integrate an existing MCP server. We have a growing [list of tutorials](/docs/category/mcp-servers) to help you find some technologies like GitHub, PostgreSQL, Google Maps, and more. Once you feel comfortable with using MCP, you can start building your own MCP server for your own APIs. Q: **What about AI and MCP security?**
A: AI agents can enhance security through better context awareness in interactions, but MCP is still relatively new, and requires [careful security evaluations](/blog/2025/03/26/mcp-security/). Your business and dev teams should thoroughly investigate MCP's capabilities to ensure you're building appropriate access control, and managing data privacy. diff --git a/documentation/docs/guides/acp-clients.md b/documentation/docs/guides/acp-clients.md index ffa6518c8799..6a1e99952246 100644 --- a/documentation/docs/guides/acp-clients.md +++ b/documentation/docs/guides/acp-clients.md @@ -194,6 +194,18 @@ npm start -- --server http://HOST:PORT cargo run -p goose-cli --bin goose -- serve ``` +### Server Authentication + +Set the `GOOSE_SERVER__SECRET_KEY` environment variable to require authentication on the ACP endpoint. When it is set, `goose serve` rejects any request that doesn't present a matching token: + +```bash +GOOSE_SERVER__SECRET_KEY='a-long-random-secret' goose serve +``` + +Clients authenticate by sending the token in the `X-Secret-Key` header, or as a `?token=` query parameter for WebSocket connections (the browser WebSocket API can't set custom headers). Requests without a matching token receive `401 Unauthorized`, including WebSocket handshakes. + +When `GOOSE_SERVER__SECRET_KEY` is not set, the endpoint accepts unauthenticated connections and `goose serve` logs a warning at startup. + ### Single Prompt Mode Send a single prompt and exit (useful for scripting): diff --git a/documentation/docs/guides/environment-variables.md b/documentation/docs/guides/environment-variables.md index f63c35346290..2583445d3fa7 100644 --- a/documentation/docs/guides/environment-variables.md +++ b/documentation/docs/guides/environment-variables.md @@ -590,7 +590,7 @@ These variables configure the `goosed` server process. They are most often used | `GOOSE_HOST` | Interface the server binds to. Use `0.0.0.0` to accept connections from other machines; `localhost` or `127.0.0.1` restricts to the local machine. | Hostname or IP | `127.0.0.1` | | `GOOSE_PORT` | TCP port the server listens on | Port number | `3000` | | `GOOSE_TLS` | Enable TLS with a self-signed certificate. Required when connecting goose Desktop to a remote `goosed`. | `true`, `false` | `true` | -| `GOOSE_SERVER__SECRET_KEY` | Shared secret required in the `X-Secret-Key` header on all client requests | Secret string | Random (auto-generated) | +| `GOOSE_SERVER__SECRET_KEY` | Shared secret required in the `X-Secret-Key` header on all client requests. When set, it is also enforced on the `goose serve` ACP endpoint. | Secret string | Random (auto-generated) | **Examples** diff --git a/documentation/docs/guides/sessions/session-management.md b/documentation/docs/guides/sessions/session-management.md index 93d81ea813aa..b7ae3b702587 100644 --- a/documentation/docs/guides/sessions/session-management.md +++ b/documentation/docs/guides/sessions/session-management.md @@ -5,7 +5,7 @@ sidebar_label: Session Management --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -import { AppWindow, PanelLeft, FolderDot, Paperclip, Copy, Edit2, Trash2, Download, Upload, ChefHat } from 'lucide-react'; +import { AppWindow, PanelLeft, FolderDot, Paperclip, Copy, Edit2, Trash2, Download, Upload, ChefHat, History } from 'lucide-react'; A session is a single, continuous interaction between you and goose, providing a space to ask questions and prompt action. This guide covers how to manage the session lifecycle. @@ -86,15 +86,14 @@ In your first session, goose prompts you to [set up an LLM (Large Language Model You can edit session names after they're created: - 1. Click the button in the top-left to open the sidebar - 2. Click `View All` at the bottom of the `Chat` section - 3. Hover over the session you'd like to rename - 4. Click the button that appears on the session card - 5. In the "Edit Session Description" modal that opens: + 1. Click `Session History` in the sidebar + 2. Hover over the session you'd like to rename + 3. Click the button that appears on the session card + 4. In the "Edit Session Description" modal that opens: - Enter your new session description (up to 200 characters) - Press `Enter` to save or `Escape` to cancel - Or click the `Save` or `Cancel` buttons - 6. A success toast notification will confirm the change + 5. A success toast notification will confirm the change Session names appear in the `Chat` section of the sidebar, the `Window` menu, and the Dock (macOS) or taskbar (Windows) menu. @@ -182,11 +181,10 @@ Search allows you to find specific content within sessions or find specific sess To search conversation history across all your sessions: - 1. Click the button in the top-left to open the sidebar - 2. Click `View All` at the bottom of the `Chat` section - 3. Use `Cmd+F` (or `Ctrl+F`) to open the search bar - 4. Enter your search term - 5. Use keyboard shortcuts and search bar buttons to navigate the results (`Cmd+E` not supported) + 1. Click `Session History` in the sidebar + 2. Use `Cmd+F` (or `Ctrl+F`) to open the search bar + 3. Enter your search term + 4. Use keyboard shortcuts and search bar buttons to navigate the results (`Cmd+E` not supported) This searches the content of messages in your conversations. The search is limited to the 10 most recent matching messages across sessions. If your search term appears in many messages, the search will only return a subset of sessions. @@ -291,10 +289,9 @@ Search allows you to find specific content within sessions or find specific sess To find and resume sessions beyond your 10 most recent: - 1. Click the button in the top-left to open the sidebar - 2. Click `View All` at the bottom of the `Chat` section - 3. Find the session you'd like to resume. goose provides [search features](#search-sessions) to help you find the session. - 4. Choose how to resume: + 1. Click `Session History` in the sidebar + 2. Find the session you'd like to resume. goose provides [search features](#search-sessions) to help you find the session. + 3. Choose how to resume: - Click `Resume` to continue in the current window - Click `New Window` to open in a new window @@ -345,11 +342,10 @@ Create a complete copy of any session to reuse configurations, experiment with v Duplicate a session from the session list: - 1. Click the button in the top-left to open the sidebar - 2. Click `View All` at the bottom of the `Chat` section - 3. Find the session you want to duplicate - 4. Hover over the session card to reveal the action buttons - 5. Click the button that appears in the top-right corner + 1. Click `Session History` in the sidebar + 2. Find the session you want to duplicate + 3. Hover over the session card to reveal the action buttons + 4. Click the button that appears in the top-right corner The duplicated session includes: - Complete conversation history @@ -399,12 +395,11 @@ Create a complete copy of any session to reuse configurations, experiment with v You can delete sessions directly from the Desktop app: - 1. Click the button in the top-left to open the sidebar - 2. Click `View All` at the bottom of the `Chat` section - 3. Find the session you want to delete - 4. Hover over the session card to reveal the action buttons - 5. Click the button that appears - 6. Confirm the deletion in the modal that appears + 1. Click `Session History` in the sidebar + 2. Find the session you want to delete + 3. Hover over the session card to reveal the action buttons + 4. Click the button that appears + 5. Confirm the deletion in the modal that appears :::warning Permanent deletion Deleting a session from goose Desktop will also delete it from the CLI. This action cannot be undone. @@ -423,12 +418,11 @@ Create a complete copy of any session to reuse configurations, experiment with v Import complete sessions from JSON files to restore, share, or migrate sessions between goose instances. Importing creates a new session with a new ID rather than overwriting existing sessions. - 1. Click the button in the top-left to open the sidebar - 2. Click `View All` at the bottom of the `Chat` section - 3. Click the `Import Session` button in the top-right corner - 4. Select a `.json` session file that was previously exported from goose - 5. The session will be imported with a new session ID - 6. A success notification will confirm the import + 1. Click `Session History` in the sidebar + 2. Click the `Import Session` button in the top-right corner + 3. Select a `.json` session file that was previously exported from goose + 4. The session will be imported with a new session ID + 5. A success notification will confirm the import @@ -442,12 +436,11 @@ Create a complete copy of any session to reuse configurations, experiment with v Export complete sessions as JSON files for backup, sharing, migration, or archival. Exported files preserve all session data including conversation history, metadata, and settings. - 1. Click the button in the top-left to open the sidebar - 2. Click `View All` at the bottom of the `Chat` section - 3. Find the session you want to export - 4. Hover over the session card to reveal the action buttons - 5. Click the button that appears - 6. The session will be downloaded as a `.json` file named after the session description + 1. Click `Session History` in the sidebar + 2. Find the session you want to export + 3. Hover over the session card to reveal the action buttons + 4. Click the button that appears + 5. The session will be downloaded as a `.json` file named after the session description diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 3bd3b51992e8..bf8bb14a95d5 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3203,81 +3203,6 @@ } } }, - "/sessions": { - "get": { - "tags": [ - "Session Management" - ], - "operationId": "list_sessions", - "responses": { - "200": { - "description": "List of available sessions retrieved successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SessionListResponse" - } - } - } - }, - "401": { - "description": "Unauthorized - Invalid or missing API key" - }, - "500": { - "description": "Internal server error" - } - }, - "security": [ - { - "api_key": [] - } - ] - } - }, - "/sessions/import": { - "post": { - "tags": [ - "Session Management" - ], - "operationId": "import_session", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ImportSessionRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Session imported successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Session" - } - } - } - }, - "400": { - "description": "Bad request - Invalid JSON" - }, - "401": { - "description": "Unauthorized - Invalid or missing API key" - }, - "500": { - "description": "Internal server error" - } - }, - "security": [ - { - "api_key": [] - } - ] - } - }, "/sessions/import/nostr": { "post": { "tags": [ @@ -3322,116 +3247,6 @@ ] } }, - "/sessions/insights": { - "get": { - "tags": [ - "Session Management" - ], - "operationId": "get_session_insights", - "responses": { - "200": { - "description": "Session insights retrieved successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SessionInsights" - } - } - } - }, - "401": { - "description": "Unauthorized - Invalid or missing API key" - }, - "500": { - "description": "Internal server error" - } - }, - "security": [ - { - "api_key": [] - } - ] - } - }, - "/sessions/search": { - "get": { - "tags": [ - "Session Management" - ], - "operationId": "search_sessions", - "parameters": [ - { - "name": "query", - "in": "query", - "description": "Search query string", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "limit", - "in": "query", - "description": "Maximum results (default: 10, max: 50)", - "required": false, - "schema": { - "type": "integer", - "nullable": true, - "minimum": 0 - } - }, - { - "name": "after_date", - "in": "query", - "description": "Filter after date (ISO 8601)", - "required": false, - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "before_date", - "in": "query", - "description": "Filter before date (ISO 8601)", - "required": false, - "schema": { - "type": "string", - "nullable": true - } - } - ], - "responses": { - "200": { - "description": "Matching sessions", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Session" - } - } - } - } - }, - "400": { - "description": "Bad request - Invalid query" - }, - "401": { - "description": "Unauthorized" - }, - "500": { - "description": "Internal server error" - } - }, - "security": [ - { - "api_key": [] - } - ] - } - }, "/sessions/{id}/cancel": { "post": { "tags": [ @@ -3596,87 +3411,6 @@ "api_key": [] } ] - }, - "delete": { - "tags": [ - "Session Management" - ], - "operationId": "delete_session", - "parameters": [ - { - "name": "session_id", - "in": "path", - "description": "Unique identifier for the session", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Session deleted successfully" - }, - "401": { - "description": "Unauthorized - Invalid or missing API key" - }, - "404": { - "description": "Session not found" - }, - "500": { - "description": "Internal server error" - } - }, - "security": [ - { - "api_key": [] - } - ] - } - }, - "/sessions/{session_id}/export": { - "get": { - "tags": [ - "Session Management" - ], - "operationId": "export_session", - "parameters": [ - { - "name": "session_id", - "in": "path", - "description": "Unique identifier for the session", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Session exported successfully", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } - }, - "401": { - "description": "Unauthorized - Invalid or missing API key" - }, - "404": { - "description": "Session not found" - }, - "500": { - "description": "Internal server error" - } - }, - "security": [ - { - "api_key": [] - } - ] } }, "/sessions/{session_id}/extensions": { @@ -5928,17 +5662,6 @@ } } }, - "ImportSessionRequest": { - "type": "object", - "required": [ - "json" - ], - "properties": { - "json": { - "type": "string" - } - } - }, "InferenceMetadata": { "type": "object", "required": [ @@ -6571,6 +6294,10 @@ ], "nullable": true }, + "steer": { + "type": "boolean", + "description": "Whether this message is a steer injected into an active run. UI-only:\nsurfaced as `_meta.goose.steer` so clients can mark the steer boundary\nwithout matching user-visible text. Never sent to providers." + }, "userVisible": { "type": "boolean", "description": "Whether the message should be visible to the user in the UI" @@ -8395,38 +8122,6 @@ } } }, - "SessionInsights": { - "type": "object", - "required": [ - "totalSessions", - "totalTokens" - ], - "properties": { - "totalSessions": { - "type": "integer", - "minimum": 0 - }, - "totalTokens": { - "type": "integer", - "format": "int64" - } - } - }, - "SessionListResponse": { - "type": "object", - "required": [ - "sessions" - ], - "properties": { - "sessions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Session" - }, - "description": "List of available session information objects" - } - } - }, "SessionReplyRequest": { "type": "object", "required": [ diff --git a/ui/desktop/src/acp/sessions.ts b/ui/desktop/src/acp/sessions.ts index 9eeac367eee6..d48ce1b5966d 100644 --- a/ui/desktop/src/acp/sessions.ts +++ b/ui/desktop/src/acp/sessions.ts @@ -51,9 +51,27 @@ function sessionInfoToListItem(s: SessionInfo): SessionListItem { }; } -export async function acpListSessions(cursor?: string | null): Promise { +export interface SessionListFilter { + keyword?: string; +} + +const SESSION_LIST_TYPES = ['user', 'scheduled'] as const; + +export async function acpListSessions( + cursor?: string | null, + filter?: SessionListFilter +): Promise { const client = await getAcpClient(); - const request: ListSessionsRequest = cursor ? { cursor } : {}; + const request: ListSessionsRequest = {}; + if (cursor) { + request.cursor = cursor; + } + const meta: Record = { types: SESSION_LIST_TYPES }; + const keyword = filter?.keyword?.trim(); + if (keyword) { + meta.query = keyword; + } + request._meta = meta; const response = await client.listSessions(request); return { sessions: response.sessions.map(sessionInfoToListItem), @@ -67,7 +85,7 @@ export async function acpListRecentSessions(maxSessions: number): Promise = Options2 & { /** @@ -498,17 +498,6 @@ export const sessionsHandler = (options: O export const unpauseSchedule = (options: Options) => (options.client ?? client).post({ url: '/schedule/{id}/unpause', ...options }); -export const listSessions = (options?: Options) => (options?.client ?? client).get({ url: '/sessions', ...options }); - -export const importSession = (options: Options) => (options.client ?? client).post({ - url: '/sessions/import', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - export const importSessionNostr = (options: Options) => (options.client ?? client).post({ url: '/sessions/import/nostr', ...options, @@ -518,10 +507,6 @@ export const importSessionNostr = (options } }); -export const getSessionInsights = (options?: Options) => (options?.client ?? client).get({ url: '/sessions/insights', ...options }); - -export const searchSessions = (options: Options) => (options.client ?? client).get({ url: '/sessions/search', ...options }); - export const sessionCancel = (options: Options) => (options.client ?? client).post({ url: '/sessions/{id}/cancel', ...options, @@ -542,12 +527,8 @@ export const sessionReply = (options: Opti } }); -export const deleteSession = (options: Options) => (options.client ?? client).delete({ url: '/sessions/{session_id}', ...options }); - export const getSession = (options: Options) => (options.client ?? client).get({ url: '/sessions/{session_id}', ...options }); -export const exportSession = (options: Options) => (options.client ?? client).get({ url: '/sessions/{session_id}/export', ...options }); - export const getSessionExtensions = (options: Options) => (options.client ?? client).get({ url: '/sessions/{session_id}/extensions', ...options }); export const forkSession = (options: Options) => (options.client ?? client).post({ diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index fda990138938..ca49c97ee6de 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -608,10 +608,6 @@ export type ImportSessionNostrRequest = { deeplink: string; }; -export type ImportSessionRequest = { - json: string; -}; - export type InferenceMetadata = { provider: string; requestedModel: string; @@ -770,6 +766,12 @@ export type MessageMetadata = { */ agentVisible: boolean; inference?: InferenceMetadata | null; + /** + * Whether this message is a steer injected into an active run. UI-only: + * surfaced as `_meta.goose.steer` so clients can mark the steer boundary + * without matching user-visible text. Never sent to providers. + */ + steer?: boolean; /** * Whether the message should be visible to the user in the UI */ @@ -1375,18 +1377,6 @@ export type SessionExtensionsResponse = { extensions: Array; }; -export type SessionInsights = { - totalSessions: number; - totalTokens: number; -}; - -export type SessionListResponse = { - /** - * List of available session information objects - */ - sessions: Array; -}; - export type SessionReplyRequest = { override_conversation?: Array | null; /** @@ -4217,64 +4207,6 @@ export type UnpauseScheduleResponses = { export type UnpauseScheduleResponse = UnpauseScheduleResponses[keyof UnpauseScheduleResponses]; -export type ListSessionsData = { - body?: never; - path?: never; - query?: never; - url: '/sessions'; -}; - -export type ListSessionsErrors = { - /** - * Unauthorized - Invalid or missing API key - */ - 401: unknown; - /** - * Internal server error - */ - 500: unknown; -}; - -export type ListSessionsResponses = { - /** - * List of available sessions retrieved successfully - */ - 200: SessionListResponse; -}; - -export type ListSessionsResponse = ListSessionsResponses[keyof ListSessionsResponses]; - -export type ImportSessionData = { - body: ImportSessionRequest; - path?: never; - query?: never; - url: '/sessions/import'; -}; - -export type ImportSessionErrors = { - /** - * Bad request - Invalid JSON - */ - 400: unknown; - /** - * Unauthorized - Invalid or missing API key - */ - 401: unknown; - /** - * Internal server error - */ - 500: unknown; -}; - -export type ImportSessionResponses = { - /** - * Session imported successfully - */ - 200: Session; -}; - -export type ImportSessionResponse = ImportSessionResponses[keyof ImportSessionResponses]; - export type ImportSessionNostrData = { body: ImportSessionNostrRequest; path?: never; @@ -4306,81 +4238,6 @@ export type ImportSessionNostrResponses = { export type ImportSessionNostrResponse = ImportSessionNostrResponses[keyof ImportSessionNostrResponses]; -export type GetSessionInsightsData = { - body?: never; - path?: never; - query?: never; - url: '/sessions/insights'; -}; - -export type GetSessionInsightsErrors = { - /** - * Unauthorized - Invalid or missing API key - */ - 401: unknown; - /** - * Internal server error - */ - 500: unknown; -}; - -export type GetSessionInsightsResponses = { - /** - * Session insights retrieved successfully - */ - 200: SessionInsights; -}; - -export type GetSessionInsightsResponse = GetSessionInsightsResponses[keyof GetSessionInsightsResponses]; - -export type SearchSessionsData = { - body?: never; - path?: never; - query: { - /** - * Search query string - */ - query: string; - /** - * Maximum results (default: 10, max: 50) - */ - limit?: number | null; - /** - * Filter after date (ISO 8601) - */ - after_date?: string | null; - /** - * Filter before date (ISO 8601) - */ - before_date?: string | null; - }; - url: '/sessions/search'; -}; - -export type SearchSessionsErrors = { - /** - * Bad request - Invalid query - */ - 400: unknown; - /** - * Unauthorized - */ - 401: unknown; - /** - * Internal server error - */ - 500: unknown; -}; - -export type SearchSessionsResponses = { - /** - * Matching sessions - */ - 200: Array; -}; - -export type SearchSessionsResponse = SearchSessionsResponses[keyof SearchSessionsResponses]; - export type SessionCancelData = { body: CancelRequest; path: { @@ -4468,40 +4325,6 @@ export type SessionReplyResponses = { export type SessionReplyResponse2 = SessionReplyResponses[keyof SessionReplyResponses]; -export type DeleteSessionData = { - body?: never; - path: { - /** - * Unique identifier for the session - */ - session_id: string; - }; - query?: never; - url: '/sessions/{session_id}'; -}; - -export type DeleteSessionErrors = { - /** - * Unauthorized - Invalid or missing API key - */ - 401: unknown; - /** - * Session not found - */ - 404: unknown; - /** - * Internal server error - */ - 500: unknown; -}; - -export type DeleteSessionResponses = { - /** - * Session deleted successfully - */ - 200: unknown; -}; - export type GetSessionData = { body?: never; path: { @@ -4538,42 +4361,6 @@ export type GetSessionResponses = { export type GetSessionResponse = GetSessionResponses[keyof GetSessionResponses]; -export type ExportSessionData = { - body?: never; - path: { - /** - * Unique identifier for the session - */ - session_id: string; - }; - query?: never; - url: '/sessions/{session_id}/export'; -}; - -export type ExportSessionErrors = { - /** - * Unauthorized - Invalid or missing API key - */ - 401: unknown; - /** - * Session not found - */ - 404: unknown; - /** - * Internal server error - */ - 500: unknown; -}; - -export type ExportSessionResponses = { - /** - * Session exported successfully - */ - 200: string; -}; - -export type ExportSessionResponse = ExportSessionResponses[keyof ExportSessionResponses]; - export type GetSessionExtensionsData = { body?: never; path: { diff --git a/ui/desktop/src/components/conversation/SearchBar.tsx b/ui/desktop/src/components/conversation/SearchBar.tsx index 35c2ad7e760c..3d3964f37a58 100644 --- a/ui/desktop/src/components/conversation/SearchBar.tsx +++ b/ui/desktop/src/components/conversation/SearchBar.tsx @@ -49,6 +49,11 @@ interface SearchBarProps { initialSearchTerm?: string; /** Placeholder text for the search input */ placeholder?: string; + /** Show the case-sensitivity toggle (default: true). */ + showCaseSensitive?: boolean; + /** Show the previous/next match navigation arrows (default: true). When hidden, the + * result counter shows the total instead of "current/total". */ + showNavigation?: boolean; } /** @@ -62,6 +67,8 @@ export const SearchBar: React.FC = ({ inputRef: externalInputRef, initialSearchTerm = '', placeholder, + showCaseSensitive = true, + showNavigation = true, }: SearchBarProps) => { const intl = useIntl(); const resolvedPlaceholder = placeholder ?? intl.formatMessage(i18nMessages.defaultPlaceholder); @@ -197,52 +204,57 @@ export const SearchBar: React.FC = ({
- {(() => { - return localSearchResults?.count && localSearchResults.count > 0 && searchTerm - ? `${localSearchResults.currentIndex}/${localSearchResults.count}` - : null; - })()} + {showNavigation && + localSearchResults?.count && + localSearchResults.count > 0 && + searchTerm + ? `${localSearchResults.currentIndex}/${localSearchResults.count}` + : null}
- - -
- + {showCaseSensitive && ( -
+ )} + + {showNavigation && ( +
+ + +
+ )}
@@ -916,6 +847,17 @@ const SessionListView: React.FC = React.memo( } if (sessions.length === 0) { + // `sessions` holds the keyword-filtered set, so an empty result while searching + // means "no matches" rather than "no sessions at all". + if (debouncedSearchTerm) { + return ( +
+ +

{intl.formatMessage(i18n.noMatching)}

+

{intl.formatMessage(i18n.noMatchingDesc)}

+
+ ); + } return (
@@ -925,16 +867,6 @@ const SessionListView: React.FC = React.memo( ); } - if (dateGroups.length === 0 && searchResults !== null) { - return ( -
- -

{intl.formatMessage(i18n.noMatching)}

-

{intl.formatMessage(i18n.noMatchingDesc)}

-
- ); - } - return (
{visibleDateGroups.map((group) => ( @@ -1014,10 +946,11 @@ const SessionListView: React.FC = React.memo(
{/* Skeleton layer - always rendered but conditionally visible */}
{ - const sessionsResponse = await listSessions({ client: ctx.client }); - expect(sessionsResponse.response).toBeOkResponse(); - expect(sessionsResponse.data).toBeDefined(); - expect(sessionsResponse.data!.sessions).toBeDefined(); - expect(Array.isArray(sessionsResponse.data!.sessions)).toBe(true); - }); - it('should persist goose_mode on the session', async () => { await upsertConfig({ client: ctx.client, diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 6a5f09c20dd6..144d984ba365 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -404,8 +404,8 @@ importers: text: dependencies: '@aaif/goose-sdk': - specifier: workspace:* - version: link:../sdk + specifier: 0.20.2 + version: 0.20.2(@agentclientprotocol/sdk@0.19.0(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@agentclientprotocol/sdk': specifier: ^0.19.0 version: 0.19.0(zod@4.3.6) @@ -452,6 +452,41 @@ importers: packages: + '@aaif/goose-binary-darwin-arm64@0.20.2': + resolution: {integrity: sha512-0wv6UcYxg/dZXRuIUBTL+64Hz0D50PfUA2mJiUEBhWkBmiBCgS/Iymore0gYuayxVV7MzQhKrHqdDPBoo0lqIg==} + cpu: [arm64] + os: [darwin] + hasBin: true + + '@aaif/goose-binary-darwin-x64@0.20.2': + resolution: {integrity: sha512-dZG6H1QF/lv/kVc1YmHVKRGJCZtoe9o0N2q4vgvDK0Lz+ZJp/ehKcIxUSqE/zebXDqLb4yWByDGelq5Ud2rnCw==} + cpu: [x64] + os: [darwin] + hasBin: true + + '@aaif/goose-binary-linux-arm64@0.20.2': + resolution: {integrity: sha512-DLbm8r21v4c39dGbLepjXDmXNLsdWw0PFFpRbQtBd2m5Q+xIBgYOx1jNm3EygDQT9yGV9R3WS6SW3LvMJLA3xw==} + cpu: [arm64] + os: [linux] + hasBin: true + + '@aaif/goose-binary-linux-x64@0.20.2': + resolution: {integrity: sha512-+TgB2+MKj18kbw5PBnDMAWvuy8ZijNxXQL4qRBeMebcx07W3dIf6zoy9xD5mSyce61ugG9vsLt9a+3Pm8qZaRw==} + cpu: [x64] + os: [linux] + hasBin: true + + '@aaif/goose-binary-win32-x64@0.20.2': + resolution: {integrity: sha512-P0GrtagS0U/98XW4VuzQ77Fy02bw4wFhzyRWWbR9LDVwY+lLG+AkE5m0Ml/KGKgDWS6gd8BtkAqRxdFw3PM9RQ==} + cpu: [x64] + os: [win32] + hasBin: true + + '@aaif/goose-sdk@0.20.2': + resolution: {integrity: sha512-HhsspN1OycRqgKgnUBWC/5xOpJ8zuKWmABax0Br0ZfND94AKKZbsBlya8e+pSM+CvztWLXHRz2KxPQuXYoGyBQ==} + peerDependencies: + '@agentclientprotocol/sdk': ^0.19.0 + '@acemir/cssom@0.9.31': resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} @@ -7275,6 +7310,39 @@ packages: snapshots: + '@aaif/goose-binary-darwin-arm64@0.20.2': + optional: true + + '@aaif/goose-binary-darwin-x64@0.20.2': + optional: true + + '@aaif/goose-binary-linux-arm64@0.20.2': + optional: true + + '@aaif/goose-binary-linux-x64@0.20.2': + optional: true + + '@aaif/goose-binary-win32-x64@0.20.2': + optional: true + + '@aaif/goose-sdk@0.20.2(@agentclientprotocol/sdk@0.19.0(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@agentclientprotocol/sdk': 0.19.0(zod@4.3.6) + '@modelcontextprotocol/ext-apps': 0.3.1(@modelcontextprotocol/sdk@1.27.1(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) + zod: 3.25.76 + optionalDependencies: + '@aaif/goose-binary-darwin-arm64': 0.20.2 + '@aaif/goose-binary-darwin-x64': 0.20.2 + '@aaif/goose-binary-linux-arm64': 0.20.2 + '@aaif/goose-binary-linux-x64': 0.20.2 + '@aaif/goose-binary-win32-x64': 0.20.2 + transitivePeerDependencies: + - '@cfworker/json-schema' + - react + - react-dom + - supports-color + '@acemir/cssom@0.9.31': {} '@adobe/css-tools@4.4.4': {} diff --git a/ui/sdk/src/generated/client.gen.ts b/ui/sdk/src/generated/client.gen.ts index a11a411cd94c..72a22590f7df 100644 --- a/ui/sdk/src/generated/client.gen.ts +++ b/ui/sdk/src/generated/client.gen.ts @@ -52,6 +52,8 @@ import type { GetConfigExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, + GetSessionInfoRequest_unstable, + GetSessionInfoResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseSessionNotification_unstable, @@ -98,6 +100,8 @@ import type { RenameSessionRequest_unstable, SetConfigExtensionEnabledRequest_unstable, SetSessionSystemPromptRequest_unstable, + SteerSessionRequest_unstable, + SteerSessionResponse_unstable, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, @@ -120,6 +124,7 @@ import { zGetAvailableExtensionsResponse_unstable, zGetConfigExtensionsResponse_unstable, zGetSessionExtensionsResponse_unstable, + zGetSessionInfoResponse_unstable, zGetToolsResponse_unstable, zGooseSessionNotification_unstable, zGooseToolCallResponse_unstable, @@ -139,6 +144,7 @@ import { zProviderSupportedModelsListResponse_unstable, zReadResourceResponse_unstable, zRefreshProviderInventoryResponse_unstable, + zSteerSessionResponse_unstable, zUpdateSourceResponse_unstable, } from './zod.gen.js'; @@ -206,6 +212,18 @@ export class GooseExtClient { ); } + async sessionSteer_unstable( + params: SteerSessionRequest_unstable, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/unstable/session/steer", + params, + ); + return zSteerSessionResponse_unstable.parse( + raw, + ) as SteerSessionResponse_unstable; + } + async sessionDelete(params: DeleteSessionRequest): Promise { await this.conn.extMethod("session/delete", params); } @@ -546,6 +564,18 @@ export class GooseExtClient { ) as ImportSessionResponse_unstable; } + async sessionInfo_unstable( + params: GetSessionInfoRequest_unstable, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/unstable/session/info", + params, + ); + return zGetSessionInfoResponse_unstable.parse( + raw, + ) as GetSessionInfoResponse_unstable; + } + async elicitationRespond_unstable( params: ElicitationRespondRequest_unstable, ): Promise { diff --git a/ui/sdk/src/generated/index.ts b/ui/sdk/src/generated/index.ts index ea3d436b478d..77d32c437591 100644 --- a/ui/sdk/src/generated/index.ts +++ b/ui/sdk/src/generated/index.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { AddConfigExtensionRequest_unstable, AddExtensionRequest_unstable, ArchiveSessionRequest_unstable, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, ElicitationRespondRequest_unstable, EmptyResponse, EnvVariable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtNotification, ExtRequest, ExtResponse, GetAvailableExtensionsRequest_unstable, GetAvailableExtensionsResponse_unstable, GetConfigExtensionsRequest_unstable, GetConfigExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseExtension, GooseExtensionEntry, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, HttpHeader, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, Interaction, InteractionState, InteractionUpdate, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, McpServer, McpServerHttp, McpServerSse, McpServerStdio, 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, SetConfigExtensionEnabledRequest_unstable, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; +export type { AddConfigExtensionRequest_unstable, AddExtensionRequest_unstable, Annotations, ArchiveSessionRequest_unstable, AudioContent, BlobResourceContents, ContentBlock, 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, EmbeddedResource, EmbeddedResourceResource, EmptyResponse, EnvVariable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtNotification, ExtRequest, ExtResponse, GetAvailableExtensionsRequest_unstable, GetAvailableExtensionsResponse_unstable, GetConfigExtensionsRequest_unstable, GetConfigExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetSessionInfoRequest_unstable, GetSessionInfoResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseExtension, GooseExtensionEntry, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, HttpHeader, ImageContent, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, Interaction, InteractionState, InteractionUpdate, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, McpServer, McpServerHttp, McpServerSse, McpServerStdio, 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, ResourceLink, Role, SessionId, SessionInfo, SessionSystemPromptMode, SessionUsageUpdate, SetConfigExtensionEnabledRequest_unstable, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, SteerSessionRequest_unstable, SteerSessionResponse_unstable, TextContent, TextResourceContents, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; export const GOOSE_EXT_METHODS = [ { @@ -38,6 +38,11 @@ export const GOOSE_EXT_METHODS = [ requestType: "SetSessionSystemPromptRequest_unstable", responseType: "EmptyResponse", }, + { + method: "_goose/unstable/session/steer", + requestType: "SteerSessionRequest_unstable", + responseType: "SteerSessionResponse_unstable", + }, { method: "session/delete", requestType: "DeleteSessionRequest", @@ -193,6 +198,11 @@ export const GOOSE_EXT_METHODS = [ requestType: "ImportSessionRequest_unstable", responseType: "ImportSessionResponse_unstable", }, + { + method: "_goose/unstable/session/info", + requestType: "GetSessionInfoRequest_unstable", + responseType: "GetSessionInfoResponse_unstable", + }, { method: "_goose/unstable/elicitation/respond", requestType: "ElicitationRespondRequest_unstable", diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index bf7bf3112e2a..1216f16fb46b 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -109,6 +109,218 @@ export type SetSessionSystemPromptRequest_unstable = { */ export type SessionSystemPromptMode = 'set' | 'append'; +/** + * Add user input to the currently active prompt without starting a new prompt. + */ +export type SteerSessionRequest_unstable = { + sessionId: string; + prompt?: Array; + expectedRunId: string; +}; + +/** + * Content blocks represent displayable information in the Agent Client Protocol. + * + * They provide a structured way to handle various types of user-facing content—whether + * it's text from language models, images for analysis, or embedded resources for context. + * + * Content blocks appear in: + * - User prompts sent via `session/prompt` + * - Language model output streamed through `session/update` notifications + * - Progress updates and results from tool calls + * + * This structure is compatible with the Model Context Protocol (MCP), enabling + * agents to seamlessly forward content from MCP tool outputs without transformation. + * + * See protocol docs: [Content](https://agentclientprotocol.com/protocol/content) + */ +export type ContentBlock = ({ + type: 'TextContent'; +} & TextContent) | ({ + type: 'ImageContent'; +} & ImageContent) | ({ + type: 'AudioContent'; +} & AudioContent) | ({ + type: 'ResourceLink'; +} & ResourceLink) | ({ + type: 'EmbeddedResource'; +} & EmbeddedResource); + +/** + * Optional annotations for the client. The client can use annotations to inform how objects are used or displayed + */ +export type Annotations = { + audience?: Array | null; + lastModified?: string | null; + priority?: number | null; + /** + * The _meta property is reserved by ACP to allow clients and agents to attach additional + * metadata to their interactions. Implementations MUST NOT make assumptions about values at + * these keys. + * + * See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + */ + _meta?: { + [key: string]: unknown; + } | null; +}; + +/** + * The sender or recipient of messages and data in a conversation. + */ +export type Role = 'assistant' | 'user'; + +/** + * Text provided to or from an LLM. + */ +export type TextContent = { + annotations?: Annotations | null; + text: string; + /** + * The _meta property is reserved by ACP to allow clients and agents to attach additional + * metadata to their interactions. Implementations MUST NOT make assumptions about values at + * these keys. + * + * See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + */ + _meta?: { + [key: string]: unknown; + } | null; +}; + +/** + * An image provided to or from an LLM. + */ +export type ImageContent = { + annotations?: Annotations | null; + data: string; + mimeType: string; + uri?: string | null; + /** + * The _meta property is reserved by ACP to allow clients and agents to attach additional + * metadata to their interactions. Implementations MUST NOT make assumptions about values at + * these keys. + * + * See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + */ + _meta?: { + [key: string]: unknown; + } | null; +}; + +/** + * Audio provided to or from an LLM. + */ +export type AudioContent = { + annotations?: Annotations | null; + data: string; + mimeType: string; + /** + * The _meta property is reserved by ACP to allow clients and agents to attach additional + * metadata to their interactions. Implementations MUST NOT make assumptions about values at + * these keys. + * + * See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + */ + _meta?: { + [key: string]: unknown; + } | null; +}; + +/** + * A resource that the server is capable of reading, included in a prompt or tool call result. + */ +export type ResourceLink = { + annotations?: Annotations | null; + description?: string | null; + mimeType?: string | null; + name: string; + size?: number | null; + title?: string | null; + uri: string; + /** + * The _meta property is reserved by ACP to allow clients and agents to attach additional + * metadata to their interactions. Implementations MUST NOT make assumptions about values at + * these keys. + * + * See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + */ + _meta?: { + [key: string]: unknown; + } | null; +}; + +/** + * Resource content that can be embedded in a message. + */ +export type EmbeddedResourceResource = TextResourceContents | BlobResourceContents; + +/** + * Text-based resource contents. + */ +export type TextResourceContents = { + mimeType?: string | null; + text: string; + uri: string; + /** + * The _meta property is reserved by ACP to allow clients and agents to attach additional + * metadata to their interactions. Implementations MUST NOT make assumptions about values at + * these keys. + * + * See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + */ + _meta?: { + [key: string]: unknown; + } | null; +}; + +/** + * Binary resource contents. + */ +export type BlobResourceContents = { + blob: string; + mimeType?: string | null; + uri: string; + /** + * The _meta property is reserved by ACP to allow clients and agents to attach additional + * metadata to their interactions. Implementations MUST NOT make assumptions about values at + * these keys. + * + * See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + */ + _meta?: { + [key: string]: unknown; + } | null; +}; + +/** + * The contents of a resource, embedded into a prompt or tool call result. + */ +export type EmbeddedResource = { + annotations?: Annotations | null; + resource: EmbeddedResourceResource; + /** + * The _meta property is reserved by ACP to allow clients and agents to attach additional + * metadata to their interactions. Implementations MUST NOT make assumptions about values at + * these keys. + * + * See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + */ + _meta?: { + [key: string]: unknown; + } | null; +}; + +export type SteerSessionResponse_unstable = { + runId: string; + /** + * Stable id of the queued steer message. The same id later appears as + * `messageId` on the streamed `UserMessageChunk` (with `_meta.goose.steer`), + * letting clients correlate a queued steer with its pickup. + */ + messageId: string; +}; + /** * Delete a session. */ @@ -794,7 +1006,7 @@ export type PreferencesReadRequest_unstable = { keys?: Array; }; -export type PreferenceKey = 'autoCompactThreshold' | 'voiceAutoSubmitPhrases' | 'voiceDictationProvider' | 'voiceDictationPreferredMic'; +export type PreferenceKey = 'autoCompactThreshold' | 'gooseThinkingEffort' | 'voiceAutoSubmitPhrases' | 'voiceDictationProvider' | 'voiceDictationPreferredMic'; export type PreferencesReadResponse_unstable = { values: Array; @@ -922,6 +1134,69 @@ export type ImportSessionResponse_unstable = { messageCount: number; }; +/** + * Return list-style metadata for a single session without loading the conversation. + */ +export type GetSessionInfoRequest_unstable = { + sessionId: string; +}; + +export type GetSessionInfoResponse_unstable = { + session: SessionInfo; +}; + +/** + * Information about a session returned by session/list + */ +export type SessionInfo = { + /** + * Unique identifier for the session + */ + sessionId: SessionId; + /** + * The working directory for this session. Must be an absolute path. + */ + cwd: string; + /** + * **UNSTABLE** + * + * This capability is not part of the spec yet, and may be removed or changed at any point. + * + * Authoritative ordered additional workspace roots for this session. Each path must be absolute. + * + * When omitted or empty, there are no additional roots for the session. + */ + additionalDirectories?: Array; + /** + * Human-readable title for the session + */ + title?: string | null; + /** + * ISO 8601 timestamp of last activity + */ + updatedAt?: string | null; + /** + * The _meta property is reserved by ACP to allow clients and agents to attach additional + * metadata to their interactions. Implementations MUST NOT make assumptions about values at + * these keys. + * + * See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + */ + _meta?: { + [key: string]: unknown; + } | null; +}; + +/** + * A unique identifier for a conversation session between a client and agent. + * + * Sessions maintain their own context, conversation history, and state, + * allowing multiple independent interactions with the same agent. + * + * See protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id) + */ +export type SessionId = string; + /** * Submit a response for a pending MCP elicitation in an active session. */ @@ -1345,14 +1620,14 @@ export type InteractionUpdate = { export type ExtRequest = { id: string; method: string; - params?: AddExtensionRequest_unstable | RemoveExtensionRequest_unstable | GetToolsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | DeleteSessionRequest | GetConfigExtensionsRequest_unstable | GetAvailableExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | SetConfigExtensionEnabledRequest_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 | { + params?: AddExtensionRequest_unstable | RemoveExtensionRequest_unstable | GetToolsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | SteerSessionRequest_unstable | DeleteSessionRequest | GetConfigExtensionsRequest_unstable | GetAvailableExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | SetConfigExtensionEnabledRequest_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 | GetSessionInfoRequest_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; }; export type ExtResponse = { id: string; - result?: EmptyResponse | GetToolsResponse_unstable | GooseToolCallResponse_unstable | ReadResourceResponse_unstable | GetConfigExtensionsResponse_unstable | GetAvailableExtensionsResponse_unstable | GetSessionExtensionsResponse_unstable | ListProvidersResponse_unstable | ProviderSupportedModelsListResponse_unstable | ProviderCatalogListResponse_unstable | ProviderSetupCatalogListResponse_unstable | ProviderCatalogTemplateResponse_unstable | CustomProviderCreateResponse_unstable | CustomProviderReadResponse_unstable | CustomProviderUpdateResponse_unstable | CustomProviderDeleteResponse_unstable | RefreshProviderInventoryResponse_unstable | ProviderConfigReadResponse_unstable | ProviderConfigStatusResponse_unstable | ProviderConfigChangeResponse_unstable | PreferencesReadResponse_unstable | DefaultsReadResponse_unstable | OnboardingImportScanResponse_unstable | OnboardingImportApplyResponse_unstable | ExportSessionResponse_unstable | ImportSessionResponse_unstable | CreateSourceResponse_unstable | ListSourcesResponse_unstable | UpdateSourceResponse_unstable | ExportSourceResponse_unstable | ImportSourcesResponse_unstable | DictationTranscribeResponse_unstable | DictationConfigResponse_unstable | DictationModelsListResponse_unstable | DictationModelDownloadProgressResponse_unstable | unknown; + result?: EmptyResponse | GetToolsResponse_unstable | GooseToolCallResponse_unstable | ReadResourceResponse_unstable | SteerSessionResponse_unstable | GetConfigExtensionsResponse_unstable | GetAvailableExtensionsResponse_unstable | GetSessionExtensionsResponse_unstable | ListProvidersResponse_unstable | ProviderSupportedModelsListResponse_unstable | ProviderCatalogListResponse_unstable | ProviderSetupCatalogListResponse_unstable | ProviderCatalogTemplateResponse_unstable | CustomProviderCreateResponse_unstable | CustomProviderReadResponse_unstable | CustomProviderUpdateResponse_unstable | CustomProviderDeleteResponse_unstable | RefreshProviderInventoryResponse_unstable | ProviderConfigReadResponse_unstable | ProviderConfigStatusResponse_unstable | ProviderConfigChangeResponse_unstable | PreferencesReadResponse_unstable | DefaultsReadResponse_unstable | OnboardingImportScanResponse_unstable | OnboardingImportApplyResponse_unstable | ExportSessionResponse_unstable | ImportSessionResponse_unstable | GetSessionInfoResponse_unstable | CreateSourceResponse_unstable | ListSourcesResponse_unstable | UpdateSourceResponse_unstable | ExportSourceResponse_unstable | ImportSourcesResponse_unstable | DictationTranscribeResponse_unstable | DictationConfigResponse_unstable | DictationModelsListResponse_unstable | DictationModelDownloadProgressResponse_unstable | unknown; } | { error: { code: number; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index a73387f5d26d..b98f640e0e5f 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -105,6 +105,219 @@ export const zSetSessionSystemPromptRequest_unstable = z.object({ text: z.string() }); +/** + * The sender or recipient of messages and data in a conversation. + */ +export const zRole = z.enum(['assistant', 'user']); + +/** + * Optional annotations for the client. The client can use annotations to inform how objects are used or displayed + */ +export const zAnnotations = z.object({ + audience: z.union([ + z.array(zRole), + z.null() + ]).optional(), + lastModified: z.union([ + z.string(), + z.null() + ]).optional(), + priority: z.union([ + z.number(), + z.null() + ]).optional(), + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional() +}); + +/** + * Text provided to or from an LLM. + */ +export const zTextContent = z.object({ + annotations: z.union([ + zAnnotations, + z.null() + ]).optional(), + text: z.string(), + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional() +}); + +/** + * An image provided to or from an LLM. + */ +export const zImageContent = z.object({ + annotations: z.union([ + zAnnotations, + z.null() + ]).optional(), + data: z.string(), + mimeType: z.string(), + uri: z.union([ + z.string(), + z.null() + ]).optional(), + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional() +}); + +/** + * Audio provided to or from an LLM. + */ +export const zAudioContent = z.object({ + annotations: z.union([ + zAnnotations, + z.null() + ]).optional(), + data: z.string(), + mimeType: z.string(), + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional() +}); + +/** + * A resource that the server is capable of reading, included in a prompt or tool call result. + */ +export const zResourceLink = z.object({ + annotations: z.union([ + zAnnotations, + z.null() + ]).optional(), + description: z.union([ + z.string(), + z.null() + ]).optional(), + mimeType: z.union([ + z.string(), + z.null() + ]).optional(), + name: z.string(), + size: z.union([ + z.number().int(), + z.null() + ]).optional(), + title: z.union([ + z.string(), + z.null() + ]).optional(), + uri: z.string(), + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional() +}); + +/** + * Text-based resource contents. + */ +export const zTextResourceContents = z.object({ + mimeType: z.union([ + z.string(), + z.null() + ]).optional(), + text: z.string(), + uri: z.string(), + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional() +}); + +/** + * Binary resource contents. + */ +export const zBlobResourceContents = z.object({ + blob: z.string(), + mimeType: z.union([ + z.string(), + z.null() + ]).optional(), + uri: z.string(), + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional() +}); + +/** + * Resource content that can be embedded in a message. + */ +export const zEmbeddedResourceResource = z.union([ + zTextResourceContents, + zBlobResourceContents +]); + +/** + * The contents of a resource, embedded into a prompt or tool call result. + */ +export const zEmbeddedResource = z.object({ + annotations: z.union([ + zAnnotations, + z.null() + ]).optional(), + resource: zEmbeddedResourceResource, + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional() +}); + +/** + * Content blocks represent displayable information in the Agent Client Protocol. + * + * They provide a structured way to handle various types of user-facing content—whether + * it's text from language models, images for analysis, or embedded resources for context. + * + * Content blocks appear in: + * - User prompts sent via `session/prompt` + * - Language model output streamed through `session/update` notifications + * - Progress updates and results from tool calls + * + * This structure is compatible with the Model Context Protocol (MCP), enabling + * agents to seamlessly forward content from MCP tool outputs without transformation. + * + * See protocol docs: [Content](https://agentclientprotocol.com/protocol/content) + */ +export const zContentBlock = z.union([ + z.object({ + type: z.literal('TextContent') + }).and(zTextContent), + z.object({ + type: z.literal('ImageContent') + }).and(zImageContent), + z.object({ + type: z.literal('AudioContent') + }).and(zAudioContent), + z.object({ + type: z.literal('ResourceLink') + }).and(zResourceLink), + z.object({ + type: z.literal('EmbeddedResource') + }).and(zEmbeddedResource) +]); + +/** + * Add user input to the currently active prompt without starting a new prompt. + */ +export const zSteerSessionRequest_unstable = z.object({ + sessionId: z.string(), + prompt: z.array(zContentBlock).optional().default([]), + expectedRunId: z.string() +}); + +export const zSteerSessionResponse_unstable = z.object({ + runId: z.string(), + messageId: z.string() +}); + /** * Delete a session. */ @@ -766,6 +979,7 @@ export const zProviderConfigAuthenticateRequest_unstable = z.object({ export const zPreferenceKey = z.enum([ 'autoCompactThreshold', + 'gooseThinkingEffort', 'voiceAutoSubmitPhrases', 'voiceDictationProvider', 'voiceDictationPreferredMic' @@ -917,6 +1131,48 @@ export const zImportSessionResponse_unstable = z.object({ messageCount: z.number().int().gte(0) }); +/** + * Return list-style metadata for a single session without loading the conversation. + */ +export const zGetSessionInfoRequest_unstable = z.object({ + sessionId: z.string() +}); + +/** + * A unique identifier for a conversation session between a client and agent. + * + * Sessions maintain their own context, conversation history, and state, + * allowing multiple independent interactions with the same agent. + * + * See protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id) + */ +export const zSessionId = z.string(); + +/** + * Information about a session returned by session/list + */ +export const zSessionInfo = z.object({ + sessionId: zSessionId, + cwd: z.string(), + additionalDirectories: z.array(z.string()).optional(), + title: z.union([ + z.string(), + z.null() + ]).optional(), + updatedAt: z.union([ + z.string(), + z.null() + ]).optional(), + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional() +}); + +export const zGetSessionInfoResponse_unstable = z.object({ + session: zSessionInfo +}); + /** * Submit a response for a pending MCP elicitation in an active session. */ @@ -1346,6 +1602,7 @@ export const zExtRequest = z.object({ zReadResourceRequest_unstable, zUpdateWorkingDirRequest_unstable, zSetSessionSystemPromptRequest_unstable, + zSteerSessionRequest_unstable, zDeleteSessionRequest, zGetConfigExtensionsRequest_unstable, zGetAvailableExtensionsRequest_unstable, @@ -1377,6 +1634,7 @@ export const zExtRequest = z.object({ zOnboardingImportApplyRequest_unstable, zExportSessionRequest_unstable, zImportSessionRequest_unstable, + zGetSessionInfoRequest_unstable, zElicitationRespondRequest_unstable, zUpdateSessionProjectRequest_unstable, zRenameSessionRequest_unstable, @@ -1415,6 +1673,7 @@ export const zExtResponse = z.union([ zGetToolsResponse_unstable, zGooseToolCallResponse_unstable, zReadResourceResponse_unstable, + zSteerSessionResponse_unstable, zGetConfigExtensionsResponse_unstable, zGetAvailableExtensionsResponse_unstable, zGetSessionExtensionsResponse_unstable, @@ -1437,6 +1696,7 @@ export const zExtResponse = z.union([ zOnboardingImportApplyResponse_unstable, zExportSessionResponse_unstable, zImportSessionResponse_unstable, + zGetSessionInfoResponse_unstable, zCreateSourceResponse_unstable, zListSourcesResponse_unstable, zUpdateSourceResponse_unstable, diff --git a/ui/text/README.md b/ui/text/README.md index 191dbe0bf7cc..f08e8b644bc9 100644 --- a/ui/text/README.md +++ b/ui/text/README.md @@ -7,33 +7,35 @@ https://github.com/aaif-goose/goose/discussions/7309 ## Running -The TUI automatically launches the goose ACP server using the `goose acp` command. +The TUI launches the goose ACP server by spawning `goose acp`. Which binary it spawns is resolved by `@aaif/goose-sdk`: -### Development (from source) - -When running from source, `npm start` automatically builds the Rust binary from the workspace root if needed: +1. the `GOOSE_BINARY` environment variable, if set, otherwise +2. the platform's prebuilt `@aaif/goose-binary-*` package (an optional dependency of the pinned `@aaif/goose-sdk`). ```bash cd ui/text -npm i -npm run start +pnpm install # pulls the pinned @aaif/goose-sdk and its matching @aaif/goose-binary-* package +pnpm start # tsx src/tui.tsx — runs against the released binary, no Rust build ``` -The `dev:binary` script checks if the Rust binary needs rebuilding by comparing timestamps of: -- `target/release/goose` binary -- `Cargo.toml` and `Cargo.lock` -- `crates/goose-cli/Cargo.toml` +The TUI pins a specific `@aaif/goose-sdk` version, so `pnpm start` always runs against a goose binary that matches the SDK. + +### Building goose from local source + +To test local Rust changes, run the dev launcher directly. It builds a debug binary (`cargo build -p goose-cli` → `target/debug/goose`) from the workspace root and points the TUI at it via `GOOSE_BINARY`: -If any source files are newer, it runs `cargo build --release -p goose-cli` automatically. +```bash +node scripts/dev-start.mjs +``` -### Production (with prebuilt binaries) +If your changes touch the ACP schema, also point the TUI at the in-repo SDK so the two stay matched: set `@aaif/goose-sdk` to `workspace:*` in `package.json` and re-run `pnpm install`. Otherwise the locally built binary may not match the pinned published SDK's schema. Revert that change before committing — the TUI is meant to stay frozen on its pinned SDK version. -In production, the TUI uses prebuilt binaries from the `@aaif/goose-binary-*` packages installed via `postinstall`. +To run any other prebuilt binary, set `GOOSE_BINARY=/path/to/goose` and use `pnpm start`. ### Custom server URL -To use a custom server URL instead of the built-in binary: +To connect to an already-running server instead of spawning a binary: ```bash -npm run start -- --server http://localhost:8080 +pnpm start -- --server http://localhost:8080 ``` diff --git a/ui/text/package.json b/ui/text/package.json index 533953abb2de..560a3d2d4d27 100644 --- a/ui/text/package.json +++ b/ui/text/package.json @@ -23,11 +23,11 @@ ], "scripts": { "build": "tsc", - "start": "node scripts/dev-start.mjs", + "start": "tsx src/tui.tsx", "lint": "tsc --noEmit" }, "dependencies": { - "@aaif/goose-sdk": "workspace:*", + "@aaif/goose-sdk": "0.20.2", "@agentclientprotocol/sdk": "^0.19.0", "@inkjs/ui": "^2.0.0", "ink": "^6.8.0",