From 15224fbab2f683f3bd119d9683711b2f533ba4ea Mon Sep 17 00:00:00 2001 From: David Katz Date: Thu, 16 Oct 2025 13:36:22 -0400 Subject: [PATCH 01/55] first wave refactor --- crates/goose-cli/src/session/mod.rs | 24 +--- crates/goose-server/src/routes/context.rs | 13 ++- crates/goose/src/agents/agent.rs | 51 +++++---- crates/goose/src/context_mgmt/mod.rs | 131 ++++++++-------------- 4 files changed, 84 insertions(+), 135 deletions(-) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index ea32ef089c53..7a9c18e4cb4b 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -655,26 +655,12 @@ impl CliSession { println!("{}", console::style("Summarizing conversation...").yellow()); output::show_thinking(); - // Get the provider for summarization - let _provider = self.agent.provider().await?; - - // Get session metadata if available - let session_metadata_for_compact = - if let Some(ref session_id) = self.session_id { - SessionManager::get_session(session_id, false).await.ok() - } else { - None - }; - - // Call the summarize_context method - let (_, summarized_messages, _token_counts, summarization_usage) = - goose::context_mgmt::check_and_compact_messages( + // Force compaction (no need to check threshold for manual summarize command) + let (summarized_messages, _token_counts, summarization_usage) = + goose::context_mgmt::compact_messages( &self.agent, - self.messages.messages(), - true, - false, - None, - session_metadata_for_compact.as_ref(), + &self.messages, + false, // don't preserve last user message ) .await?; diff --git a/crates/goose-server/src/routes/context.rs b/crates/goose-server/src/routes/context.rs index 63dd203af612..aefda40ff8b5 100644 --- a/crates/goose-server/src/routes/context.rs +++ b/crates/goose-server/src/routes/context.rs @@ -1,6 +1,6 @@ use crate::state::AppState; use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; -use goose::conversation::message::Message; +use goose::conversation::{message::Message, Conversation}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use utoipa::ToSchema; @@ -46,13 +46,14 @@ async fn manage_context( ) -> Result, StatusCode> { let agent = state.get_agent_for_route(request.session_id).await?; - let (_, processed_messages, token_counts, _) = goose::context_mgmt::check_and_compact_messages( + // Convert messages to Conversation + let conversation = Conversation::new_unvalidated(request.messages); + + // Force compaction without preserving last user message + let (processed_messages, token_counts, _) = goose::context_mgmt::compact_messages( &agent, - &request.messages, - true, + &conversation, false, - None, - None, ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 4a31d7826cb1..10c7a95e08f3 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -33,7 +33,7 @@ use crate::agents::tool_router_index_manager::ToolRouterIndexManager; use crate::agents::types::SessionConfig; use crate::agents::types::{FrontendTool, ToolResultReceiver}; use crate::config::{get_enabled_extensions, get_extension_by_name, Config}; -use crate::context_mgmt::{check_and_compact_messages, DEFAULT_COMPACTION_THRESHOLD}; +use crate::context_mgmt::DEFAULT_COMPACTION_THRESHOLD; use crate::conversation::{debug_conversation_fix, fix_conversation, Conversation}; use crate::mcp_utils::ToolResult; use crate::permission::permission_inspector::PermissionInspector; @@ -918,22 +918,27 @@ impl Agent { None }; - let (did_compact, compacted_conversation, compaction_error) = - match check_and_compact_messages( - self, - unfixed_conversation.messages(), - false, - false, - None, - session_metadata.as_ref(), - ) - .await - { - Ok((did_compact, conversation, _removed_indices, _summarization_usage)) => { - (did_compact, conversation, None) + // Check if compaction is needed + let check_result = crate::context_mgmt::check_if_compaction_needed( + self, + &unfixed_conversation, + None, + session_metadata.as_ref(), + ) + .await; + + let (did_compact, compacted_conversation, compaction_error) = match check_result { + // TODO(dkatz): send a notification that we are starting compaction here. + Ok(check_result) if check_result.needs_compaction => { + // Perform compaction + match crate::context_mgmt::compact_messages(self, &unfixed_conversation, false).await { + Ok((conversation, _token_counts, _summarization_usage)) => (true, conversation, None), + Err(e) => (false, unfixed_conversation.clone(), Some(e)), } - Err(e) => (false, unfixed_conversation.clone(), Some(e)), - }; + } + Ok(_) => (false, unfixed_conversation, None), + Err(e) => (false, unfixed_conversation.clone(), Some(e)), + }; if did_compact { // Get threshold from config to include in message @@ -970,7 +975,7 @@ impl Agent { )); })) } else { - self.reply_internal(unfixed_conversation, session, cancel_token) + self.reply_internal(compacted_conversation, session, cancel_token) .await } } @@ -1297,15 +1302,9 @@ impl Agent { Err(ProviderError::ContextLengthExceeded(_error_msg)) => { info!("Context length exceeded, attempting compaction"); - // Get session metadata if available - let session_metadata_for_compact = if let Some(ref session_config) = session { - SessionManager::get_session(&session_config.id, false).await.ok() - } else { - None - }; - - match check_and_compact_messages(self, conversation.messages(), true, true, None, session_metadata_for_compact.as_ref()).await { - Ok((_did_compact, compacted_conversation, _removed_indices, _usage)) => { + // TODO(dkatz): send a notification that we are starting compaction here. + match crate::context_mgmt::compact_messages(self, &conversation, true).await { + Ok((compacted_conversation, _token_counts, _usage)) => { conversation = compacted_conversation; did_recovery_compact_this_iteration = true; diff --git a/crates/goose/src/context_mgmt/mod.rs b/crates/goose/src/context_mgmt/mod.rs index 8b2c73c3feac..e426a6246ad0 100644 --- a/crates/goose/src/context_mgmt/mod.rs +++ b/crates/goose/src/context_mgmt/mod.rs @@ -46,101 +46,65 @@ struct SummarizeContext { messages: String, } -/// Check if messages need compaction and compact them if necessary +/// Compact messages by summarizing them /// -/// This function combines checking and compaction. It first checks if compaction -/// is needed based on the threshold, and if so, performs the compaction by -/// summarizing messages and updating their visibility metadata. +/// This function performs the actual compaction by summarizing messages and updating +/// their visibility metadata. It does not check thresholds - use `check_if_compaction_needed` +/// first to determine if compaction is necessary. /// /// # Arguments /// * `agent` - The agent to use for context management -/// * `messages` - The current message history -/// * `force_compact` - If true, skip the threshold check and force compaction +/// * `conversation` - The current conversation history /// * `preserve_last_user_message` - If true and last message is not a user message, copy the most recent user message to the end -/// * `threshold_override` - Optional threshold override (defaults to GOOSE_AUTO_COMPACT_THRESHOLD config) -/// * `session_metadata` - Optional session metadata containing actual token counts /// /// # Returns /// * A tuple containing: -/// - `bool`: Whether compaction was performed -/// - `Conversation`: The potentially compacted messages -/// - `Vec`: Indices of removed messages (empty if no compaction) -/// - `Option`: Provider usage from summarization (if compaction occurred) -pub async fn check_and_compact_messages( +/// - `Conversation`: The compacted messages +/// - `Vec`: Token counts for each message +/// - `Option`: Provider usage from summarization +pub async fn compact_messages( agent: &Agent, - messages_with_user_message: &[Message], - force_compact: bool, + conversation: &Conversation, preserve_last_user_message: bool, - threshold_override: Option, - session_metadata: Option<&crate::session::Session>, -) -> std::result::Result<(bool, Conversation, Vec, Option), anyhow::Error> { - if !force_compact { - let check_result = check_compaction_needed( - agent, - messages_with_user_message, - threshold_override, - session_metadata, - ) - .await?; - - // If no compaction is needed, return early - if !check_result.needs_compaction { - debug!( - "No compaction needed (usage: {:.1}% <= {:.1}% threshold)", - check_result.usage_ratio * 100.0, - check_result.percentage_until_compaction - ); - return Ok(( - false, - Conversation::new_unvalidated(messages_with_user_message.to_vec()), - Vec::new(), - None, - )); - } +) -> Result<(Conversation, Vec, Option)> { + info!("Performing message compaction"); - info!( - "Performing message compaction (usage: {:.1}%)", - check_result.usage_ratio * 100.0 - ); - } else { - info!("Forcing message compaction due to context limit exceeded"); - } + let messages = conversation.messages(); - // Perform the actual compaction // Check if the most recent message is a user message - let (messages, preserved_user_message) = - if let Some(last_message) = messages_with_user_message.last() { - if matches!(last_message.role, rmcp::model::Role::User) { - // Remove the last user message before compaction - ( - &messages_with_user_message[..messages_with_user_message.len() - 1], - Some(last_message.clone()), - ) - } else if preserve_last_user_message { - // Last message is not a user message, but we want to preserve the most recent user message - // Find the most recent user message and copy it (don't remove from history) - let most_recent_user_message = messages_with_user_message - .iter() - .rev() - .find(|msg| matches!(msg.role, rmcp::model::Role::User)) - .cloned(); - (messages_with_user_message, most_recent_user_message) - } else { - (messages_with_user_message, None) - } + let (messages_to_compact, preserved_user_message) = if let Some(last_message) = messages.last() + { + if matches!(last_message.role, rmcp::model::Role::User) { + // Remove the last user message before compaction + ( + &messages[..messages.len() - 1], + Some(last_message.clone()), + ) + } else if preserve_last_user_message { + // Last message is not a user message, but we want to preserve the most recent user message + // Find the most recent user message and copy it (don't remove from history) + let most_recent_user_message = messages + .iter() + .rev() + .find(|msg| matches!(msg.role, rmcp::model::Role::User)) + .cloned(); + (messages.as_slice(), most_recent_user_message) } else { - (messages_with_user_message, None) - }; + (messages.as_slice(), None) + } + } else { + (messages.as_slice(), None) + }; let provider = agent.provider().await?; - let summary = do_compact(provider.clone(), messages).await?; + let summary = do_compact(provider.clone(), messages_to_compact).await?; let (summary_message, summarization_usage) = match summary { Some((summary_message, provider_usage)) => (summary_message, Some(provider_usage)), None => { // No summary was generated (empty input) tracing::warn!("Summarization failed. Returning empty messages."); - return Ok((false, Conversation::empty(), vec![], None)); + return Ok((Conversation::empty(), vec![], None)); } }; @@ -153,7 +117,7 @@ pub async fn check_and_compact_messages( let mut final_token_counts = Vec::new(); // Add all original messages with updated visibility (preserve user_visible, set agent_visible=false) - for msg in messages.iter().cloned() { + for msg in messages_to_compact.iter().cloned() { let updated_metadata = msg.metadata.with_agent_invisible(); let updated_msg = msg.with_metadata(updated_metadata); final_messages.push(updated_msg); @@ -198,34 +162,33 @@ Just continue the conversation naturally based on the summarized context" } Ok(( - true, Conversation::new_unvalidated(final_messages), final_token_counts, summarization_usage, )) } -/// Check if messages need compaction without performing the compaction +/// Check if messages exceed the auto-compaction threshold /// -/// This function analyzes the current token usage and returns detailed information -/// about whether compaction is needed and how close we are to the threshold. -/// It prioritizes actual token counts from session metadata when available, -/// falling back to estimated counts if needed. +/// This function analyzes the current token usage and returns whether compaction +/// is needed based on the configured threshold. It prioritizes actual token counts +/// from session metadata when available, falling back to estimated counts if needed. /// /// # Arguments /// * `agent` - The agent to use for context management -/// * `messages` - The current message history +/// * `conversation` - The current conversation history /// * `threshold_override` - Optional threshold override (defaults to GOOSE_AUTO_COMPACT_THRESHOLD config) /// * `session_metadata` - Optional session metadata containing actual token counts /// /// # Returns -/// * `CompactionCheckResult` containing detailed information about compaction needs -async fn check_compaction_needed( +/// * `CompactionCheckResult` containing whether compaction is needed and usage details +pub async fn check_if_compaction_needed( agent: &Agent, - messages: &[Message], + conversation: &Conversation, threshold_override: Option, session_metadata: Option<&crate::session::Session>, ) -> Result { + let messages = conversation.messages(); let config = Config::global(); // TODO(Douwe): check the default here; it seems to reset to 0.3 sometimes let threshold = threshold_override.unwrap_or_else(|| { From a79542973a13d16dc65c70a466e7a45890cfab56 Mon Sep 17 00:00:00 2001 From: David Katz Date: Thu, 16 Oct 2025 13:46:22 -0400 Subject: [PATCH 02/55] rename conversation compacted content --- crates/goose-server/src/openapi.rs | 2 +- crates/goose/src/conversation/message.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 73551c81271b..90139b1a3efb 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -18,7 +18,7 @@ use goose::config::declarative_providers::{ DeclarativeProviderConfig, LoadedProvider, ProviderEngine, }; use goose::conversation::message::{ - ConversationCompacted, FrontendToolRequest, Message, MessageContent, MessageMetadata, + ConversationCompactedContent, FrontendToolRequest, Message, MessageContent, MessageMetadata, RedactedThinkingContent, ThinkingContent, ToolConfirmationRequest, ToolRequest, ToolResponse, }; diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index b47d05774a96..98c842810ecb 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -112,7 +112,7 @@ pub struct FrontendToolRequest { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] -pub struct ConversationCompacted { +pub struct ConversationCompactedContent { pub msg: String, } @@ -128,7 +128,7 @@ pub enum MessageContent { FrontendToolRequest(FrontendToolRequest), Thinking(ThinkingContent), RedactedThinking(RedactedThinkingContent), - ConversationCompacted(ConversationCompacted), + ConversationCompacted(ConversationCompactedContent), } impl fmt::Display for MessageContent { @@ -238,11 +238,11 @@ impl MessageContent { } pub fn conversation_compacted>(msg: S) -> Self { - MessageContent::ConversationCompacted(ConversationCompacted { msg: msg.into() }) + MessageContent::ConversationCompacted(ConversationCompactedContent { msg: msg.into() }) } // Add this new method to check for summarization requested content - pub fn as_summarization_requested(&self) -> Option<&ConversationCompacted> { + pub fn as_summarization_requested(&self) -> Option<&ConversationCompactedContent> { if let MessageContent::ConversationCompacted(ref summarization_requested) = self { Some(summarization_requested) } else { From 814de077f159ae133ab7ecde30e734c464ab503f Mon Sep 17 00:00:00 2001 From: David Katz Date: Thu, 16 Oct 2025 13:47:56 -0400 Subject: [PATCH 03/55] fmt --- crates/goose-server/src/routes/context.rs | 11 ++++------- crates/goose/src/agents/agent.rs | 8 ++++++-- crates/goose/src/context_mgmt/mod.rs | 5 +---- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/crates/goose-server/src/routes/context.rs b/crates/goose-server/src/routes/context.rs index aefda40ff8b5..782fd3fcb027 100644 --- a/crates/goose-server/src/routes/context.rs +++ b/crates/goose-server/src/routes/context.rs @@ -50,13 +50,10 @@ async fn manage_context( let conversation = Conversation::new_unvalidated(request.messages); // Force compaction without preserving last user message - let (processed_messages, token_counts, _) = goose::context_mgmt::compact_messages( - &agent, - &conversation, - false, - ) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let (processed_messages, token_counts, _) = + goose::context_mgmt::compact_messages(&agent, &conversation, false) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // TODO(Douwe): store into db Ok(Json(ContextManageResponse { diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 10c7a95e08f3..01c86d8962a7 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -931,8 +931,12 @@ impl Agent { // TODO(dkatz): send a notification that we are starting compaction here. Ok(check_result) if check_result.needs_compaction => { // Perform compaction - match crate::context_mgmt::compact_messages(self, &unfixed_conversation, false).await { - Ok((conversation, _token_counts, _summarization_usage)) => (true, conversation, None), + match crate::context_mgmt::compact_messages(self, &unfixed_conversation, false) + .await + { + Ok((conversation, _token_counts, _summarization_usage)) => { + (true, conversation, None) + } Err(e) => (false, unfixed_conversation.clone(), Some(e)), } } diff --git a/crates/goose/src/context_mgmt/mod.rs b/crates/goose/src/context_mgmt/mod.rs index e426a6246ad0..afe7af300506 100644 --- a/crates/goose/src/context_mgmt/mod.rs +++ b/crates/goose/src/context_mgmt/mod.rs @@ -76,10 +76,7 @@ pub async fn compact_messages( { if matches!(last_message.role, rmcp::model::Role::User) { // Remove the last user message before compaction - ( - &messages[..messages.len() - 1], - Some(last_message.clone()), - ) + (&messages[..messages.len() - 1], Some(last_message.clone())) } else if preserve_last_user_message { // Last message is not a user message, but we want to preserve the most recent user message // Find the most recent user message and copy it (don't remove from history) From af08051795c2897a09c1ee112918f2a122cb78ee Mon Sep 17 00:00:00 2001 From: David Katz Date: Thu, 16 Oct 2025 13:56:38 -0400 Subject: [PATCH 04/55] undo rename --- crates/goose-server/src/openapi.rs | 2 +- crates/goose/src/conversation/message.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 90139b1a3efb..73551c81271b 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -18,7 +18,7 @@ use goose::config::declarative_providers::{ DeclarativeProviderConfig, LoadedProvider, ProviderEngine, }; use goose::conversation::message::{ - ConversationCompactedContent, FrontendToolRequest, Message, MessageContent, MessageMetadata, + ConversationCompacted, FrontendToolRequest, Message, MessageContent, MessageMetadata, RedactedThinkingContent, ThinkingContent, ToolConfirmationRequest, ToolRequest, ToolResponse, }; diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index 98c842810ecb..b47d05774a96 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -112,7 +112,7 @@ pub struct FrontendToolRequest { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] -pub struct ConversationCompactedContent { +pub struct ConversationCompacted { pub msg: String, } @@ -128,7 +128,7 @@ pub enum MessageContent { FrontendToolRequest(FrontendToolRequest), Thinking(ThinkingContent), RedactedThinking(RedactedThinkingContent), - ConversationCompacted(ConversationCompactedContent), + ConversationCompacted(ConversationCompacted), } impl fmt::Display for MessageContent { @@ -238,11 +238,11 @@ impl MessageContent { } pub fn conversation_compacted>(msg: S) -> Self { - MessageContent::ConversationCompacted(ConversationCompactedContent { msg: msg.into() }) + MessageContent::ConversationCompacted(ConversationCompacted { msg: msg.into() }) } // Add this new method to check for summarization requested content - pub fn as_summarization_requested(&self) -> Option<&ConversationCompactedContent> { + pub fn as_summarization_requested(&self) -> Option<&ConversationCompacted> { if let MessageContent::ConversationCompacted(ref summarization_requested) = self { Some(summarization_requested) } else { From 69b66f2987a070149ec59d3e2320e7e605e26597 Mon Sep 17 00:00:00 2001 From: David Katz Date: Thu, 16 Oct 2025 14:00:37 -0400 Subject: [PATCH 05/55] rm some comments --- crates/goose-cli/src/session/mod.rs | 3 +-- crates/goose-server/src/routes/context.rs | 2 -- crates/goose/src/agents/agent.rs | 2 -- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 7a9c18e4cb4b..133b3aef4886 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -655,12 +655,11 @@ impl CliSession { println!("{}", console::style("Summarizing conversation...").yellow()); output::show_thinking(); - // Force compaction (no need to check threshold for manual summarize command) let (summarized_messages, _token_counts, summarization_usage) = goose::context_mgmt::compact_messages( &self.agent, &self.messages, - false, // don't preserve last user message + false, ) .await?; diff --git a/crates/goose-server/src/routes/context.rs b/crates/goose-server/src/routes/context.rs index 782fd3fcb027..97f964280269 100644 --- a/crates/goose-server/src/routes/context.rs +++ b/crates/goose-server/src/routes/context.rs @@ -46,10 +46,8 @@ async fn manage_context( ) -> Result, StatusCode> { let agent = state.get_agent_for_route(request.session_id).await?; - // Convert messages to Conversation let conversation = Conversation::new_unvalidated(request.messages); - // Force compaction without preserving last user message let (processed_messages, token_counts, _) = goose::context_mgmt::compact_messages(&agent, &conversation, false) .await diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 01c86d8962a7..9da25d38f741 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -918,7 +918,6 @@ impl Agent { None }; - // Check if compaction is needed let check_result = crate::context_mgmt::check_if_compaction_needed( self, &unfixed_conversation, @@ -930,7 +929,6 @@ impl Agent { let (did_compact, compacted_conversation, compaction_error) = match check_result { // TODO(dkatz): send a notification that we are starting compaction here. Ok(check_result) if check_result.needs_compaction => { - // Perform compaction match crate::context_mgmt::compact_messages(self, &unfixed_conversation, false) .await { From 60aca5e24e9a46fdc76a3d3b52620d5a75bae88e Mon Sep 17 00:00:00 2001 From: David Katz Date: Thu, 16 Oct 2025 16:11:09 -0400 Subject: [PATCH 06/55] rm compactioncheckresult --- crates/goose/src/agents/agent.rs | 4 +-- crates/goose/src/context_mgmt/mod.rs | 50 ++-------------------------- 2 files changed, 4 insertions(+), 50 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 9da25d38f741..b2ade9ea89fb 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -928,7 +928,7 @@ impl Agent { let (did_compact, compacted_conversation, compaction_error) = match check_result { // TODO(dkatz): send a notification that we are starting compaction here. - Ok(check_result) if check_result.needs_compaction => { + Ok(true) => { match crate::context_mgmt::compact_messages(self, &unfixed_conversation, false) .await { @@ -938,7 +938,7 @@ impl Agent { Err(e) => (false, unfixed_conversation.clone(), Some(e)), } } - Ok(_) => (false, unfixed_conversation, None), + Ok(false) => (false, unfixed_conversation, None), Err(e) => (false, unfixed_conversation.clone(), Some(e)), }; diff --git a/crates/goose/src/context_mgmt/mod.rs b/crates/goose/src/context_mgmt/mod.rs index afe7af300506..e5190e7a4ce8 100644 --- a/crates/goose/src/context_mgmt/mod.rs +++ b/crates/goose/src/context_mgmt/mod.rs @@ -24,23 +24,6 @@ pub struct AutoCompactResult { pub summarization_usage: Option, } -/// Result of checking if compaction is needed -#[derive(Debug)] -pub struct CompactionCheckResult { - /// Whether compaction is needed - pub needs_compaction: bool, - /// Current token count - pub current_tokens: usize, - /// Context limit being used - pub context_limit: usize, - /// Current usage ratio (0.0 to 1.0) - pub usage_ratio: f64, - /// Remaining tokens before compaction threshold - pub remaining_tokens: usize, - /// Percentage until compaction threshold (0.0 to 100.0) - pub percentage_until_compaction: f64, -} - #[derive(Serialize)] struct SummarizeContext { messages: String, @@ -166,25 +149,12 @@ Just continue the conversation naturally based on the summarized context" } /// Check if messages exceed the auto-compaction threshold -/// -/// This function analyzes the current token usage and returns whether compaction -/// is needed based on the configured threshold. It prioritizes actual token counts -/// from session metadata when available, falling back to estimated counts if needed. -/// -/// # Arguments -/// * `agent` - The agent to use for context management -/// * `conversation` - The current conversation history -/// * `threshold_override` - Optional threshold override (defaults to GOOSE_AUTO_COMPACT_THRESHOLD config) -/// * `session_metadata` - Optional session metadata containing actual token counts -/// -/// # Returns -/// * `CompactionCheckResult` containing whether compaction is needed and usage details pub async fn check_if_compaction_needed( agent: &Agent, conversation: &Conversation, threshold_override: Option, session_metadata: Option<&crate::session::Session>, -) -> Result { +) -> Result { let messages = conversation.messages(); let config = Config::global(); // TODO(Douwe): check the default here; it seems to reset to 0.3 sometimes @@ -216,15 +186,6 @@ pub async fn check_if_compaction_needed( let usage_ratio = current_tokens as f64 / context_limit as f64; - let threshold_tokens = (context_limit as f64 * threshold) as usize; - let remaining_tokens = threshold_tokens.saturating_sub(current_tokens); - - let percentage_until_compaction = if usage_ratio < threshold { - (threshold - usage_ratio) * 100.0 - } else { - 0.0 - }; - let needs_compaction = if threshold <= 0.0 || threshold >= 1.0 { usage_ratio > DEFAULT_COMPACTION_THRESHOLD } else { @@ -241,14 +202,7 @@ pub async fn check_if_compaction_needed( token_source ); - Ok(CompactionCheckResult { - needs_compaction, - current_tokens, - context_limit, - usage_ratio, - remaining_tokens, - percentage_until_compaction, - }) + Ok(needs_compaction) } async fn do_compact( From d0098b3b9b22f5349770118ad9acbadcff0f2444 Mon Sep 17 00:00:00 2001 From: David Katz Date: Thu, 16 Oct 2025 16:16:45 -0400 Subject: [PATCH 07/55] rm autocompactresult --- crates/goose/src/context_mgmt/mod.rs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/crates/goose/src/context_mgmt/mod.rs b/crates/goose/src/context_mgmt/mod.rs index e5190e7a4ce8..a56c87006d1f 100644 --- a/crates/goose/src/context_mgmt/mod.rs +++ b/crates/goose/src/context_mgmt/mod.rs @@ -12,18 +12,6 @@ use tracing::{debug, info}; pub const DEFAULT_COMPACTION_THRESHOLD: f64 = 0.8; -/// Result of auto-compaction check -#[derive(Debug)] -pub struct AutoCompactResult { - /// Whether compaction was performed - pub compacted: bool, - /// The messages after potential compaction - pub messages: Conversation, - /// Provider usage from summarization (if compaction occurred) - /// This contains the actual token counts after compaction - pub summarization_usage: Option, -} - #[derive(Serialize)] struct SummarizeContext { messages: String, From 38f00fea241bd40a8abdb548fd11d9b34db9cfb7 Mon Sep 17 00:00:00 2001 From: David Katz Date: Thu, 16 Oct 2025 17:22:50 -0400 Subject: [PATCH 08/55] massive rename fun --- crates/goose-cli/src/session/export.rs | 4 +- crates/goose-cli/src/session/output.rs | 4 +- crates/goose-server/src/openapi.rs | 8 ++- crates/goose/src/agents/agent.rs | 12 ++-- crates/goose/src/context_mgmt/mod.rs | 11 +++- crates/goose/src/conversation/message.rs | 60 +++++++++++++++---- .../goose/src/providers/formats/anthropic.rs | 2 +- crates/goose/src/providers/formats/bedrock.rs | 4 +- .../goose/src/providers/formats/databricks.rs | 2 +- crates/goose/src/providers/formats/openai.rs | 2 +- .../goose/src/providers/formats/snowflake.rs | 2 +- openapi.json | 0 ui/desktop/openapi.json | 37 ++++++++---- ui/desktop/src/api/types.gen.ts | 15 +++-- .../context_management/CompactionMarker.tsx | 10 ++-- .../context_management/ContextManager.tsx | 5 +- .../__tests__/CompactionMarker.test.tsx | 21 ++++--- .../__tests__/ContextManager.test.tsx | 32 +++++++--- 18 files changed, 157 insertions(+), 74 deletions(-) create mode 100644 openapi.json diff --git a/crates/goose-cli/src/session/export.rs b/crates/goose-cli/src/session/export.rs index ef2b111cd60d..8c9636807594 100644 --- a/crates/goose-cli/src/session/export.rs +++ b/crates/goose-cli/src/session/export.rs @@ -369,8 +369,8 @@ pub fn message_to_markdown(message: &Message, export_all_content: bool) -> Strin md.push_str("**Thinking:**\n"); md.push_str("> *Thinking was redacted*\n\n"); } - MessageContent::ConversationCompacted(summarization) => { - md.push_str(&format!("*{}*\n\n", summarization.msg)); + MessageContent::SystemNotification(notification) => { + md.push_str(&format!("*{}*\n\n", notification.msg)); } _ => { md.push_str( diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index e7c536f8a427..e7520b8ffac0 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -185,8 +185,8 @@ pub fn render_message(message: &Message, debug: bool) { println!("\n{}", style("Thinking:").dim().italic()); print_markdown("Thinking was redacted", theme); } - MessageContent::ConversationCompacted(summarization) => { - println!("\n{}", style(&summarization.msg).yellow()); + MessageContent::SystemNotification(notification) => { + println!("\n{}", style(¬ification.msg).yellow()); } _ => { println!("WARNING: Message content type could not be rendered"); diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 73551c81271b..f134662d9bb5 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -18,8 +18,9 @@ use goose::config::declarative_providers::{ DeclarativeProviderConfig, LoadedProvider, ProviderEngine, }; use goose::conversation::message::{ - ConversationCompacted, FrontendToolRequest, Message, MessageContent, MessageMetadata, - RedactedThinkingContent, ThinkingContent, ToolConfirmationRequest, ToolRequest, ToolResponse, + FrontendToolRequest, Message, MessageContent, MessageMetadata, + RedactedThinkingContent, SystemNotificationContent, SystemNotificationType, ThinkingContent, + ToolConfirmationRequest, ToolRequest, ToolResponse, }; use utoipa::openapi::schema::{ @@ -420,7 +421,8 @@ derive_utoipa!(Icon as IconSchema); RedactedThinkingContent, FrontendToolRequest, ResourceContentsSchema, - ConversationCompacted, + SystemNotificationType, + SystemNotificationContent, JsonObjectSchema, RoleSchema, ProviderMetadata, diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index b2ade9ea89fb..1d84aa0ddc15 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -62,7 +62,7 @@ use super::model_selector::autopilot::AutoPilot; use super::platform_tools; use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE}; use crate::agents::subagent_task_config::TaskConfig; -use crate::conversation::message::{Message, ToolRequest}; +use crate::conversation::message::{Message, SystemNotificationType, ToolRequest}; use crate::session::extension_data::{EnabledExtensionsState, ExtensionState}; use crate::session::SessionManager; @@ -958,7 +958,10 @@ impl Agent { Ok(Box::pin(async_stream::try_stream! { // TODO(Douwe): send this before we actually compact: yield AgentEvent::Message( - Message::assistant().with_conversation_compacted(compaction_msg) + Message::assistant().with_system_notification( + SystemNotificationType::InlineMessage, + compaction_msg, + ) ); yield AgentEvent::HistoryReplaced(compacted_conversation.clone()); if let Some(session_to_store) = &session { @@ -1311,8 +1314,9 @@ impl Agent { did_recovery_compact_this_iteration = true; yield AgentEvent::Message( - Message::assistant().with_conversation_compacted( - "Context limit reached. Conversation has been automatically compacted to continue." + Message::assistant().with_system_notification( + SystemNotificationType::InlineMessage, + "Context limit reached. Conversation has been automatically compacted to continue.", ) ); yield AgentEvent::HistoryReplaced(conversation.clone()); diff --git a/crates/goose/src/context_mgmt/mod.rs b/crates/goose/src/context_mgmt/mod.rs index a56c87006d1f..d5390fe9893f 100644 --- a/crates/goose/src/context_mgmt/mod.rs +++ b/crates/goose/src/context_mgmt/mod.rs @@ -1,5 +1,5 @@ use crate::conversation::message::MessageMetadata; -use crate::conversation::message::{Message, MessageContent}; +use crate::conversation::message::{Message, MessageContent, SystemNotificationType}; use crate::conversation::Conversation; use crate::prompt_template::render_global_file; use crate::providers::base::{Provider, ProviderUsage}; @@ -95,7 +95,10 @@ pub async fn compact_messages( // Add the compaction marker (user_visible=true, agent_visible=false) let compaction_marker = Message::assistant() - .with_conversation_compacted("Conversation compacted and summarized") + .with_system_notification( + SystemNotificationType::InlineMessage, + "Conversation compacted and summarized", + ) .with_metadata(MessageMetadata::user_only()); let compaction_marker_tokens: usize = 0; // Not counted since agent_visible=false final_messages.push(compaction_marker); @@ -281,7 +284,9 @@ fn format_message_for_compacting(msg: &Message) -> String { } MessageContent::Thinking(thinking) => format!("thinking: {}", thinking.thinking), MessageContent::RedactedThinking(_) => "redacted_thinking".to_string(), - MessageContent::ConversationCompacted(compact) => format!("compacted: {}", compact.msg), + MessageContent::SystemNotification(notification) => { + format!("system_notification: {}", notification.msg) + } }) .collect(); diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index b47d05774a96..eca53246f1b1 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -112,7 +112,16 @@ pub struct FrontendToolRequest { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] -pub struct ConversationCompacted { +#[serde(rename_all = "camelCase")] +pub enum SystemNotificationType { + ThinkingMessage, + InlineMessage, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SystemNotificationContent { + pub notification_type: SystemNotificationType, pub msg: String, } @@ -128,7 +137,7 @@ pub enum MessageContent { FrontendToolRequest(FrontendToolRequest), Thinking(ThinkingContent), RedactedThinking(RedactedThinkingContent), - ConversationCompacted(ConversationCompacted), + SystemNotification(SystemNotificationContent), } impl fmt::Display for MessageContent { @@ -156,8 +165,8 @@ impl fmt::Display for MessageContent { }, MessageContent::Thinking(t) => write!(f, "[Thinking: {}]", t.thinking), MessageContent::RedactedThinking(_r) => write!(f, "[RedactedThinking]"), - MessageContent::ConversationCompacted(r) => { - write!(f, "[SummarizationRequested: {}]", r.msg) + MessageContent::SystemNotification(r) => { + write!(f, "[SystemNotification: {}]", r.msg) } } } @@ -237,14 +246,19 @@ impl MessageContent { }) } - pub fn conversation_compacted>(msg: S) -> Self { - MessageContent::ConversationCompacted(ConversationCompacted { msg: msg.into() }) + pub fn system_notification>( + notification_type: SystemNotificationType, + msg: S, + ) -> Self { + MessageContent::SystemNotification(SystemNotificationContent { + notification_type, + msg: msg.into(), + }) } - // Add this new method to check for summarization requested content - pub fn as_summarization_requested(&self) -> Option<&ConversationCompacted> { - if let MessageContent::ConversationCompacted(ref summarization_requested) = self { - Some(summarization_requested) + pub fn as_system_notification(&self) -> Option<&SystemNotificationContent> { + if let MessageContent::SystemNotification(ref notification) = self { + Some(notification) } else { None } @@ -650,8 +664,12 @@ impl Message { .all(|c| matches!(c, MessageContent::Text(_))) } - pub fn with_conversation_compacted>(self, msg: S) -> Self { - self.with_content(MessageContent::conversation_compacted(msg)) + pub fn with_system_notification>( + self, + notification_type: SystemNotificationType, + msg: S, + ) -> Self { + self.with_content(MessageContent::system_notification(notification_type, msg)) } /// Set the visibility metadata for the message @@ -694,7 +712,7 @@ impl Message { #[cfg(test)] mod tests { - use crate::conversation::message::{Message, MessageContent, MessageMetadata}; + use crate::conversation::message::{Message, MessageContent, MessageMetadata, SystemNotificationType}; use crate::conversation::*; use rmcp::model::{ AnnotateAble, CallToolRequestParam, PromptMessage, PromptMessageContent, PromptMessageRole, @@ -1182,4 +1200,20 @@ mod tests { assert!(metadata.user_visible); assert!(metadata.agent_visible); } + + #[test] + fn test_system_notification_serialization() { + let message = Message::assistant() + .with_system_notification(SystemNotificationType::InlineMessage, "Test notification"); + + let json_str = serde_json::to_string_pretty(&message).unwrap(); + println!("Serialized SystemNotification: {}", json_str); + + let value: Value = serde_json::from_str(&json_str).unwrap(); + + // Check that the content has the right structure + assert_eq!(value["content"][0]["type"], "systemNotification"); + assert_eq!(value["content"][0]["notificationType"], "inlineMessage"); + assert_eq!(value["content"][0]["msg"], "Test notification"); + } } diff --git a/crates/goose/src/providers/formats/anthropic.rs b/crates/goose/src/providers/formats/anthropic.rs index 6142d2188c75..e82c92235a65 100644 --- a/crates/goose/src/providers/formats/anthropic.rs +++ b/crates/goose/src/providers/formats/anthropic.rs @@ -90,7 +90,7 @@ pub fn format_messages(messages: &[Message]) -> Vec { MessageContent::ToolConfirmationRequest(_tool_confirmation_request) => { // Skip tool confirmation requests } - MessageContent::ConversationCompacted(_) => { + MessageContent::SystemNotification(_) => { // Skip } MessageContent::Thinking(thinking) => { diff --git a/crates/goose/src/providers/formats/bedrock.rs b/crates/goose/src/providers/formats/bedrock.rs index ae4d37f7eca8..1cb9b2573551 100644 --- a/crates/goose/src/providers/formats/bedrock.rs +++ b/crates/goose/src/providers/formats/bedrock.rs @@ -48,8 +48,8 @@ pub fn to_bedrock_message_content(content: &MessageContent) -> Result { - bail!("SummarizationRequested should not get passed to the provider") + MessageContent::SystemNotification(_) => { + bail!("SystemNotification should not get passed to the provider") } MessageContent::ToolRequest(tool_req) => { let tool_use_id = tool_req.id.to_string(); diff --git a/crates/goose/src/providers/formats/databricks.rs b/crates/goose/src/providers/formats/databricks.rs index c7a34e54273a..90b97cefad7d 100644 --- a/crates/goose/src/providers/formats/databricks.rs +++ b/crates/goose/src/providers/formats/databricks.rs @@ -127,7 +127,7 @@ fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec { + MessageContent::SystemNotification(_) => { continue; } MessageContent::ToolResponse(response) => { diff --git a/crates/goose/src/providers/formats/openai.rs b/crates/goose/src/providers/formats/openai.rs index fc977ca0b09a..5473587d87e9 100644 --- a/crates/goose/src/providers/formats/openai.rs +++ b/crates/goose/src/providers/formats/openai.rs @@ -95,7 +95,7 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec< // Redacted thinking blocks are not directly used in OpenAI format continue; } - MessageContent::ConversationCompacted(_) => { + MessageContent::SystemNotification(_) => { continue; } MessageContent::ToolRequest(request) => match &request.tool_call { diff --git a/crates/goose/src/providers/formats/snowflake.rs b/crates/goose/src/providers/formats/snowflake.rs index 4e52b263086a..0f355612fe2b 100644 --- a/crates/goose/src/providers/formats/snowflake.rs +++ b/crates/goose/src/providers/formats/snowflake.rs @@ -53,7 +53,7 @@ pub fn format_messages(messages: &[Message]) -> Vec { MessageContent::ToolConfirmationRequest(_) => { // Skip tool confirmation requests } - MessageContent::ConversationCompacted(_) => { + MessageContent::SystemNotification(_) => { // Skip } MessageContent::Thinking(_thinking) => { diff --git a/openapi.json b/openapi.json new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 2f52cbbf2c05..155a07bf760c 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -2303,17 +2303,6 @@ "$ref": "#/components/schemas/Message" } }, - "ConversationCompacted": { - "type": "object", - "required": [ - "msg" - ], - "properties": { - "msg": { - "type": "string" - } - } - }, "CreateRecipeRequest": { "type": "object", "required": [ @@ -3331,7 +3320,7 @@ { "allOf": [ { - "$ref": "#/components/schemas/ConversationCompacted" + "$ref": "#/components/schemas/SystemNotificationContent" }, { "type": "object", @@ -3342,7 +3331,7 @@ "type": { "type": "string", "enum": [ - "conversationCompacted" + "systemNotification" ] } } @@ -4409,6 +4398,28 @@ "propertyName": "type" } }, + "SystemNotificationContent": { + "type": "object", + "required": [ + "notificationType", + "msg" + ], + "properties": { + "msg": { + "type": "string" + }, + "notificationType": { + "$ref": "#/components/schemas/SystemNotificationType" + } + } + }, + "SystemNotificationType": { + "type": "string", + "enum": [ + "thinkingMessage", + "inlineMessage" + ] + }, "TextContent": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index c82e302f46f5..fb67e69dde83 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -102,10 +102,6 @@ export type ContextManageResponse = { export type Conversation = Array; -export type ConversationCompacted = { - msg: string; -}; - export type CreateRecipeRequest = { author?: AuthorRequest | null; session_id: string; @@ -404,8 +400,8 @@ export type MessageContent = (TextContent & { type: 'thinking'; }) | (RedactedThinkingContent & { type: 'redactedThinking'; -}) | (ConversationCompacted & { - type: 'conversationCompacted'; +}) | (SystemNotificationContent & { + type: 'systemNotification'; }); /** @@ -788,6 +784,13 @@ export type SuccessCheck = { type: 'Shell'; }; +export type SystemNotificationContent = { + msg: string; + notificationType: SystemNotificationType; +}; + +export type SystemNotificationType = 'thinkingMessage' | 'inlineMessage'; + export type TextContent = { _meta?: { [key: string]: unknown; diff --git a/ui/desktop/src/components/context_management/CompactionMarker.tsx b/ui/desktop/src/components/context_management/CompactionMarker.tsx index 870e034cfdba..524958b82b8c 100644 --- a/ui/desktop/src/components/context_management/CompactionMarker.tsx +++ b/ui/desktop/src/components/context_management/CompactionMarker.tsx @@ -1,17 +1,17 @@ import React from 'react'; -import { Message, ConversationCompacted } from '../../api'; +import { Message, SystemNotificationContent } from '../../api'; interface CompactionMarkerProps { message: Message; } export const CompactionMarker: React.FC = ({ message }) => { - const compactionContent = message.content.find( - (content): content is ConversationCompacted & { type: 'conversationCompacted' } => - content.type === 'conversationCompacted' + const systemNotification = message.content.find( + (content): content is SystemNotificationContent & { type: 'systemNotification' } => + content.type === 'systemNotification' ); - const markerText = compactionContent?.msg || 'Conversation compacted'; + const markerText = systemNotification?.msg || 'Conversation compacted'; return
{markerText}
; }; diff --git a/ui/desktop/src/components/context_management/ContextManager.tsx b/ui/desktop/src/components/context_management/ContextManager.tsx index 7ef110aed8b5..79473e169f85 100644 --- a/ui/desktop/src/components/context_management/ContextManager.tsx +++ b/ui/desktop/src/components/context_management/ContextManager.tsx @@ -75,8 +75,9 @@ export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = ( created: Math.floor(Date.now() / 1000), content: [ { - type: 'conversationCompacted', + type: 'systemNotification', msg: 'Compaction failed. Please try again or start a new session.', + notificationType: 'inlineMessage', }, ], metadata: { userVisible: true, agentVisible: true }, @@ -102,7 +103,7 @@ export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = ( ); const hasCompactionMarker = useCallback((message: Message): boolean => { - return message.content.some((content) => content.type === 'conversationCompacted'); + return message.content.some((content) => content.type === 'systemNotification'); }, []); const value = { diff --git a/ui/desktop/src/components/context_management/__tests__/CompactionMarker.test.tsx b/ui/desktop/src/components/context_management/__tests__/CompactionMarker.test.tsx index 756d1cf14960..eaffbdf138f9 100644 --- a/ui/desktop/src/components/context_management/__tests__/CompactionMarker.test.tsx +++ b/ui/desktop/src/components/context_management/__tests__/CompactionMarker.test.tsx @@ -10,11 +10,12 @@ const default_message: Message = { }, id: '1', role: 'assistant', - created: 1000,content: [] + created: 1000, + content: [], }; describe('CompactionMarker', () => { - it('should render default message when no conversationCompacted content found', () => { + it('should render default message when no systemNotification content found', () => { const message: Message = { ...default_message, content: [{ type: 'text', text: 'Regular message' }], @@ -25,12 +26,16 @@ describe('CompactionMarker', () => { expect(screen.getByText('Conversation compacted')).toBeInTheDocument(); }); - it('should render custom message from conversationCompacted content', () => { + it('should render custom message from systemNotification content', () => { const message: Message = { ...default_message, content: [ { type: 'text', text: 'Some other content' }, - { type: 'conversationCompacted', msg: 'Custom compaction message' }, + { + type: 'systemNotification', + msg: 'Custom compaction message', + notificationType: 'inlineMessage', + }, ], }; @@ -50,10 +55,10 @@ describe('CompactionMarker', () => { expect(screen.getByText('Conversation compacted')).toBeInTheDocument(); }); - it('should handle summarizationRequested content with empty msg', () => { + it('should handle systemNotification content with empty msg', () => { const message: Message = { ...default_message, - content: [{ type: 'conversationCompacted', msg: '' }], + content: [{ type: 'systemNotification', msg: '', notificationType: 'inlineMessage' }], }; render(); @@ -62,11 +67,11 @@ describe('CompactionMarker', () => { expect(screen.getByText('Conversation compacted')).toBeInTheDocument(); }); - it('should handle summarizationRequested content with undefined msg', () => { + it('should handle systemNotification content with undefined msg', () => { const message: Message = { ...default_message, // eslint-disable-next-line @typescript-eslint/no-explicit-any - content: [{ type: 'conversationCompacted' } as any], + content: [{ type: 'systemNotification', notificationType: 'inlineMessage' } as any], }; render(); diff --git a/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx b/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx index 82887370e321..05154999af06 100644 --- a/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx +++ b/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx @@ -62,17 +62,23 @@ describe('ContextManager', () => { }); describe('hasCompactionMarker', () => { - it('should return true for messages with summarizationRequested content', () => { + it('should return true for messages with systemNotification content', () => { const { result } = renderContextManager(); const messageWithMarker: Message = { ...default_message, - content: [{ type: 'conversationCompacted', msg: 'Compaction marker' }], + content: [ + { + type: 'systemNotification', + msg: 'Compaction marker', + notificationType: 'inlineMessage', + }, + ], }; expect(result.current.hasCompactionMarker(messageWithMarker)).toBe(true); }); - it('should return false for messages without summarizationRequested content', () => { + it('should return false for messages without systemNotification content', () => { const { result } = renderContextManager(); const regularMessage: Message = { ...default_message, @@ -82,13 +88,17 @@ describe('ContextManager', () => { expect(result.current.hasCompactionMarker(regularMessage)).toBe(false); }); - it('should return true for messages with mixed content including conversationCompacted', () => { + it('should return true for messages with mixed content including systemNotification', () => { const { result } = renderContextManager(); const mixedMessage: Message = { ...default_message, content: [ { type: 'text', text: 'Some text' }, - { type: 'conversationCompacted', msg: 'Compaction marker' }, + { + type: 'systemNotification', + msg: 'Compaction marker', + notificationType: 'inlineMessage', + }, ], }; @@ -103,7 +113,11 @@ describe('ContextManager', () => { { ...default_message, content: [ - { type: 'conversationCompacted', msg: 'Conversation compacted and summarized' }, + { + type: 'systemNotification', + msg: 'Conversation compacted and summarized', + notificationType: 'inlineMessage', + }, ], }, { @@ -210,7 +224,11 @@ describe('ContextManager', () => { { ...default_message, content: [ - { type: 'conversationCompacted', msg: 'Conversation compacted and summarized' }, + { + type: 'systemNotification', + msg: 'Conversation compacted and summarized', + notificationType: 'inlineMessage', + }, ], }, { From 97aa4ab9c072a3de2c4b6170978e0886672588fe Mon Sep 17 00:00:00 2001 From: David Katz Date: Fri, 17 Oct 2025 15:08:35 -0400 Subject: [PATCH 09/55] continue renames --- ui/desktop/src/components/ProgressiveMessageList.tsx | 6 +++--- ...actionMarker.tsx => SystemNotificationInline.tsx} | 4 ++-- .../__tests__/CompactionMarker.test.tsx | 12 ++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) rename ui/desktop/src/components/context_management/{CompactionMarker.tsx => SystemNotificationInline.tsx} (76%) diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx index 3c39a97ed5be..f5de0aaab028 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -18,7 +18,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Message } from '../api'; import GooseMessage from './GooseMessage'; import UserMessage from './UserMessage'; -import { CompactionMarker } from './context_management/CompactionMarker'; +import { SystemNotificationInline } from './context_management/SystemNotificationInline'; import { useContextManager } from './context_management/ContextManager'; import { NotificationEvent } from '../hooks/useMessageStream'; import LoadingGoose from './LoadingGoose'; @@ -198,7 +198,7 @@ export default function ProgressiveMessageList({ {isUser ? ( <> {hasCompactionMarker && hasCompactionMarker(message) ? ( - + ) : ( !hasOnlyToolResponses(message) && ( @@ -208,7 +208,7 @@ export default function ProgressiveMessageList({ ) : ( <> {hasCompactionMarker && hasCompactionMarker(message) ? ( - + ) : ( = ({ message }) => { +export const SystemNotificationInline: React.FC = ({ message }) => { const systemNotification = message.content.find( (content): content is SystemNotificationContent & { type: 'systemNotification' } => content.type === 'systemNotification' diff --git a/ui/desktop/src/components/context_management/__tests__/CompactionMarker.test.tsx b/ui/desktop/src/components/context_management/__tests__/CompactionMarker.test.tsx index eaffbdf138f9..19f739f97e81 100644 --- a/ui/desktop/src/components/context_management/__tests__/CompactionMarker.test.tsx +++ b/ui/desktop/src/components/context_management/__tests__/CompactionMarker.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; -import { CompactionMarker } from '../CompactionMarker'; +import { SystemNotificationInline } from '../SystemNotificationInline'; import { Message } from '../../../api'; const default_message: Message = { @@ -21,7 +21,7 @@ describe('CompactionMarker', () => { content: [{ type: 'text', text: 'Regular message' }], }; - render(); + render(); expect(screen.getByText('Conversation compacted')).toBeInTheDocument(); }); @@ -39,7 +39,7 @@ describe('CompactionMarker', () => { ], }; - render(); + render(); expect(screen.getByText('Custom compaction message')).toBeInTheDocument(); }); @@ -50,7 +50,7 @@ describe('CompactionMarker', () => { content: [], }; - render(); + render(); expect(screen.getByText('Conversation compacted')).toBeInTheDocument(); }); @@ -61,7 +61,7 @@ describe('CompactionMarker', () => { content: [{ type: 'systemNotification', msg: '', notificationType: 'inlineMessage' }], }; - render(); + render(); // Empty string falls back to default due to || operator expect(screen.getByText('Conversation compacted')).toBeInTheDocument(); @@ -74,7 +74,7 @@ describe('CompactionMarker', () => { content: [{ type: 'systemNotification', notificationType: 'inlineMessage' } as any], }; - render(); + render(); // Should render the default message when msg is undefined expect(screen.getByText('Conversation compacted')).toBeInTheDocument(); From 3689708d1574d99c4c3287481352d472fccc07cd Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 20 Oct 2025 11:34:07 -0400 Subject: [PATCH 10/55] massive code delete --- crates/goose/src/context_mgmt/mod.rs | 10 +- ui/desktop/src/components/BaseChat.tsx | 34 +- ui/desktop/src/components/BaseChat2.tsx | 25 +- ui/desktop/src/components/ChatInput.tsx | 54 ++-- .../src/components/ProgressiveMessageList.tsx | 21 +- .../context_management/ContextManager.tsx | 129 -------- .../SystemNotificationInline.tsx | 6 +- .../__tests__/CompactionMarker.test.tsx | 82 ----- .../__tests__/ContextManager.test.tsx | 301 ------------------ .../components/context_management/index.ts | 29 -- ui/desktop/src/components/hub.tsx | 57 ++-- .../sessions/SessionHistoryView.tsx | 45 ++- 12 files changed, 98 insertions(+), 695 deletions(-) delete mode 100644 ui/desktop/src/components/context_management/ContextManager.tsx delete mode 100644 ui/desktop/src/components/context_management/__tests__/CompactionMarker.test.tsx delete mode 100644 ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx delete mode 100644 ui/desktop/src/components/context_management/index.ts diff --git a/crates/goose/src/context_mgmt/mod.rs b/crates/goose/src/context_mgmt/mod.rs index d5390fe9893f..2ed077411107 100644 --- a/crates/goose/src/context_mgmt/mod.rs +++ b/crates/goose/src/context_mgmt/mod.rs @@ -93,16 +93,16 @@ pub async fn compact_messages( final_token_counts.push(0); } - // Add the compaction marker (user_visible=true, agent_visible=false) - let compaction_marker = Message::assistant() + // Add a system notification to inform the user (user_visible=true, agent_visible=false) + let system_notification = Message::assistant() .with_system_notification( SystemNotificationType::InlineMessage, "Conversation compacted and summarized", ) .with_metadata(MessageMetadata::user_only()); - let compaction_marker_tokens: usize = 0; // Not counted since agent_visible=false - final_messages.push(compaction_marker); - final_token_counts.push(compaction_marker_tokens); + let system_notification_tokens: usize = 0; // Not counted since agent_visible=false + final_messages.push(system_notification); + final_token_counts.push(system_notification_tokens); // Add the summary message (agent_visible=true, user_visible=false) let summary_msg = summary_message.with_metadata(MessageMetadata::agent_only()); diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index b04bc7327fc5..b10b6551751a 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -50,7 +50,6 @@ import RecipeActivities from './recipes/RecipeActivities'; import PopularChatTopics from './PopularChatTopics'; import ProgressiveMessageList from './ProgressiveMessageList'; import { View, ViewOptions } from '../utils/navigationUtils'; -import { ContextManagerProvider, useContextManager } from './context_management/ContextManager'; import { MainPanelLayout } from './Layout/MainPanelLayout'; import ChatInput from './ChatInput'; import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area'; @@ -115,7 +114,6 @@ function BaseChatContent({ const disableAnimation = location.state?.disableAnimation || false; const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false); const [currentRecipeTitle, setCurrentRecipeTitle] = React.useState(null); - const { isCompacting, handleManualCompaction } = useContextManager(); // Use shared chat engine const { @@ -391,26 +389,12 @@ function BaseChatContent({ {error.message || 'Honk! Goose experienced an error while responding'} - {/* Action buttons for all errors including token limit errors */} + {/* Action button to retry last message */}
{ clearError(); - - await handleManualCompaction( - messages, - setMessages, - append, - chat.sessionId - ); - }} - > - Summarize Conversation -
-
{ // Find the last user message const lastUserMessage = messages.reduceRight( (found, m) => found || (m.role === 'user' ? m : null), @@ -441,16 +425,10 @@ function BaseChatContent({ {/* Fixed loading indicator at bottom left of chat container */} - {(chatState !== ChatState.Idle || loadingChat || isCompacting) && ( + {(chatState !== ChatState.Idle || loadingChat) && (
@@ -524,9 +502,5 @@ function BaseChatContent({ } export default function BaseChat(props: BaseChatProps) { - return ( - - - - ); + return ; } diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index cdf5292fd089..1e6a1eda8b19 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -5,7 +5,6 @@ import LoadingGoose from './LoadingGoose'; import PopularChatTopics from './PopularChatTopics'; import ProgressiveMessageList from './ProgressiveMessageList'; import { View, ViewOptions } from '../utils/navigationUtils'; -import { ContextManagerProvider } from './context_management/ContextManager'; import { MainPanelLayout } from './Layout/MainPanelLayout'; import ChatInput from './ChatInput'; import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area'; @@ -56,9 +55,6 @@ function BaseChatContent({ const scrollRef = useRef(null); const disableAnimation = location.state?.disableAnimation || false; - // const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false); - // const [currentRecipeTitle, setCurrentRecipeTitle] = React.useState(null); - // const { isCompacting, handleManualCompaction } = useContextManager(); const isMobile = useIsMobile(); const { state: sidebarState } = useSidebar(); @@ -227,8 +223,6 @@ function BaseChatContent({ ); const showPopularTopics = messages.length === 0; - // TODO(Douwe): get this from the backend - const isCompacting = false; const initialPrompt = messages.length == 0 && recipe?.prompt ? recipe.prompt : ''; return ( @@ -371,18 +365,9 @@ function BaseChatContent({ {/* Fixed loading indicator at bottom left of chat container */} - {(messages.length === 0 || isCompacting) && !sessionLoadError && ( + {messages.length === 0 && !sessionLoadError && (
- +
)}
@@ -455,9 +440,5 @@ function BaseChatContent({ } export default function BaseChat(props: BaseChatProps) { - return ( - - - - ); + return ; } diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 7419f09909c8..3c3d0f9355e9 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -20,7 +20,6 @@ import { WaveformVisualizer } from './WaveformVisualizer'; import { toastError } from '../toasts'; import MentionPopover, { FileItemWithMatch } from './MentionPopover'; import { useDictationSettings } from '../hooks/useDictationSettings'; -import { useContextManager } from './context_management/ContextManager'; import { useChatContext } from '../contexts/ChatContext'; import { COST_TRACKING_ENABLED, VOICE_DICTATION_ELEVENLABS_ENABLED } from '../updates'; import { CostTracker } from './bottom_menu/CostTracker'; @@ -114,7 +113,7 @@ export default function ChatInput({ initialPrompt, toolCount, autoSubmit = false, - append, + append: _append, isExtensionsLoading = false, }: ChatInputProps) { const [_value, setValue] = useState(initialValue); @@ -136,7 +135,6 @@ export default function ChatInput({ const dropdownRef: React.RefObject = useRef( null ) as React.RefObject; - const { isCompacting, handleManualCompaction } = useContextManager(); const { getProviders, read } = useConfig(); const { getCurrentModelAndProvider, currentModel, currentProvider } = useModelAndProvider(); const [tokenLimit, setTokenLimit] = useState(TOKEN_LIMIT_DEFAULT); @@ -520,9 +518,6 @@ export default function ChatInput({ // Show alert when either there is registered token usage, or we know the limit if ((numTokens && numTokens > 0) || (isTokenLimitLoaded && tokenLimit)) { - // in these conditions we want it to be present but disabled - const compactButtonDisabled = !numTokens || isCompacting; - addAlert({ type: AlertType.Info, message: 'Context window', @@ -531,12 +526,25 @@ export default function ChatInput({ total: tokenLimit, }, showCompactButton: true, - compactButtonDisabled, - onCompact: () => { - // Hide the alert popup by dispatching a custom event that the popover can listen to - // Importantly, this leaves the alert so the dot still shows up, but hides the popover + compactButtonDisabled: !numTokens, + onCompact: async () => { + // Simple compact: just call the API endpoint and let the agent handle it window.dispatchEvent(new CustomEvent('hide-alert-popover')); - handleManualCompaction(messages, setMessages, append, sessionId || ''); + try { + const { manageContext } = await import('../api'); + const result = await manageContext({ + body: { + messages: messages, + sessionId: sessionId || '', + }, + }); + // Update messages with the compacted version from the backend + if (result.data) { + setMessages(result.data.messages); + } + } catch (err) { + console.error('Manual compaction failed:', err); + } }, compactIcon: , autoCompactThreshold: autoCompactThreshold, @@ -566,7 +574,6 @@ export default function ChatInput({ tokenLimit, isTokenLimitLoaded, addAlert, - isCompacting, clearAlerts, autoCompactThreshold, ]); @@ -937,7 +944,6 @@ export default function ChatInput({ const canSubmit = !isLoading && - !isCompacting && agentIsReady && (displayValue.trim() || pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) || @@ -1094,7 +1100,6 @@ export default function ChatInput({ e.preventDefault(); const canSubmit = !isLoading && - !isCompacting && agentIsReady && (displayValue.trim() || pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) || @@ -1149,7 +1154,6 @@ export default function ChatInput({ isAnyDroppedFileLoading || isRecording || isTranscribing || - isCompacting || !agentIsReady || isExtensionsLoading; @@ -1394,17 +1398,15 @@ export default function ChatInput({

{isExtensionsLoading ? 'Loading extensions...' - : isCompacting - ? 'Compacting conversation...' - : isAnyImageLoading - ? 'Waiting for images to save...' - : isAnyDroppedFileLoading - ? 'Processing dropped files...' - : isRecording - ? 'Recording...' - : isTranscribing - ? 'Transcribing...' - : (chatContext?.agentWaitingMessage ?? 'Send')} + : isAnyImageLoading + ? 'Waiting for images to save...' + : isAnyDroppedFileLoading + ? 'Processing dropped files...' + : isRecording + ? 'Recording...' + : isTranscribing + ? 'Transcribing...' + : (chatContext?.agentWaitingMessage ?? 'Send')}

diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx index f5de0aaab028..50efa1f75887 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -19,7 +19,6 @@ import { Message } from '../api'; import GooseMessage from './GooseMessage'; import UserMessage from './UserMessage'; import { SystemNotificationInline } from './context_management/SystemNotificationInline'; -import { useContextManager } from './context_management/ContextManager'; import { NotificationEvent } from '../hooks/useMessageStream'; import LoadingGoose from './LoadingGoose'; import { ChatType } from '../types/chat'; @@ -68,17 +67,10 @@ export default function ProgressiveMessageList({ const hasOnlyToolResponses = (message: Message) => message.content.every((c) => c.type === 'toolResponse'); - // Try to use context manager, but don't require it for session history - let hasCompactionMarker: ((message: Message) => boolean) | undefined; - - try { - const contextManager = useContextManager(); - hasCompactionMarker = contextManager.hasCompactionMarker; - } catch { - // Context manager not available (e.g., in session history view) - // This is fine, we'll just skip compaction marker functionality - hasCompactionMarker = undefined; - } + // Helper to check if a message contains a system notification + const hasSystemNotification = (message: Message): boolean => { + return message.content.some((content) => content.type === 'systemNotification'); + }; // Simple progressive loading - start immediately when component mounts if needed useEffect(() => { @@ -197,7 +189,7 @@ export default function ProgressiveMessageList({ > {isUser ? ( <> - {hasCompactionMarker && hasCompactionMarker(message) ? ( + {hasSystemNotification(message) ? ( ) : ( !hasOnlyToolResponses(message) && ( @@ -207,7 +199,7 @@ export default function ProgressiveMessageList({ ) : ( <> - {hasCompactionMarker && hasCompactionMarker(message) ? ( + {hasSystemNotification(message) ? ( ) : ( void, - append?: (message: Message) => void, - sessionId?: string - ) => Promise; - hasCompactionMarker: (message: Message) => boolean; -} - -// Create the context -const ContextManagerContext = createContext< - (ContextManagerState & ContextManagerActions) | undefined ->(undefined); - -// Create the provider component -export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [isCompacting, setIsCompacting] = useState(false); - const [compactionError, setCompactionError] = useState(null); - - const performCompaction = useCallback( - async ( - messages: Message[], - setMessages: (messages: Message[]) => void, - append: (message: Message) => void, - sessionId: string, - isManual: boolean = false - ) => { - setIsCompacting(true); - setCompactionError(null); - - try { - // Get the summary from the backend - const summaryResponse = await manageContextFromBackend({ - messages: messages, - manageAction: 'summarize', - sessionId: sessionId, - }); - - setMessages(summaryResponse.messages); - - // Only automatically submit the continuation message for auto-compaction (context limit reached) - // Manual compaction should just compact without continuing the conversation - if (!isManual) { - // Automatically submit the continuation message to continue the conversation - // This should be the third message (index 2) which contains the "I ran into a context length exceeded error..." text - const continuationMessage = summaryResponse.messages[2]; - if (continuationMessage) { - setTimeout(() => { - append(continuationMessage); - }, 100); - } - } - - setIsCompacting(false); - } catch (err) { - // TODO(Douwe): move this to the server - console.error('Error during compaction:', err); - setCompactionError(err instanceof Error ? err.message : 'Unknown error during compaction'); - - // Create an error marker - const errorMarker: Message = { - id: `compaction-error-${Date.now()}`, - role: 'assistant', - created: Math.floor(Date.now() / 1000), - content: [ - { - type: 'systemNotification', - msg: 'Compaction failed. Please try again or start a new session.', - notificationType: 'inlineMessage', - }, - ], - metadata: { userVisible: true, agentVisible: true }, - }; - - setMessages([...messages, errorMarker]); - setIsCompacting(false); - } - }, - [] - ); - - const handleManualCompaction = useCallback( - async ( - messages: Message[], - setMessages: (messages: Message[]) => void, - append?: (message: Message) => void, - sessionId?: string - ) => { - await performCompaction(messages, setMessages, append || (() => {}), sessionId || '', true); - }, - [performCompaction] - ); - - const hasCompactionMarker = useCallback((message: Message): boolean => { - return message.content.some((content) => content.type === 'systemNotification'); - }, []); - - const value = { - // State - isCompacting, - compactionError, - - // Actions - handleManualCompaction, - hasCompactionMarker, - }; - - return {children}; -}; - -// Create a hook to use the context -export const useContextManager = () => { - const context = useContext(ContextManagerContext); - if (context === undefined) { - throw new Error('useContextManager must be used within a ContextManagerProvider'); - } - return context; -}; diff --git a/ui/desktop/src/components/context_management/SystemNotificationInline.tsx b/ui/desktop/src/components/context_management/SystemNotificationInline.tsx index 7e6d2ff1c294..1cb3b08e34ff 100644 --- a/ui/desktop/src/components/context_management/SystemNotificationInline.tsx +++ b/ui/desktop/src/components/context_management/SystemNotificationInline.tsx @@ -11,7 +11,9 @@ export const SystemNotificationInline: React.FC = content.type === 'systemNotification' ); - const markerText = systemNotification?.msg || 'Conversation compacted'; + if (!systemNotification?.msg) { + return null; + } - return
{markerText}
; + return
{systemNotification.msg}
; }; diff --git a/ui/desktop/src/components/context_management/__tests__/CompactionMarker.test.tsx b/ui/desktop/src/components/context_management/__tests__/CompactionMarker.test.tsx deleted file mode 100644 index 19f739f97e81..000000000000 --- a/ui/desktop/src/components/context_management/__tests__/CompactionMarker.test.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { SystemNotificationInline } from '../SystemNotificationInline'; -import { Message } from '../../../api'; - -const default_message: Message = { - metadata: { - agentVisible: false, - userVisible: false, - }, - id: '1', - role: 'assistant', - created: 1000, - content: [], -}; - -describe('CompactionMarker', () => { - it('should render default message when no systemNotification content found', () => { - const message: Message = { - ...default_message, - content: [{ type: 'text', text: 'Regular message' }], - }; - - render(); - - expect(screen.getByText('Conversation compacted')).toBeInTheDocument(); - }); - - it('should render custom message from systemNotification content', () => { - const message: Message = { - ...default_message, - content: [ - { type: 'text', text: 'Some other content' }, - { - type: 'systemNotification', - msg: 'Custom compaction message', - notificationType: 'inlineMessage', - }, - ], - }; - - render(); - - expect(screen.getByText('Custom compaction message')).toBeInTheDocument(); - }); - - it('should handle empty message content array', () => { - const message: Message = { - ...default_message, - content: [], - }; - - render(); - - expect(screen.getByText('Conversation compacted')).toBeInTheDocument(); - }); - - it('should handle systemNotification content with empty msg', () => { - const message: Message = { - ...default_message, - content: [{ type: 'systemNotification', msg: '', notificationType: 'inlineMessage' }], - }; - - render(); - - // Empty string falls back to default due to || operator - expect(screen.getByText('Conversation compacted')).toBeInTheDocument(); - }); - - it('should handle systemNotification content with undefined msg', () => { - const message: Message = { - ...default_message, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - content: [{ type: 'systemNotification', notificationType: 'inlineMessage' } as any], - }; - - render(); - - // Should render the default message when msg is undefined - expect(screen.getByText('Conversation compacted')).toBeInTheDocument(); - }); -}); diff --git a/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx b/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx deleted file mode 100644 index 05154999af06..000000000000 --- a/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; -import { ContextManagerProvider, useContextManager } from '../ContextManager'; -import * as contextManagement from '../index'; -import { Message } from '../../../api'; - -const default_message: Message = { - metadata: { - agentVisible: false, - userVisible: false, - }, - id: '1', - role: 'assistant', - created: 1000, - content: [], -}; - -// Mock the context management functions -vi.mock('../index', () => ({ - manageContextFromBackend: vi.fn(), -})); - -const mockManageContextFromBackend = vi.mocked(contextManagement.manageContextFromBackend); - -describe('ContextManager', () => { - const mockMessages: Message[] = [ - { - ...default_message, - content: [{ type: 'text', text: 'Hello' }], - }, - { - ...default_message, - content: [{ type: 'text', text: 'Hi there!' }], - }, - ]; - - const mockSetMessages = vi.fn(); - const mockAppend = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - const renderContextManager = () => { - return renderHook(() => useContextManager(), { - wrapper: ({ children }) => {children}, - }); - }; - - describe('Initial State', () => { - it('should have correct initial state', () => { - const { result } = renderContextManager(); - - expect(result.current.isCompacting).toBe(false); - expect(result.current.compactionError).toBe(null); - }); - }); - - describe('hasCompactionMarker', () => { - it('should return true for messages with systemNotification content', () => { - const { result } = renderContextManager(); - const messageWithMarker: Message = { - ...default_message, - content: [ - { - type: 'systemNotification', - msg: 'Compaction marker', - notificationType: 'inlineMessage', - }, - ], - }; - - expect(result.current.hasCompactionMarker(messageWithMarker)).toBe(true); - }); - - it('should return false for messages without systemNotification content', () => { - const { result } = renderContextManager(); - const regularMessage: Message = { - ...default_message, - content: [{ type: 'text', text: 'Hello' }], - }; - - expect(result.current.hasCompactionMarker(regularMessage)).toBe(false); - }); - - it('should return true for messages with mixed content including systemNotification', () => { - const { result } = renderContextManager(); - const mixedMessage: Message = { - ...default_message, - content: [ - { type: 'text', text: 'Some text' }, - { - type: 'systemNotification', - msg: 'Compaction marker', - notificationType: 'inlineMessage', - }, - ], - }; - - expect(result.current.hasCompactionMarker(mixedMessage)).toBe(true); - }); - }); - - describe('handleManualCompaction', () => { - it('should perform compaction with server-provided messages', async () => { - mockManageContextFromBackend.mockResolvedValue({ - messages: [ - { - ...default_message, - content: [ - { - type: 'systemNotification', - msg: 'Conversation compacted and summarized', - notificationType: 'inlineMessage', - }, - ], - }, - { - ...default_message, - content: [{ type: 'text', text: 'Manual summary content' }], - }, - { - ...default_message, - content: [ - { - type: 'text', - text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context', - }, - ], - }, - ], - tokenCounts: [8, 100, 50], - }); - - const { result } = renderContextManager(); - - await act(async () => { - await result.current.handleManualCompaction( - mockMessages, - mockSetMessages, - mockAppend, - 'test-session-id' - ); - }); - - expect(mockManageContextFromBackend).toHaveBeenCalledWith({ - messages: mockMessages, - manageAction: 'summarize', - sessionId: 'test-session-id', - }); - - // Verify all three messages are set - expect(mockSetMessages).toHaveBeenCalledTimes(1); - const setMessagesCall = mockSetMessages.mock.calls[0][0]; - expect(setMessagesCall).toHaveLength(3); - expect(setMessagesCall[0]).toMatchObject({ - role: 'assistant', - content: [{ type: 'conversationCompacted', msg: 'Conversation compacted and summarized' }], - }); - expect(setMessagesCall[1]).toMatchObject({ - role: 'assistant', - content: [{ type: 'text', text: 'Manual summary content' }], - }); - expect(setMessagesCall[2]).toMatchObject({ - role: 'assistant', - content: [ - { - type: 'text', - text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context', - }, - ], - }); - - // Fast-forward timers to check if append would be called - act(() => { - vi.advanceTimersByTime(150); - }); - - // Should NOT append the continuation message for manual compaction - expect(mockAppend).not.toHaveBeenCalled(); - }); - - it('should work without append function', async () => { - mockManageContextFromBackend.mockResolvedValue({ - messages: [ - { - ...default_message, - content: [{ type: 'text', text: 'Manual summary content' }], - }, - ], - tokenCounts: [100, 50], - }); - - const { result } = renderContextManager(); - - await act(async () => { - await result.current.handleManualCompaction( - mockMessages, - mockSetMessages, - undefined // No append function - ); - }); - - expect(mockManageContextFromBackend).toHaveBeenCalled(); - // Should not throw error when append is undefined - - // Fast-forward timers to check if append would be called - act(() => { - vi.advanceTimersByTime(150); - }); - - // No append function provided, so no calls should be made - expect(mockAppend).not.toHaveBeenCalled(); - }); - - it('should not auto-continue conversation for manual compaction even with append function', async () => { - mockManageContextFromBackend.mockResolvedValue({ - messages: [ - { - ...default_message, - content: [ - { - type: 'systemNotification', - msg: 'Conversation compacted and summarized', - notificationType: 'inlineMessage', - }, - ], - }, - { - ...default_message, - content: [{ type: 'text', text: 'Manual summary content' }], - }, - { - ...default_message, - content: [ - { - type: 'text', - text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context', - }, - ], - }, - ], - tokenCounts: [8, 100, 50], - }); - - const { result } = renderContextManager(); - - await act(async () => { - await result.current.handleManualCompaction( - mockMessages, - mockSetMessages, - mockAppend, - 'test-session-id' - ); - }); - - // Verify all three messages are set - expect(mockSetMessages).toHaveBeenCalledTimes(1); - const setMessagesCall = mockSetMessages.mock.calls[0][0]; - expect(setMessagesCall).toHaveLength(3); - expect(setMessagesCall[0]).toMatchObject({ - role: 'assistant', - content: [{ type: 'conversationCompacted', msg: 'Conversation compacted and summarized' }], - }); - expect(setMessagesCall[1]).toMatchObject({ - role: 'assistant', - content: [{ type: 'text', text: 'Manual summary content' }], - }); - expect(setMessagesCall[2]).toMatchObject({ - role: 'assistant', - content: [ - { - type: 'text', - text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context', - }, - ], - }); - - // Fast-forward timers to check if append would be called - act(() => { - vi.advanceTimersByTime(150); - }); - - // Should NOT auto-continue for manual compaction, even with append function - expect(mockAppend).not.toHaveBeenCalled(); - }); - }); - - describe('Context Provider Error', () => { - it('should throw error when useContextManager is used outside provider', () => { - expect(() => { - renderHook(() => useContextManager()); - }).toThrow('useContextManager must be used within a ContextManagerProvider'); - }); - }); -}); diff --git a/ui/desktop/src/components/context_management/index.ts b/ui/desktop/src/components/context_management/index.ts deleted file mode 100644 index c4f706219678..000000000000 --- a/ui/desktop/src/components/context_management/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ContextManageRequest, ContextManageResponse, manageContext, Message } from '../../api'; - -export async function manageContextFromBackend({ - messages, - manageAction, - sessionId, -}: { - messages: Message[]; - manageAction: 'truncation' | 'summarize'; - sessionId: string; -}): Promise { - const contextManagementRequest = { manageAction, messages, sessionId }; - - // Cast to the API-expected type - const result = await manageContext({ - body: contextManagementRequest as unknown as ContextManageRequest, - }); - - // Check for errors in the result - if (result.error) { - throw new Error(`Context management failed: ${result.error}`); - } - - if (!result.data) { - throw new Error('Context management returned no data'); - } - - return result.data; -} diff --git a/ui/desktop/src/components/hub.tsx b/ui/desktop/src/components/hub.tsx index de9876b7b8e8..6fd4fe2dc04f 100644 --- a/ui/desktop/src/components/hub.tsx +++ b/ui/desktop/src/components/hub.tsx @@ -17,7 +17,6 @@ import { SessionInsights } from './sessions/SessionsInsights'; import ChatInput from './ChatInput'; import { ChatState } from '../types/chatState'; -import { ContextManagerProvider } from './context_management/ContextManager'; import 'react-toastify/dist/ReactToastify.css'; import { View, ViewOptions } from '../utils/navigationUtils'; @@ -51,35 +50,33 @@ export default function Hub({ }; return ( - -
-
- -
- - {}} - commandHistory={[]} - initialValue="" - setView={setView} - numTokens={0} - inputTokens={0} - outputTokens={0} - droppedFiles={[]} - onFilesProcessed={() => {}} - messages={[]} - setMessages={() => {}} - disableAnimation={false} - sessionCosts={undefined} - setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} - isExtensionsLoading={isExtensionsLoading} - toolCount={0} - /> +
+
+
- + + {}} + commandHistory={[]} + initialValue="" + setView={setView} + numTokens={0} + inputTokens={0} + outputTokens={0} + droppedFiles={[]} + onFilesProcessed={() => {}} + messages={[]} + setMessages={() => {}} + disableAnimation={false} + sessionCosts={undefined} + setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} + isExtensionsLoading={isExtensionsLoading} + toolCount={0} + /> +
); } diff --git a/ui/desktop/src/components/sessions/SessionHistoryView.tsx b/ui/desktop/src/components/sessions/SessionHistoryView.tsx index 4d297ae9d1ff..71f09b1137bb 100644 --- a/ui/desktop/src/components/sessions/SessionHistoryView.tsx +++ b/ui/desktop/src/components/sessions/SessionHistoryView.tsx @@ -28,7 +28,6 @@ import { } from '../ui/dialog'; import ProgressiveMessageList from '../ProgressiveMessageList'; import { SearchView } from '../conversation/SearchView'; -import { ContextManagerProvider } from '../context_management/ContextManager'; import BackButton from '../ui/BackButton'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip'; import { Message, Session } from '../../api'; @@ -101,29 +100,27 @@ const SessionMessages: React.FC<{
) : filteredMessages?.length > 0 ? ( - -
- - {}} // Read-only for session history - appendMessage={(newMessage) => { - // Read-only - do nothing - console.log('appendMessage called in read-only session history:', newMessage); - }} - isUserMessage={isUserMessage} // Use the same function as BaseChat - batchSize={15} // Same as BaseChat default - batchDelay={30} // Same as BaseChat default - showLoadingThreshold={30} // Same as BaseChat default - /> - -
-
+
+ + {}} // Read-only for session history + appendMessage={(newMessage) => { + // Read-only - do nothing + console.log('appendMessage called in read-only session history:', newMessage); + }} + isUserMessage={isUserMessage} // Use the same function as BaseChat + batchSize={15} // Same as BaseChat default + batchDelay={30} // Same as BaseChat default + showLoadingThreshold={30} // Same as BaseChat default + /> + +
) : (
From f9dada8ac0155145f438d342a54a332620169709 Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 20 Oct 2025 12:11:16 -0400 Subject: [PATCH 11/55] clean up reply --- crates/goose/src/agents/agent.rs | 96 ++++++++++++++------------------ 1 file changed, 41 insertions(+), 55 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 2f34a39fc9dc..26ec2cdfdb78 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -909,7 +909,6 @@ impl Agent { session: Option, cancel_token: Option, ) -> Result>> { - // Try to get session metadata for more accurate token counts let session_metadata = if let Some(session_config) = &session { SessionManager::get_session(&session_config.id, false) .await @@ -918,71 +917,58 @@ impl Agent { None }; - let check_result = crate::context_mgmt::check_if_compaction_needed( + let needs_compaction = crate::context_mgmt::check_if_compaction_needed( self, &unfixed_conversation, None, session_metadata.as_ref(), ) - .await; + .await?; - let (did_compact, compacted_conversation, compaction_error) = match check_result { - // TODO(dkatz): send a notification that we are starting compaction here. - Ok(true) => { - match crate::context_mgmt::compact_messages(self, &unfixed_conversation, false) - .await - { - Ok((conversation, _token_counts, _summarization_usage)) => { - (true, conversation, None) - } - Err(e) => (false, unfixed_conversation.clone(), Some(e)), - } - } - Ok(false) => (false, unfixed_conversation, None), - Err(e) => (false, unfixed_conversation.clone(), Some(e)), - }; + if !needs_compaction { + return self.reply_internal(unfixed_conversation, session, cancel_token).await; + } - if did_compact { - // Get threshold from config to include in message - let config = crate::config::Config::global(); - let threshold = config - .get_param::("GOOSE_AUTO_COMPACT_THRESHOLD") - .unwrap_or(DEFAULT_COMPACTION_THRESHOLD); - let threshold_percentage = (threshold * 100.0) as u32; - - let compaction_msg = format!( - "Exceeded auto-compact threshold of {}%. Context has been summarized and reduced.\n\n", - threshold_percentage + let config = crate::config::Config::global(); + let threshold = config + .get_param::("GOOSE_AUTO_COMPACT_THRESHOLD") + .unwrap_or(DEFAULT_COMPACTION_THRESHOLD); + let threshold_percentage = (threshold * 100.0) as u32; + + let compaction_msg = format!( + "Exceeded auto-compact threshold of {}%. Performing auto-compaction...", + threshold_percentage + ); + + Ok(Box::pin(async_stream::try_stream! { + yield AgentEvent::Message( + Message::assistant().with_system_notification( + SystemNotificationType::InlineMessage, + compaction_msg, + ) ); - Ok(Box::pin(async_stream::try_stream! { - // TODO(Douwe): send this before we actually compact: - yield AgentEvent::Message( - Message::assistant().with_system_notification( - SystemNotificationType::InlineMessage, - compaction_msg, - ) - ); - yield AgentEvent::HistoryReplaced(compacted_conversation.clone()); - if let Some(session_to_store) = &session { - SessionManager::replace_conversation(&session_to_store.id, &compacted_conversation).await? - } + match crate::context_mgmt::compact_messages(self, &unfixed_conversation, false).await { + Ok((compacted_conversation, _token_counts, _summarization_usage)) => { + // Replace history with compacted version + yield AgentEvent::HistoryReplaced(compacted_conversation.clone()); + if let Some(session_to_store) = &session { + SessionManager::replace_conversation(&session_to_store.id, &compacted_conversation).await?; + } - let mut reply_stream = self.reply_internal(compacted_conversation, session, cancel_token).await?; - while let Some(event) = reply_stream.next().await { - yield event?; + // Continue with normal reply flow + let mut reply_stream = self.reply_internal(compacted_conversation, session, cancel_token).await?; + while let Some(event) = reply_stream.next().await { + yield event?; + } } - })) - } else if let Some(error) = compaction_error { - Ok(Box::pin(async_stream::try_stream! { - yield AgentEvent::Message(Message::assistant().with_text( - format!("Ran into this error trying to auto-compact: {error}.\n\nPlease try again or create a new session") - )); - })) - } else { - self.reply_internal(compacted_conversation, session, cancel_token) - .await - } + Err(e) => { + yield AgentEvent::Message(Message::assistant().with_text( + format!("Ran into this error trying to auto-compact: {e}.\n\nPlease try again or create a new session") + )); + } + } + })) } /// Main reply method that handles the actual agent processing From c2139deb954eb89b44eda389b29dd0a87467d44a Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 20 Oct 2025 12:57:43 -0400 Subject: [PATCH 12/55] force user only on system notifications --- crates/goose/src/agents/agent.rs | 2 -- crates/goose/src/context_mgmt/mod.rs | 3 +-- crates/goose/src/conversation/message.rs | 11 +++++++++++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 26ec2cdfdb78..c2904e10819c 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -950,13 +950,11 @@ impl Agent { match crate::context_mgmt::compact_messages(self, &unfixed_conversation, false).await { Ok((compacted_conversation, _token_counts, _summarization_usage)) => { - // Replace history with compacted version yield AgentEvent::HistoryReplaced(compacted_conversation.clone()); if let Some(session_to_store) = &session { SessionManager::replace_conversation(&session_to_store.id, &compacted_conversation).await?; } - // Continue with normal reply flow let mut reply_stream = self.reply_internal(compacted_conversation, session, cancel_token).await?; while let Some(event) = reply_stream.next().await { yield event?; diff --git a/crates/goose/src/context_mgmt/mod.rs b/crates/goose/src/context_mgmt/mod.rs index 2ed077411107..5c6c18d51812 100644 --- a/crates/goose/src/context_mgmt/mod.rs +++ b/crates/goose/src/context_mgmt/mod.rs @@ -98,8 +98,7 @@ pub async fn compact_messages( .with_system_notification( SystemNotificationType::InlineMessage, "Conversation compacted and summarized", - ) - .with_metadata(MessageMetadata::user_only()); + ); let system_notification_tokens: usize = 0; // Not counted since agent_visible=false final_messages.push(system_notification); final_token_counts.push(system_notification_tokens); diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index eca53246f1b1..caeebbcedc13 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -670,6 +670,7 @@ impl Message { msg: S, ) -> Self { self.with_content(MessageContent::system_notification(notification_type, msg)) + .with_metadata(MessageMetadata::user_only()) } /// Set the visibility metadata for the message @@ -1216,4 +1217,14 @@ mod tests { assert_eq!(value["content"][0]["notificationType"], "inlineMessage"); assert_eq!(value["content"][0]["msg"], "Test notification"); } + + #[test] + fn test_system_notification_sets_correct_metadata() { + let message = Message::assistant() + .with_system_notification(SystemNotificationType::InlineMessage, "Test notification"); + + // System notifications should be user_visible=true, agent_visible=false + assert!(message.is_user_visible()); + assert!(!message.is_agent_visible()); + } } From b0e1dd312bb3c20f8b38f4955bf75fa4a6196d8b Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 20 Oct 2025 14:05:16 -0400 Subject: [PATCH 13/55] update notificiaiton --- crates/goose/src/agents/agent.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index c2904e10819c..323c0a020534 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1289,20 +1289,18 @@ impl Agent { } } Err(ProviderError::ContextLengthExceeded(_error_msg)) => { - info!("Context length exceeded, attempting compaction"); + yield AgentEvent::Message( + Message::assistant().with_system_notification( + SystemNotificationType::InlineMessage, + "Context limit reached. Attempting to compact and continue conversation...", + ) + ); - // TODO(dkatz): send a notification that we are starting compaction here. match crate::context_mgmt::compact_messages(self, &conversation, true).await { Ok((compacted_conversation, _token_counts, _usage)) => { conversation = compacted_conversation; did_recovery_compact_this_iteration = true; - - yield AgentEvent::Message( - Message::assistant().with_system_notification( - SystemNotificationType::InlineMessage, - "Context limit reached. Conversation has been automatically compacted to continue.", - ) - ); + yield AgentEvent::HistoryReplaced(conversation.clone()); if let Some(session_to_store) = &session { SessionManager::replace_conversation(&session_to_store.id, &conversation).await? From 293a6961a7dcd49f6ca3879dcee47d03e35acb22 Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 20 Oct 2025 14:22:05 -0400 Subject: [PATCH 14/55] client side compact msg --- ui/desktop/src/components/ChatInput.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 3c3d0f9355e9..fcd3b39e18b5 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -530,6 +530,24 @@ export default function ChatInput({ onCompact: async () => { // Simple compact: just call the API endpoint and let the agent handle it window.dispatchEvent(new CustomEvent('hide-alert-popover')); + + // TODO(dkatz). Set the spinner to compacting... here instead of this client method. + const startingCompactionMessage: Message = { + role: 'assistant', + created: Date.now() / 1000, + content: [ + { + type: 'systemNotification', + notificationType: 'inlineMessage', + msg: 'Compacting conversation...', + }, + ], + metadata: { userVisible: true, agentVisible: false }, + }; + + const messagesWithCompacting = [...messages, startingCompactionMessage]; + setMessages(messagesWithCompacting); + try { const { manageContext } = await import('../api'); const result = await manageContext({ From 85d446e2e8d58ea1198fff5055ba4473e03bfe4f Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 20 Oct 2025 14:47:51 -0400 Subject: [PATCH 15/55] fix typecheck --- ui/desktop/src/components/BaseChat.tsx | 2 ++ ui/desktop/src/components/BaseChat2.tsx | 3 ++- ui/desktop/src/components/ChatInput.tsx | 9 ++++++++- ui/desktop/src/hooks/useChatEngine.ts | 2 ++ ui/desktop/src/hooks/useChatStream.ts | 1 + ui/desktop/src/hooks/useMessageStream.ts | 6 ++++++ 6 files changed, 21 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 3cbc2cd4d1f7..7c83ac5dbe66 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -121,6 +121,7 @@ function BaseChatContent({ filteredMessages, append, chatState, + setChatState, error, setMessages, input, @@ -442,6 +443,7 @@ function BaseChatContent({ sessionId={chat.sessionId} handleSubmit={handleSubmit} chatState={chatState} + setChatState={setChatState} onStop={onStopGoose} commandHistory={commandHistory} initialValue={input || ''} diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index aef2ff6f87fb..823dd1b6b22b 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -126,7 +126,7 @@ function BaseChatContent({ } }, [chat.messages, chat.sessionId]); - const { chatState, handleSubmit, stopStreaming } = useChatStream({ + const { chatState, setChatState, handleSubmit, stopStreaming } = useChatStream({ sessionId: chat.sessionId || '', messages, setMessages, @@ -379,6 +379,7 @@ function BaseChatContent({ sessionId={chat?.sessionId || ''} handleSubmit={handleFormSubmit} chatState={chatState} + setChatState={setChatState} onStop={stopStreaming} //commandHistory={commandHistory} initialValue={initialPrompt} diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index fcd3b39e18b5..c34e4c562719 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -59,6 +59,7 @@ interface ChatInputProps { sessionId: string | null; handleSubmit: (e: React.FormEvent) => void; chatState: ChatState; + setChatState?: (state: ChatState) => void; onStop?: () => void; commandHistory?: string[]; // Current chat's message history initialValue?: string; @@ -93,6 +94,7 @@ export default function ChatInput({ sessionId, handleSubmit, chatState = ChatState.Idle, + setChatState, onStop, commandHistory = [], initialValue = '', @@ -531,7 +533,9 @@ export default function ChatInput({ // Simple compact: just call the API endpoint and let the agent handle it window.dispatchEvent(new CustomEvent('hide-alert-popover')); - // TODO(dkatz). Set the spinner to compacting... here instead of this client method. + // Show spinner while compacting + setChatState?.(ChatState.Thinking); + const startingCompactionMessage: Message = { role: 'assistant', created: Date.now() / 1000, @@ -562,6 +566,9 @@ export default function ChatInput({ } } catch (err) { console.error('Manual compaction failed:', err); + } finally { + // Clear spinner when done + setChatState?.(ChatState.Idle); } }, compactIcon: , diff --git a/ui/desktop/src/hooks/useChatEngine.ts b/ui/desktop/src/hooks/useChatEngine.ts index 1c0f6b7e47a4..15d00578f7fb 100644 --- a/ui/desktop/src/hooks/useChatEngine.ts +++ b/ui/desktop/src/hooks/useChatEngine.ts @@ -68,6 +68,7 @@ export const useChatEngine = ({ append: originalAppend, stop, chatState, + setChatState, error, setMessages, input: _input, @@ -433,6 +434,7 @@ export const useChatEngine = ({ append, stop, chatState, + setChatState, error, setMessages, diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index 142f60c4b541..e933a39b0655 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -135,6 +135,7 @@ export function useChatStream({ return { chatState, + setChatState, handleSubmit, stopStreaming, }; diff --git a/ui/desktop/src/hooks/useMessageStream.ts b/ui/desktop/src/hooks/useMessageStream.ts index 6e8febee04f0..b17ed4e1a66f 100644 --- a/ui/desktop/src/hooks/useMessageStream.ts +++ b/ui/desktop/src/hooks/useMessageStream.ts @@ -144,6 +144,11 @@ export interface UseMessageStreamHelpers { /** Current chat state (idle, thinking, streaming, waiting for user input) */ chatState: ChatState; + /** Update the chat state */ + setChatState: ( + state: ChatState | Promise + ) => Promise; + /** Add a tool result to a tool call */ addToolResult: ({ toolCallId, result }: { toolCallId: string; result: unknown }) => void; @@ -632,6 +637,7 @@ export function useMessageStream({ handleInputChange, handleSubmit, chatState, + setChatState: mutateChatState, addToolResult, updateMessageStreamBody, notifications, From a3c52784e503001d6398283df6d62861ef7eae00 Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 20 Oct 2025 15:26:15 -0400 Subject: [PATCH 16/55] experimental big thinking msg change --- crates/goose/src/agents/agent.rs | 16 +++++++++- ui/desktop/src/components/BaseChat.tsx | 5 ++- ui/desktop/src/components/BaseChat2.tsx | 12 +++++-- ui/desktop/src/components/ChatInput.tsx | 12 +++++-- .../SystemNotificationInline.tsx | 8 ++++- ui/desktop/src/hooks/useMessageStream.ts | 32 ++++++++++++------- ui/desktop/src/utils/thinkingMessage.ts | 30 +++++++++++++++++ 7 files changed, 95 insertions(+), 20 deletions(-) create mode 100644 ui/desktop/src/utils/thinkingMessage.ts diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 323c0a020534..2192f710f16b 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -948,6 +948,14 @@ impl Agent { ) ); + yield AgentEvent::Message( + Message::assistant().with_system_notification( + SystemNotificationType::ThinkingMessage, + "compacting conversation...", + ) + ); + + match crate::context_mgmt::compact_messages(self, &unfixed_conversation, false).await { Ok((compacted_conversation, _token_counts, _summarization_usage)) => { yield AgentEvent::HistoryReplaced(compacted_conversation.clone()); @@ -1289,6 +1297,12 @@ impl Agent { } } Err(ProviderError::ContextLengthExceeded(_error_msg)) => { + yield AgentEvent::Message( + Message::assistant().with_system_notification( + SystemNotificationType::ThinkingMessage, + "compacting conversation...", + ) + ); yield AgentEvent::Message( Message::assistant().with_system_notification( SystemNotificationType::InlineMessage, @@ -1300,7 +1314,7 @@ impl Agent { Ok((compacted_conversation, _token_counts, _usage)) => { conversation = compacted_conversation; did_recovery_compact_this_iteration = true; - + yield AgentEvent::HistoryReplaced(conversation.clone()); if let Some(session_to_store) = &session { SessionManager::replace_conversation(&session_to_store.id, &conversation).await? diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 7c83ac5dbe66..1a80e983f086 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -46,6 +46,7 @@ import { useLocation } from 'react-router-dom'; import { SearchView } from './conversation/SearchView'; import { AgentHeader } from './AgentHeader'; import LoadingGoose from './LoadingGoose'; +import { getThinkingMessage } from '../utils/thinkingMessage'; import RecipeActivities from './recipes/RecipeActivities'; import PopularChatTopics from './PopularChatTopics'; import ProgressiveMessageList from './ProgressiveMessageList'; @@ -429,7 +430,9 @@ function BaseChatContent({ {(chatState !== ChatState.Idle || loadingChat) && (
diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index 823dd1b6b22b..7c1ef331cc13 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { SearchView } from './conversation/SearchView'; import LoadingGoose from './LoadingGoose'; +import { getThinkingMessage } from '../utils/thinkingMessage'; import PopularChatTopics from './PopularChatTopics'; import ProgressiveMessageList from './ProgressiveMessageList'; import { View, ViewOptions } from '../utils/navigationUtils'; @@ -365,9 +366,16 @@ function BaseChatContent({ {/* Fixed loading indicator at bottom left of chat container */} - {messages.length === 0 && !sessionLoadError && ( + {(chatState !== ChatState.Idle || (messages.length === 0 && !sessionLoadError)) && (
- +
)}
diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index c34e4c562719..35fed8d3dfc9 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -536,20 +536,26 @@ export default function ChatInput({ // Show spinner while compacting setChatState?.(ChatState.Thinking); - const startingCompactionMessage: Message = { + // Add both a thinking message (for the indicator) and an inline message (to render in chat) + const compactingStatusMessage: Message = { role: 'assistant', created: Date.now() / 1000, content: [ + { + type: 'systemNotification', + notificationType: 'thinkingMessage', + msg: 'compacting conversation think...', + }, { type: 'systemNotification', notificationType: 'inlineMessage', - msg: 'Compacting conversation...', + msg: 'Compacting conversation inline...', }, ], metadata: { userVisible: true, agentVisible: false }, }; - const messagesWithCompacting = [...messages, startingCompactionMessage]; + const messagesWithCompacting = [...messages, compactingStatusMessage]; setMessages(messagesWithCompacting); try { diff --git a/ui/desktop/src/components/context_management/SystemNotificationInline.tsx b/ui/desktop/src/components/context_management/SystemNotificationInline.tsx index 1cb3b08e34ff..f7190569eb7c 100644 --- a/ui/desktop/src/components/context_management/SystemNotificationInline.tsx +++ b/ui/desktop/src/components/context_management/SystemNotificationInline.tsx @@ -5,10 +5,16 @@ interface SystemNotificationInlineProps { message: Message; } +/** + * Renders inline system notification messages in the chat. + * Only renders 'inlineMessage' type notifications. + * Note: 'thinkingMessage' types are NOT rendered - they only affect the thinking state indicator. + */ + export const SystemNotificationInline: React.FC = ({ message }) => { const systemNotification = message.content.find( (content): content is SystemNotificationContent & { type: 'systemNotification' } => - content.type === 'systemNotification' + content.type === 'systemNotification' && content.notificationType === 'inlineMessage' ); if (!systemNotification?.msg) { diff --git a/ui/desktop/src/hooks/useMessageStream.ts b/ui/desktop/src/hooks/useMessageStream.ts index b17ed4e1a66f..021deec41cc5 100644 --- a/ui/desktop/src/hooks/useMessageStream.ts +++ b/ui/desktop/src/hooks/useMessageStream.ts @@ -277,9 +277,6 @@ export function useMessageStream({ switch (parsedEvent.type) { case 'Message': { - // Transition from waiting to streaming on first message - mutateChatState(ChatState.Streaming); - // Create a new message object with the properties preserved or defaulted const newMessage: Message = { ...parsedEvent.message, @@ -289,6 +286,26 @@ export function useMessageStream({ content: parsedEvent.message.content || [], }; + // Check message content to determine appropriate state + const hasToolConfirmation = newMessage.content.some( + (content) => content.type === 'toolConfirmationRequest' + ); + + const hasThinkingMessage = newMessage.content.some( + (content) => + content.type === 'systemNotification' && + content.notificationType === 'thinkingMessage' + ); + + // Set appropriate state based on message content + if (hasToolConfirmation) { + mutateChatState(ChatState.WaitingForUserInput); + } else if (hasThinkingMessage) { + mutateChatState(ChatState.Thinking); + } else { + mutateChatState(ChatState.Streaming); + } + // Update messages with the new message if ( newMessage.id && @@ -303,15 +320,6 @@ export function useMessageStream({ currentMessages = [...currentMessages, newMessage]; } - // Check if this message contains tool confirmation requests - const hasToolConfirmation = newMessage.content.some( - (content) => content.type === 'toolConfirmationRequest' - ); - - if (hasToolConfirmation) { - mutateChatState(ChatState.WaitingForUserInput); - } - mutate(currentMessages, false); break; } diff --git a/ui/desktop/src/utils/thinkingMessage.ts b/ui/desktop/src/utils/thinkingMessage.ts new file mode 100644 index 000000000000..9b67d8160a78 --- /dev/null +++ b/ui/desktop/src/utils/thinkingMessage.ts @@ -0,0 +1,30 @@ +import { Message } from '../api'; +import { ChatState } from '../types/chatState'; + +/** + * Extracts the current thinking message from the message stream. + * Only looks for systemNotification messages with type 'thinkingMessage'. + * These are NOT rendered in the chat - they only hijack the thinking state text. + * Note: 'inlineMessage' types ARE rendered in the chat and should not be used here. + */ +export function getThinkingMessage(messages: Message[], chatState: ChatState): string | undefined { + // Only look for thinking messages when we're in a loading state + if (chatState === ChatState.Idle) { + return undefined; + } + + // Check the last message for a system notification + const lastMessage = messages[messages.length - 1]; + if (!lastMessage || lastMessage.role !== 'assistant') { + return undefined; + } + + // Look for thinkingMessage systemNotification content only + for (const content of lastMessage.content) { + if (content.type === 'systemNotification' && content.notificationType === 'thinkingMessage') { + return content.msg; + } + } + + return undefined; +} From 8acc16cd3c99b94c26fb1fc8f9826afe94fb8daa Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 20 Oct 2025 15:32:22 -0400 Subject: [PATCH 17/55] simplify slightly --- crates/goose/src/agents/agent.rs | 10 +++--- ui/desktop/src/hooks/useMessageStream.ts | 43 +++++++++++++----------- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 2192f710f16b..7ef94245ded6 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1299,14 +1299,14 @@ impl Agent { Err(ProviderError::ContextLengthExceeded(_error_msg)) => { yield AgentEvent::Message( Message::assistant().with_system_notification( - SystemNotificationType::ThinkingMessage, - "compacting conversation...", + SystemNotificationType::InlineMessage, + "Context limit reached. Attempting to compact and continue conversation...", ) ); - yield AgentEvent::Message( + yield AgentEvent::Message( Message::assistant().with_system_notification( - SystemNotificationType::InlineMessage, - "Context limit reached. Attempting to compact and continue conversation...", + SystemNotificationType::ThinkingMessage, + "compacting conversation...", ) ); diff --git a/ui/desktop/src/hooks/useMessageStream.ts b/ui/desktop/src/hooks/useMessageStream.ts index 021deec41cc5..ff2ddd4f5a5f 100644 --- a/ui/desktop/src/hooks/useMessageStream.ts +++ b/ui/desktop/src/hooks/useMessageStream.ts @@ -277,6 +277,9 @@ export function useMessageStream({ switch (parsedEvent.type) { case 'Message': { + // Transition from waiting to streaming on first message + mutateChatState(ChatState.Streaming); + // Create a new message object with the properties preserved or defaulted const newMessage: Message = { ...parsedEvent.message, @@ -286,26 +289,6 @@ export function useMessageStream({ content: parsedEvent.message.content || [], }; - // Check message content to determine appropriate state - const hasToolConfirmation = newMessage.content.some( - (content) => content.type === 'toolConfirmationRequest' - ); - - const hasThinkingMessage = newMessage.content.some( - (content) => - content.type === 'systemNotification' && - content.notificationType === 'thinkingMessage' - ); - - // Set appropriate state based on message content - if (hasToolConfirmation) { - mutateChatState(ChatState.WaitingForUserInput); - } else if (hasThinkingMessage) { - mutateChatState(ChatState.Thinking); - } else { - mutateChatState(ChatState.Streaming); - } - // Update messages with the new message if ( newMessage.id && @@ -320,6 +303,26 @@ export function useMessageStream({ currentMessages = [...currentMessages, newMessage]; } + // Check if this message contains tool confirmation requests + const hasToolConfirmation = newMessage.content.some( + (content) => content.type === 'toolConfirmationRequest' + ); + + if (hasToolConfirmation) { + mutateChatState(ChatState.WaitingForUserInput); + } + + // Check if this message contains a thinking message notification + const hasThinkingMessage = newMessage.content.some( + (content) => + content.type === 'systemNotification' && + content.notificationType === 'thinkingMessage' + ); + + if (hasThinkingMessage) { + mutateChatState(ChatState.Thinking); + } + mutate(currentMessages, false); break; } From e1c9b62f2eea77c31e1246a6d33ce750a1e06472 Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 20 Oct 2025 15:52:36 -0400 Subject: [PATCH 18/55] cli support --- crates/goose-cli/src/session/output.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index e7520b8ffac0..b1e0066da556 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -186,7 +186,17 @@ pub fn render_message(message: &Message, debug: bool) { print_markdown("Thinking was redacted", theme); } MessageContent::SystemNotification(notification) => { - println!("\n{}", style(¬ification.msg).yellow()); + use goose::conversation::message::SystemNotificationType; + + match notification.notification_type { + SystemNotificationType::ThinkingMessage => { + show_thinking(); + set_thinking_message(¬ification.msg); + } + SystemNotificationType::InlineMessage => { + println!("\n{}", style(¬ification.msg).yellow()); + } + } } _ => { println!("WARNING: Message content type could not be rendered"); From 44f16740b97888ba883d32c69b7c5d65a3daf2a0 Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 20 Oct 2025 16:16:16 -0400 Subject: [PATCH 19/55] msg string --- crates/goose/src/agents/agent.rs | 5 +++-- ui/desktop/src/components/ChatInput.tsx | 6 ++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 7ef94245ded6..3cfab9eb84c9 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -67,6 +67,7 @@ use crate::session::extension_data::{EnabledExtensionsState, ExtensionState}; use crate::session::SessionManager; const DEFAULT_MAX_TURNS: u32 = 1000; +const COMPACTION_THINKING_TEXT: &str = "Goose is compacting the conversation..."; /// Context needed for the reply function pub struct ReplyContext { @@ -951,7 +952,7 @@ impl Agent { yield AgentEvent::Message( Message::assistant().with_system_notification( SystemNotificationType::ThinkingMessage, - "compacting conversation...", + COMPACTION_THINKING_TEXT, ) ); @@ -1306,7 +1307,7 @@ impl Agent { yield AgentEvent::Message( Message::assistant().with_system_notification( SystemNotificationType::ThinkingMessage, - "compacting conversation...", + COMPACTION_THINKING_TEXT, ) ); diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 35fed8d3dfc9..ac6d3b322fb2 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -535,8 +535,6 @@ export default function ChatInput({ // Show spinner while compacting setChatState?.(ChatState.Thinking); - - // Add both a thinking message (for the indicator) and an inline message (to render in chat) const compactingStatusMessage: Message = { role: 'assistant', created: Date.now() / 1000, @@ -544,12 +542,12 @@ export default function ChatInput({ { type: 'systemNotification', notificationType: 'thinkingMessage', - msg: 'compacting conversation think...', + msg: 'Goose is compacting the conversation...', }, { type: 'systemNotification', notificationType: 'inlineMessage', - msg: 'Compacting conversation inline...', + msg: 'Compacting conversation...', }, ], metadata: { userVisible: true, agentVisible: false }, From 2f623d443c40cf376c1993120570b2332bddc194 Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 20 Oct 2025 16:17:02 -0400 Subject: [PATCH 20/55] fmt --- crates/goose-server/src/openapi.rs | 6 +++--- crates/goose/src/agents/agent.rs | 4 +++- crates/goose/src/context_mgmt/mod.rs | 9 ++++----- crates/goose/src/conversation/message.rs | 4 +++- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index d63c1ef8d493..fb85d20ef8ee 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -18,9 +18,9 @@ use goose::config::declarative_providers::{ DeclarativeProviderConfig, LoadedProvider, ProviderEngine, }; use goose::conversation::message::{ - FrontendToolRequest, Message, MessageContent, MessageMetadata, - RedactedThinkingContent, SystemNotificationContent, SystemNotificationType, ThinkingContent, - ToolConfirmationRequest, ToolRequest, ToolResponse, + FrontendToolRequest, Message, MessageContent, MessageMetadata, RedactedThinkingContent, + SystemNotificationContent, SystemNotificationType, ThinkingContent, ToolConfirmationRequest, + ToolRequest, ToolResponse, }; use utoipa::openapi::schema::{ diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 3cfab9eb84c9..020be0c1abf7 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -927,7 +927,9 @@ impl Agent { .await?; if !needs_compaction { - return self.reply_internal(unfixed_conversation, session, cancel_token).await; + return self + .reply_internal(unfixed_conversation, session, cancel_token) + .await; } let config = crate::config::Config::global(); diff --git a/crates/goose/src/context_mgmt/mod.rs b/crates/goose/src/context_mgmt/mod.rs index 5c6c18d51812..855f344813e3 100644 --- a/crates/goose/src/context_mgmt/mod.rs +++ b/crates/goose/src/context_mgmt/mod.rs @@ -94,11 +94,10 @@ pub async fn compact_messages( } // Add a system notification to inform the user (user_visible=true, agent_visible=false) - let system_notification = Message::assistant() - .with_system_notification( - SystemNotificationType::InlineMessage, - "Conversation compacted and summarized", - ); + let system_notification = Message::assistant().with_system_notification( + SystemNotificationType::InlineMessage, + "Conversation compacted and summarized", + ); let system_notification_tokens: usize = 0; // Not counted since agent_visible=false final_messages.push(system_notification); final_token_counts.push(system_notification_tokens); diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index caeebbcedc13..0ca70d0b7976 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -713,7 +713,9 @@ impl Message { #[cfg(test)] mod tests { - use crate::conversation::message::{Message, MessageContent, MessageMetadata, SystemNotificationType}; + use crate::conversation::message::{ + Message, MessageContent, MessageMetadata, SystemNotificationType, + }; use crate::conversation::*; use rmcp::model::{ AnnotateAble, CallToolRequestParam, PromptMessage, PromptMessageContent, PromptMessageRole, From ecb523a8a34ffd2571ccc832335a3edb837f856d Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 20 Oct 2025 16:29:42 -0400 Subject: [PATCH 21/55] minor cleanup --- ui/desktop/src/components/ChatInput.tsx | 4 -- .../src/components/ProgressiveMessageList.tsx | 69 ++++++++++--------- .../SystemNotificationInline.tsx | 6 -- ui/desktop/src/hooks/useMessageStream.ts | 1 - ui/desktop/src/utils/thinkingMessage.ts | 7 -- 5 files changed, 36 insertions(+), 51 deletions(-) diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index ac6d3b322fb2..a05757217cb2 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -530,10 +530,8 @@ export default function ChatInput({ showCompactButton: true, compactButtonDisabled: !numTokens, onCompact: async () => { - // Simple compact: just call the API endpoint and let the agent handle it window.dispatchEvent(new CustomEvent('hide-alert-popover')); - // Show spinner while compacting setChatState?.(ChatState.Thinking); const compactingStatusMessage: Message = { role: 'assistant', @@ -564,14 +562,12 @@ export default function ChatInput({ sessionId: sessionId || '', }, }); - // Update messages with the compacted version from the backend if (result.data) { setMessages(result.data.messages); } } catch (err) { console.error('Manual compaction failed:', err); } finally { - // Clear spinner when done setChatState?.(ChatState.Idle); } }, diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx index 50efa1f75887..78f11090e419 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -67,9 +67,11 @@ export default function ProgressiveMessageList({ const hasOnlyToolResponses = (message: Message) => message.content.every((c) => c.type === 'toolResponse'); - // Helper to check if a message contains a system notification - const hasSystemNotification = (message: Message): boolean => { - return message.content.some((content) => content.type === 'systemNotification'); + const hasInlineSystemNotification = (message: Message): boolean => { + return message.content.some( + (content) => + content.type === 'systemNotification' && content.notificationType === 'inlineMessage' + ); }; // Simple progressive loading - start immediately when component mounts if needed @@ -179,6 +181,19 @@ export default function ProgressiveMessageList({ return null; } + // System notifications are never user messages, handle them first + if (hasInlineSystemNotification(message)) { + return ( +
+ +
+ ); + } + const isUser = isUserMessage(message); return ( @@ -188,37 +203,25 @@ export default function ProgressiveMessageList({ data-testid="message-container" > {isUser ? ( - <> - {hasSystemNotification(message) ? ( - - ) : ( - !hasOnlyToolResponses(message) && ( - - ) - )} - + !hasOnlyToolResponses(message) && ( + + ) ) : ( - <> - {hasSystemNotification(message) ? ( - - ) : ( - - )} - + )}
); diff --git a/ui/desktop/src/components/context_management/SystemNotificationInline.tsx b/ui/desktop/src/components/context_management/SystemNotificationInline.tsx index f7190569eb7c..71b0763a8a5b 100644 --- a/ui/desktop/src/components/context_management/SystemNotificationInline.tsx +++ b/ui/desktop/src/components/context_management/SystemNotificationInline.tsx @@ -5,12 +5,6 @@ interface SystemNotificationInlineProps { message: Message; } -/** - * Renders inline system notification messages in the chat. - * Only renders 'inlineMessage' type notifications. - * Note: 'thinkingMessage' types are NOT rendered - they only affect the thinking state indicator. - */ - export const SystemNotificationInline: React.FC = ({ message }) => { const systemNotification = message.content.find( (content): content is SystemNotificationContent & { type: 'systemNotification' } => diff --git a/ui/desktop/src/hooks/useMessageStream.ts b/ui/desktop/src/hooks/useMessageStream.ts index ff2ddd4f5a5f..03893463526b 100644 --- a/ui/desktop/src/hooks/useMessageStream.ts +++ b/ui/desktop/src/hooks/useMessageStream.ts @@ -312,7 +312,6 @@ export function useMessageStream({ mutateChatState(ChatState.WaitingForUserInput); } - // Check if this message contains a thinking message notification const hasThinkingMessage = newMessage.content.some( (content) => content.type === 'systemNotification' && diff --git a/ui/desktop/src/utils/thinkingMessage.ts b/ui/desktop/src/utils/thinkingMessage.ts index 9b67d8160a78..25ef1be074e3 100644 --- a/ui/desktop/src/utils/thinkingMessage.ts +++ b/ui/desktop/src/utils/thinkingMessage.ts @@ -1,12 +1,5 @@ import { Message } from '../api'; import { ChatState } from '../types/chatState'; - -/** - * Extracts the current thinking message from the message stream. - * Only looks for systemNotification messages with type 'thinkingMessage'. - * These are NOT rendered in the chat - they only hijack the thinking state text. - * Note: 'inlineMessage' types ARE rendered in the chat and should not be used here. - */ export function getThinkingMessage(messages: Message[], chatState: ChatState): string | undefined { // Only look for thinking messages when we're in a loading state if (chatState === ChatState.Idle) { From 22db6f8dec5b78ea2858a391ee677cf8ccf2a63b Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 20 Oct 2025 16:36:00 -0400 Subject: [PATCH 22/55] rm a couple more comments --- ui/desktop/src/utils/thinkingMessage.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/ui/desktop/src/utils/thinkingMessage.ts b/ui/desktop/src/utils/thinkingMessage.ts index 25ef1be074e3..2d7bdc4b9f18 100644 --- a/ui/desktop/src/utils/thinkingMessage.ts +++ b/ui/desktop/src/utils/thinkingMessage.ts @@ -1,18 +1,15 @@ import { Message } from '../api'; import { ChatState } from '../types/chatState'; export function getThinkingMessage(messages: Message[], chatState: ChatState): string | undefined { - // Only look for thinking messages when we're in a loading state if (chatState === ChatState.Idle) { return undefined; } - // Check the last message for a system notification const lastMessage = messages[messages.length - 1]; if (!lastMessage || lastMessage.role !== 'assistant') { return undefined; } - // Look for thinkingMessage systemNotification content only for (const content of lastMessage.content) { if (content.type === 'systemNotification' && content.notificationType === 'thinkingMessage') { return content.msg; From 91516861b216300072c56459356dea5c732b7a26 Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 20 Oct 2025 17:18:35 -0400 Subject: [PATCH 23/55] setMessage cleanup in progress --- crates/goose-server/src/routes/reply.rs | 111 ++++++++++++++++++++++++ ui/desktop/src/components/ChatInput.tsx | 49 ++--------- 2 files changed, 120 insertions(+), 40 deletions(-) diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index f5af162570a0..94f298dcbf83 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -214,6 +214,17 @@ pub async fn reply( let messages = Conversation::new_unvalidated(request.messages); + // Check if this is a manual compaction request + let is_manual_compact = messages.messages().last().map_or(false, |msg| { + msg.content.iter().any(|c| { + if let MessageContent::Text(text) = c { + text.text.trim() == "manual-compact" + } else { + false + } + }) + }); + let task_cancel = cancel_token.clone(); let task_tx = tx.clone(); @@ -234,6 +245,106 @@ pub async fn reply( } }; + // Handle manual compaction request + if is_manual_compact { + use goose::conversation::message::{Message as GooseMessage, SystemNotificationType}; + + // Send thinking message + let thinking_msg = GooseMessage::assistant() + .with_system_notification( + SystemNotificationType::ThinkingMessage, + "compacting conversation...", + ); + stream_event( + MessageEvent::Message { + message: thinking_msg, + }, + &task_tx, + &task_cancel, + ) + .await; + + // Filter out the "manual-compact" message from conversation + let filtered_messages: Vec = messages.messages() + .iter() + .filter_map(|msg| { + let filtered_content: Vec = msg.content.iter() + .filter(|c| { + if let MessageContent::Text(text) = c { + text.text.trim() != "manual-compact" + } else { + true + } + }) + .cloned() + .collect(); + + if filtered_content.is_empty() { + None + } else { + let mut filtered_msg = msg.clone(); + filtered_msg.content = filtered_content; + Some(filtered_msg) + } + }) + .collect(); + + let msgs_without_compact = Conversation::new_unvalidated(filtered_messages); + + // Perform compaction + match goose::context_mgmt::compact_messages(&agent, &msgs_without_compact, false).await + { + Ok((compacted_messages, _, _)) => { + // Send the compacted conversation + stream_event( + MessageEvent::UpdateConversation { + conversation: compacted_messages.clone(), + }, + &task_tx, + &task_cancel, + ) + .await; + + // Send completion message + let complete_msg = GooseMessage::assistant() + .with_system_notification( + SystemNotificationType::InlineMessage, + "Compaction complete", + ); + stream_event( + MessageEvent::Message { + message: complete_msg, + }, + &task_tx, + &task_cancel, + ) + .await; + + // Send finish event + stream_event( + MessageEvent::Finish { + reason: "Manual compaction completed".to_string(), + }, + &task_tx, + &task_cancel, + ) + .await; + } + Err(e) => { + tracing::error!("Compaction failed: {}", e); + stream_event( + MessageEvent::Error { + error: format!("Compaction failed: {}", e), + }, + &task_tx, + &task_cancel, + ) + .await; + } + } + return; + } + let session = match SessionManager::get_session(&session_id, false).await { Ok(metadata) => metadata, Err(e) => { diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index a05757217cb2..8f5ae8e73ac0 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -94,7 +94,7 @@ export default function ChatInput({ sessionId, handleSubmit, chatState = ChatState.Idle, - setChatState, + setChatState: _setChatState, onStop, commandHistory = [], initialValue = '', @@ -105,7 +105,7 @@ export default function ChatInput({ inputTokens, outputTokens, messages = [], - setMessages, + setMessages: _setMessages, disableAnimation = false, sessionCosts, setIsGoosehintsModalOpen, @@ -529,47 +529,16 @@ export default function ChatInput({ }, showCompactButton: true, compactButtonDisabled: !numTokens, - onCompact: async () => { + onCompact: () => { window.dispatchEvent(new CustomEvent('hide-alert-popover')); - setChatState?.(ChatState.Thinking); - const compactingStatusMessage: Message = { - role: 'assistant', - created: Date.now() / 1000, - content: [ - { - type: 'systemNotification', - notificationType: 'thinkingMessage', - msg: 'Goose is compacting the conversation...', - }, - { - type: 'systemNotification', - notificationType: 'inlineMessage', - msg: 'Compacting conversation...', - }, - ], - metadata: { userVisible: true, agentVisible: false }, - }; - - const messagesWithCompacting = [...messages, compactingStatusMessage]; - setMessages(messagesWithCompacting); + // Trigger normal submission with "manual-compact" command + // The server will handle it through the streaming endpoint + const customEvent = new CustomEvent('submit', { + detail: { value: 'manual-compact' }, + }) as unknown as React.FormEvent; - try { - const { manageContext } = await import('../api'); - const result = await manageContext({ - body: { - messages: messages, - sessionId: sessionId || '', - }, - }); - if (result.data) { - setMessages(result.data.messages); - } - } catch (err) { - console.error('Manual compaction failed:', err); - } finally { - setChatState?.(ChatState.Idle); - } + handleSubmit(customEvent); }, compactIcon: , autoCompactThreshold: autoCompactThreshold, From 4925dba2443cf81089e619abe00ec5a3ecfa96d0 Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 20 Oct 2025 18:08:38 -0400 Subject: [PATCH 24/55] big change -> client streams compact message --- crates/goose-server/src/routes/reply.rs | 111 --------------------- crates/goose/src/agents/agent.rs | 122 ++++++++++++++++++----- ui/desktop/src/components/BaseChat.tsx | 1 - ui/desktop/src/components/BaseChat2.tsx | 3 +- ui/desktop/src/components/ChatInput.tsx | 4 - ui/desktop/src/hooks/useChatStream.ts | 6 +- ui/desktop/src/hooks/useMessageStream.ts | 1 - 7 files changed, 102 insertions(+), 146 deletions(-) diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index 94f298dcbf83..f5af162570a0 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -214,17 +214,6 @@ pub async fn reply( let messages = Conversation::new_unvalidated(request.messages); - // Check if this is a manual compaction request - let is_manual_compact = messages.messages().last().map_or(false, |msg| { - msg.content.iter().any(|c| { - if let MessageContent::Text(text) = c { - text.text.trim() == "manual-compact" - } else { - false - } - }) - }); - let task_cancel = cancel_token.clone(); let task_tx = tx.clone(); @@ -245,106 +234,6 @@ pub async fn reply( } }; - // Handle manual compaction request - if is_manual_compact { - use goose::conversation::message::{Message as GooseMessage, SystemNotificationType}; - - // Send thinking message - let thinking_msg = GooseMessage::assistant() - .with_system_notification( - SystemNotificationType::ThinkingMessage, - "compacting conversation...", - ); - stream_event( - MessageEvent::Message { - message: thinking_msg, - }, - &task_tx, - &task_cancel, - ) - .await; - - // Filter out the "manual-compact" message from conversation - let filtered_messages: Vec = messages.messages() - .iter() - .filter_map(|msg| { - let filtered_content: Vec = msg.content.iter() - .filter(|c| { - if let MessageContent::Text(text) = c { - text.text.trim() != "manual-compact" - } else { - true - } - }) - .cloned() - .collect(); - - if filtered_content.is_empty() { - None - } else { - let mut filtered_msg = msg.clone(); - filtered_msg.content = filtered_content; - Some(filtered_msg) - } - }) - .collect(); - - let msgs_without_compact = Conversation::new_unvalidated(filtered_messages); - - // Perform compaction - match goose::context_mgmt::compact_messages(&agent, &msgs_without_compact, false).await - { - Ok((compacted_messages, _, _)) => { - // Send the compacted conversation - stream_event( - MessageEvent::UpdateConversation { - conversation: compacted_messages.clone(), - }, - &task_tx, - &task_cancel, - ) - .await; - - // Send completion message - let complete_msg = GooseMessage::assistant() - .with_system_notification( - SystemNotificationType::InlineMessage, - "Compaction complete", - ); - stream_event( - MessageEvent::Message { - message: complete_msg, - }, - &task_tx, - &task_cancel, - ) - .await; - - // Send finish event - stream_event( - MessageEvent::Finish { - reason: "Manual compaction completed".to_string(), - }, - &task_tx, - &task_cancel, - ) - .await; - } - Err(e) => { - tracing::error!("Compaction failed: {}", e); - stream_event( - MessageEvent::Error { - error: format!("Compaction failed: {}", e), - }, - &task_tx, - &task_cancel, - ) - .await; - } - } - return; - } - let session = match SessionManager::get_session(&session_id, false).await { Ok(metadata) => metadata, Err(e) => { diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 020be0c1abf7..3cd1282ce0a3 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -62,7 +62,7 @@ use super::model_selector::autopilot::AutoPilot; use super::platform_tools; use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE}; use crate::agents::subagent_task_config::TaskConfig; -use crate::conversation::message::{Message, SystemNotificationType, ToolRequest}; +use crate::conversation::message::{Message, MessageContent, SystemNotificationType, ToolRequest}; use crate::session::extension_data::{EnabledExtensionsState, ExtensionState}; use crate::session::SessionManager; @@ -903,6 +903,55 @@ impl Agent { } } + /// Performs compaction with proper notifications and error handling + /// + /// If continue_with_reply is true, continues with reply_internal after compaction. + /// Otherwise, sends a completion message and finishes. + pub async fn do_compact( + &self, + conversation: Conversation, + session: Option, + cancel_token: Option, + continue_with_reply: bool, + ) -> Result>> { + Ok(Box::pin(async_stream::try_stream! { + yield AgentEvent::Message( + Message::assistant().with_system_notification( + SystemNotificationType::ThinkingMessage, + COMPACTION_THINKING_TEXT, + ) + ); + + match crate::context_mgmt::compact_messages(self, &conversation, false).await { + Ok((compacted_conversation, _token_counts, _summarization_usage)) => { + yield AgentEvent::HistoryReplaced(compacted_conversation.clone()); + if let Some(session_to_store) = &session { + SessionManager::replace_conversation(&session_to_store.id, &compacted_conversation).await?; + } + + if continue_with_reply { + let mut reply_stream = self.reply_internal(compacted_conversation, session, cancel_token).await?; + while let Some(event) = reply_stream.next().await { + yield event?; + } + } else { + yield AgentEvent::Message( + Message::assistant().with_system_notification( + SystemNotificationType::InlineMessage, + "Compaction complete", + ) + ); + } + } + Err(e) => { + yield AgentEvent::Message(Message::assistant().with_text( + format!("Ran into this error trying to compact: {e}.\n\nPlease try again or create a new session") + )); + } + } + })) + } + #[instrument(skip(self, unfixed_conversation, session), fields(user_message))] pub async fn reply( &self, @@ -910,6 +959,47 @@ impl Agent { session: Option, cancel_token: Option, ) -> Result>> { + // Check if this is a manual compaction request + let is_manual_compact = unfixed_conversation.messages().last().map_or(false, |msg| { + msg.content.iter().any(|c| { + if let MessageContent::Text(text) = c { + text.text.trim() == "manual-compact" + } else { + false + } + }) + }); + + if is_manual_compact { + // Filter out the "manual-compact" message from conversation + let filtered_messages: Vec = unfixed_conversation.messages() + .iter() + .filter_map(|msg| { + let filtered_content: Vec = msg.content.iter() + .filter(|c| { + if let MessageContent::Text(text) = c { + text.text.trim() != "manual-compact" + } else { + true + } + }) + .cloned() + .collect(); + + if filtered_content.is_empty() { + None + } else { + let mut filtered_msg = msg.clone(); + filtered_msg.content = filtered_content; + Some(filtered_msg) + } + }) + .collect(); + + let conversation_without_compact = Conversation::new_unvalidated(filtered_messages); + return self.do_compact(conversation_without_compact, session, cancel_token, false).await; + } + let session_metadata = if let Some(session_config) = &session { SessionManager::get_session(&session_config.id, false) .await @@ -944,6 +1034,7 @@ impl Agent { ); Ok(Box::pin(async_stream::try_stream! { + // Send inline message about threshold yield AgentEvent::Message( Message::assistant().with_system_notification( SystemNotificationType::InlineMessage, @@ -951,31 +1042,10 @@ impl Agent { ) ); - yield AgentEvent::Message( - Message::assistant().with_system_notification( - SystemNotificationType::ThinkingMessage, - COMPACTION_THINKING_TEXT, - ) - ); - - - match crate::context_mgmt::compact_messages(self, &unfixed_conversation, false).await { - Ok((compacted_conversation, _token_counts, _summarization_usage)) => { - yield AgentEvent::HistoryReplaced(compacted_conversation.clone()); - if let Some(session_to_store) = &session { - SessionManager::replace_conversation(&session_to_store.id, &compacted_conversation).await?; - } - - let mut reply_stream = self.reply_internal(compacted_conversation, session, cancel_token).await?; - while let Some(event) = reply_stream.next().await { - yield event?; - } - } - Err(e) => { - yield AgentEvent::Message(Message::assistant().with_text( - format!("Ran into this error trying to auto-compact: {e}.\n\nPlease try again or create a new session") - )); - } + // Use do_compact to handle the rest + let mut compact_stream = self.do_compact(unfixed_conversation, session, cancel_token, true).await?; + while let Some(event) = compact_stream.next().await { + yield event?; } })) } diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 1a80e983f086..40228d303713 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -446,7 +446,6 @@ function BaseChatContent({ sessionId={chat.sessionId} handleSubmit={handleSubmit} chatState={chatState} - setChatState={setChatState} onStop={onStopGoose} commandHistory={commandHistory} initialValue={input || ''} diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index 7c1ef331cc13..909bf8867671 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -127,7 +127,7 @@ function BaseChatContent({ } }, [chat.messages, chat.sessionId]); - const { chatState, setChatState, handleSubmit, stopStreaming } = useChatStream({ + const { chatState, handleSubmit, stopStreaming } = useChatStream({ sessionId: chat.sessionId || '', messages, setMessages, @@ -387,7 +387,6 @@ function BaseChatContent({ sessionId={chat?.sessionId || ''} handleSubmit={handleFormSubmit} chatState={chatState} - setChatState={setChatState} onStop={stopStreaming} //commandHistory={commandHistory} initialValue={initialPrompt} diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 8f5ae8e73ac0..1fba5a5145d6 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -59,7 +59,6 @@ interface ChatInputProps { sessionId: string | null; handleSubmit: (e: React.FormEvent) => void; chatState: ChatState; - setChatState?: (state: ChatState) => void; onStop?: () => void; commandHistory?: string[]; // Current chat's message history initialValue?: string; @@ -70,7 +69,6 @@ interface ChatInputProps { inputTokens?: number; outputTokens?: number; messages?: Message[]; - setMessages: (messages: Message[]) => void; sessionCosts?: { [key: string]: { inputTokens: number; @@ -94,7 +92,6 @@ export default function ChatInput({ sessionId, handleSubmit, chatState = ChatState.Idle, - setChatState: _setChatState, onStop, commandHistory = [], initialValue = '', @@ -105,7 +102,6 @@ export default function ChatInput({ inputTokens, outputTokens, messages = [], - setMessages: _setMessages, disableAnimation = false, sessionCosts, setIsGoosehintsModalOpen, diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index e933a39b0655..bf72387991fb 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -102,6 +102,11 @@ export function useChatStream({ setMessages(currentMessages); } + if (event.type === 'UpdateConversation' && event.conversation) { + currentMessages = event.conversation; + setMessages(currentMessages); + } + if (event.error) { console.error('Stream error:', event.error); setChatState(ChatState.Idle); @@ -135,7 +140,6 @@ export function useChatStream({ return { chatState, - setChatState, handleSubmit, stopStreaming, }; diff --git a/ui/desktop/src/hooks/useMessageStream.ts b/ui/desktop/src/hooks/useMessageStream.ts index 03893463526b..2ed246d46bdf 100644 --- a/ui/desktop/src/hooks/useMessageStream.ts +++ b/ui/desktop/src/hooks/useMessageStream.ts @@ -647,7 +647,6 @@ export function useMessageStream({ handleInputChange, handleSubmit, chatState, - setChatState: mutateChatState, addToolResult, updateMessageStreamBody, notifications, From dfddf3e89a54e3fc2356cebfc99c9b3479f65204 Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 20 Oct 2025 18:22:22 -0400 Subject: [PATCH 25/55] remove messy do_compaction abstraction --- crates/goose/src/agents/agent.rs | 196 +++++++++++++++---------------- 1 file changed, 97 insertions(+), 99 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 3cd1282ce0a3..faa3facd7815 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -903,55 +903,6 @@ impl Agent { } } - /// Performs compaction with proper notifications and error handling - /// - /// If continue_with_reply is true, continues with reply_internal after compaction. - /// Otherwise, sends a completion message and finishes. - pub async fn do_compact( - &self, - conversation: Conversation, - session: Option, - cancel_token: Option, - continue_with_reply: bool, - ) -> Result>> { - Ok(Box::pin(async_stream::try_stream! { - yield AgentEvent::Message( - Message::assistant().with_system_notification( - SystemNotificationType::ThinkingMessage, - COMPACTION_THINKING_TEXT, - ) - ); - - match crate::context_mgmt::compact_messages(self, &conversation, false).await { - Ok((compacted_conversation, _token_counts, _summarization_usage)) => { - yield AgentEvent::HistoryReplaced(compacted_conversation.clone()); - if let Some(session_to_store) = &session { - SessionManager::replace_conversation(&session_to_store.id, &compacted_conversation).await?; - } - - if continue_with_reply { - let mut reply_stream = self.reply_internal(compacted_conversation, session, cancel_token).await?; - while let Some(event) = reply_stream.next().await { - yield event?; - } - } else { - yield AgentEvent::Message( - Message::assistant().with_system_notification( - SystemNotificationType::InlineMessage, - "Compaction complete", - ) - ); - } - } - Err(e) => { - yield AgentEvent::Message(Message::assistant().with_text( - format!("Ran into this error trying to compact: {e}.\n\nPlease try again or create a new session") - )); - } - } - })) - } - #[instrument(skip(self, unfixed_conversation, session), fields(user_message))] pub async fn reply( &self, @@ -970,12 +921,54 @@ impl Agent { }) }); - if is_manual_compact { - // Filter out the "manual-compact" message from conversation - let filtered_messages: Vec = unfixed_conversation.messages() + let compaction_inline_msg = if is_manual_compact { + None // Manual compact doesn't need inline message, goes straight to thinking + } else { + let session_metadata = if let Some(session_config) = &session { + SessionManager::get_session(&session_config.id, false) + .await + .ok() + } else { + None + }; + + let needs_auto_compact = crate::context_mgmt::check_if_compaction_needed( + self, + &unfixed_conversation, + None, + session_metadata.as_ref(), + ) + .await?; + + if !needs_auto_compact { + return self + .reply_internal(unfixed_conversation, session, cancel_token) + .await; + } + + let config = crate::config::Config::global(); + let threshold = config + .get_param::("GOOSE_AUTO_COMPACT_THRESHOLD") + .unwrap_or(DEFAULT_COMPACTION_THRESHOLD); + let threshold_percentage = (threshold * 100.0) as u32; + + let compaction_msg = format!( + "Exceeded auto-compact threshold of {}%. Performing auto-compaction...", + threshold_percentage + ); + + Some(compaction_msg) + }; + + // Prepare conversation for compaction + let conversation_to_compact = if is_manual_compact { + let filtered_messages: Vec = unfixed_conversation + .messages() .iter() .filter_map(|msg| { - let filtered_content: Vec = msg.content.iter() + let filtered_content: Vec = msg + .content + .iter() .filter(|c| { if let MessageContent::Text(text) = c { text.text.trim() != "manual-compact" @@ -995,57 +988,61 @@ impl Agent { } }) .collect(); - - let conversation_without_compact = Conversation::new_unvalidated(filtered_messages); - return self.do_compact(conversation_without_compact, session, cancel_token, false).await; - } - - let session_metadata = if let Some(session_config) = &session { - SessionManager::get_session(&session_config.id, false) - .await - .ok() + Conversation::new_unvalidated(filtered_messages) } else { - None + unfixed_conversation }; - let needs_compaction = crate::context_mgmt::check_if_compaction_needed( - self, - &unfixed_conversation, - None, - session_metadata.as_ref(), - ) - .await?; - - if !needs_compaction { - return self - .reply_internal(unfixed_conversation, session, cancel_token) - .await; - } - - let config = crate::config::Config::global(); - let threshold = config - .get_param::("GOOSE_AUTO_COMPACT_THRESHOLD") - .unwrap_or(DEFAULT_COMPACTION_THRESHOLD); - let threshold_percentage = (threshold * 100.0) as u32; - - let compaction_msg = format!( - "Exceeded auto-compact threshold of {}%. Performing auto-compaction...", - threshold_percentage - ); - Ok(Box::pin(async_stream::try_stream! { - // Send inline message about threshold + // Send optional inline message (for auto-compact) + if let Some(inline_msg) = compaction_inline_msg { + yield AgentEvent::Message( + Message::assistant().with_system_notification( + SystemNotificationType::InlineMessage, + inline_msg, + ) + ); + } + + // Send thinking message yield AgentEvent::Message( Message::assistant().with_system_notification( - SystemNotificationType::InlineMessage, - compaction_msg, + SystemNotificationType::ThinkingMessage, + COMPACTION_THINKING_TEXT, ) ); - // Use do_compact to handle the rest - let mut compact_stream = self.do_compact(unfixed_conversation, session, cancel_token, true).await?; - while let Some(event) = compact_stream.next().await { - yield event?; + // Perform compaction + match crate::context_mgmt::compact_messages(self, &conversation_to_compact, false).await { + Ok((compacted_conversation, _token_counts, _summarization_usage)) => { + // Save to session + if let Some(session_to_store) = &session { + SessionManager::replace_conversation(&session_to_store.id, &compacted_conversation).await?; + } + + // Yield history replaced + yield AgentEvent::HistoryReplaced(compacted_conversation.clone()); + + // Continue with reply for auto-compact, or send completion for manual-compact + if is_manual_compact { + yield AgentEvent::Message( + Message::assistant().with_system_notification( + SystemNotificationType::InlineMessage, + "Compaction complete", + ) + ); + } else { + let mut reply_stream = self.reply_internal(compacted_conversation, session, cancel_token).await?; + while let Some(event) = reply_stream.next().await { + yield event?; + } + } + } + Err(e) => { + yield AgentEvent::Message(Message::assistant().with_text( + format!("Ran into this error trying to compact: {e}.\n\nPlease try again or create a new session") + )); + } } })) } @@ -1376,7 +1373,7 @@ impl Agent { "Context limit reached. Attempting to compact and continue conversation...", ) ); - yield AgentEvent::Message( + yield AgentEvent::Message( Message::assistant().with_system_notification( SystemNotificationType::ThinkingMessage, COMPACTION_THINKING_TEXT, @@ -1385,13 +1382,14 @@ impl Agent { match crate::context_mgmt::compact_messages(self, &conversation, true).await { Ok((compacted_conversation, _token_counts, _usage)) => { + if let Some(session_to_store) = &session { + SessionManager::replace_conversation(&session_to_store.id, &compacted_conversation).await? + } + conversation = compacted_conversation; did_recovery_compact_this_iteration = true; yield AgentEvent::HistoryReplaced(conversation.clone()); - if let Some(session_to_store) = &session { - SessionManager::replace_conversation(&session_to_store.id, &conversation).await? - } continue; } Err(e) => { From b82dec7983b68563df2eb8a2fb487a6dd7af705a Mon Sep 17 00:00:00 2001 From: David Katz Date: Mon, 20 Oct 2025 18:31:52 -0400 Subject: [PATCH 26/55] Change manual compaction command to /compact and remove state setters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Replace "manual-compact" command with "/compact" for better UX - Remove all traces of setChatState exposure from TypeScript codebase - Remove setMessages prop from ChatInput component The setChatState exposure was a bad abstraction that enabled incorrect state manipulation. Now state is controlled entirely by the server through message content, with only internal useState usage remaining. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- crates/goose/src/agents/agent.rs | 4 ++-- ui/desktop/src/components/BaseChat.tsx | 2 -- ui/desktop/src/components/BaseChat2.tsx | 1 - ui/desktop/src/components/ChatInput.tsx | 4 ++-- ui/desktop/src/components/hub.tsx | 1 - ui/desktop/src/hooks/useChatEngine.ts | 2 -- ui/desktop/src/hooks/useMessageStream.ts | 5 ----- 7 files changed, 4 insertions(+), 15 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index faa3facd7815..4387dc78943c 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -914,7 +914,7 @@ impl Agent { let is_manual_compact = unfixed_conversation.messages().last().map_or(false, |msg| { msg.content.iter().any(|c| { if let MessageContent::Text(text) = c { - text.text.trim() == "manual-compact" + text.text.trim() == "/compact" } else { false } @@ -971,7 +971,7 @@ impl Agent { .iter() .filter(|c| { if let MessageContent::Text(text) = c { - text.text.trim() != "manual-compact" + text.text.trim() != "/compact" } else { true } diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 40228d303713..284a28ce5fa0 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -122,7 +122,6 @@ function BaseChatContent({ filteredMessages, append, chatState, - setChatState, error, setMessages, input, @@ -456,7 +455,6 @@ function BaseChatContent({ droppedFiles={droppedFiles} onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing messages={messages} - setMessages={setMessages} disableAnimation={disableAnimation} sessionCosts={sessionCosts} setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index 909bf8867671..93846a8310dd 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -397,7 +397,6 @@ function BaseChatContent({ droppedFiles={droppedFiles} onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing messages={messages} - setMessages={(_m) => {}} disableAnimation={disableAnimation} //sessionCosts={sessionCosts} setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 1fba5a5145d6..e3f48e0f8a7f 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -528,10 +528,10 @@ export default function ChatInput({ onCompact: () => { window.dispatchEvent(new CustomEvent('hide-alert-popover')); - // Trigger normal submission with "manual-compact" command + // Trigger normal submission with "/compact" command // The server will handle it through the streaming endpoint const customEvent = new CustomEvent('submit', { - detail: { value: 'manual-compact' }, + detail: { value: '/compact' }, }) as unknown as React.FormEvent; handleSubmit(customEvent); diff --git a/ui/desktop/src/components/hub.tsx b/ui/desktop/src/components/hub.tsx index 6fd4fe2dc04f..a8982b3045f9 100644 --- a/ui/desktop/src/components/hub.tsx +++ b/ui/desktop/src/components/hub.tsx @@ -70,7 +70,6 @@ export default function Hub({ droppedFiles={[]} onFilesProcessed={() => {}} messages={[]} - setMessages={() => {}} disableAnimation={false} sessionCosts={undefined} setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} diff --git a/ui/desktop/src/hooks/useChatEngine.ts b/ui/desktop/src/hooks/useChatEngine.ts index 15d00578f7fb..1c0f6b7e47a4 100644 --- a/ui/desktop/src/hooks/useChatEngine.ts +++ b/ui/desktop/src/hooks/useChatEngine.ts @@ -68,7 +68,6 @@ export const useChatEngine = ({ append: originalAppend, stop, chatState, - setChatState, error, setMessages, input: _input, @@ -434,7 +433,6 @@ export const useChatEngine = ({ append, stop, chatState, - setChatState, error, setMessages, diff --git a/ui/desktop/src/hooks/useMessageStream.ts b/ui/desktop/src/hooks/useMessageStream.ts index 2ed246d46bdf..38a93ac24817 100644 --- a/ui/desktop/src/hooks/useMessageStream.ts +++ b/ui/desktop/src/hooks/useMessageStream.ts @@ -144,11 +144,6 @@ export interface UseMessageStreamHelpers { /** Current chat state (idle, thinking, streaming, waiting for user input) */ chatState: ChatState; - /** Update the chat state */ - setChatState: ( - state: ChatState | Promise - ) => Promise; - /** Add a tool result to a tool call */ addToolResult: ({ toolCallId, result }: { toolCallId: string; result: unknown }) => void; From 2cb863671c18f781d7991c9c5215cc7c766a0f7d Mon Sep 17 00:00:00 2001 From: David Katz Date: Tue, 21 Oct 2025 11:03:51 -0400 Subject: [PATCH 27/55] reply cleanup --- crates/goose/src/agents/agent.rs | 90 ++++++++-------------------- crates/goose/src/context_mgmt/mod.rs | 9 --- 2 files changed, 24 insertions(+), 75 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 4387dc78943c..5e6b90dc6246 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -49,7 +49,7 @@ use crate::tool_monitor::RepetitionInspector; use crate::utils::is_token_cancelled; use regex::Regex; use rmcp::model::{ - CallToolRequestParam, Content, ErrorCode, ErrorData, GetPromptResult, Prompt, + CallToolRequestParam, Content, ErrorCode, ErrorData, GetPromptResult, Prompt, Role, ServerNotification, Tool, }; use serde_json::Value; @@ -910,7 +910,6 @@ impl Agent { session: Option, cancel_token: Option, ) -> Result>> { - // Check if this is a manual compaction request let is_manual_compact = unfixed_conversation.messages().last().map_or(false, |msg| { msg.content.iter().any(|c| { if let MessageContent::Text(text) = c { @@ -921,9 +920,7 @@ impl Agent { }) }); - let compaction_inline_msg = if is_manual_compact { - None // Manual compact doesn't need inline message, goes straight to thinking - } else { + if !is_manual_compact { let session_metadata = if let Some(session_config) = &session { SessionManager::get_session(&session_config.id, false) .await @@ -945,57 +942,23 @@ impl Agent { .reply_internal(unfixed_conversation, session, cancel_token) .await; } + } - let config = crate::config::Config::global(); - let threshold = config - .get_param::("GOOSE_AUTO_COMPACT_THRESHOLD") - .unwrap_or(DEFAULT_COMPACTION_THRESHOLD); - let threshold_percentage = (threshold * 100.0) as u32; - - let compaction_msg = format!( - "Exceeded auto-compact threshold of {}%. Performing auto-compaction...", - threshold_percentage - ); - - Some(compaction_msg) - }; - - // Prepare conversation for compaction - let conversation_to_compact = if is_manual_compact { - let filtered_messages: Vec = unfixed_conversation - .messages() - .iter() - .filter_map(|msg| { - let filtered_content: Vec = msg - .content - .iter() - .filter(|c| { - if let MessageContent::Text(text) = c { - text.text.trim() != "/compact" - } else { - true - } - }) - .cloned() - .collect(); - - if filtered_content.is_empty() { - None - } else { - let mut filtered_msg = msg.clone(); - filtered_msg.content = filtered_content; - Some(filtered_msg) - } - }) - .collect(); - Conversation::new_unvalidated(filtered_messages) - } else { - unfixed_conversation - }; + let conversation_to_compact = unfixed_conversation.clone(); Ok(Box::pin(async_stream::try_stream! { - // Send optional inline message (for auto-compact) - if let Some(inline_msg) = compaction_inline_msg { + if !is_manual_compact { + let config = crate::config::Config::global(); + let threshold = config + .get_param::("GOOSE_AUTO_COMPACT_THRESHOLD") + .unwrap_or(DEFAULT_COMPACTION_THRESHOLD); + let threshold_percentage = (threshold * 100.0) as u32; + + let inline_msg = format!( + "Exceeded auto-compact threshold of {}%. Performing auto-compaction...", + threshold_percentage + ); + yield AgentEvent::Message( Message::assistant().with_system_notification( SystemNotificationType::InlineMessage, @@ -1004,7 +967,6 @@ impl Agent { ); } - // Send thinking message yield AgentEvent::Message( Message::assistant().with_system_notification( SystemNotificationType::ThinkingMessage, @@ -1012,26 +974,22 @@ impl Agent { ) ); - // Perform compaction match crate::context_mgmt::compact_messages(self, &conversation_to_compact, false).await { Ok((compacted_conversation, _token_counts, _summarization_usage)) => { - // Save to session if let Some(session_to_store) = &session { SessionManager::replace_conversation(&session_to_store.id, &compacted_conversation).await?; } - // Yield history replaced yield AgentEvent::HistoryReplaced(compacted_conversation.clone()); - // Continue with reply for auto-compact, or send completion for manual-compact - if is_manual_compact { - yield AgentEvent::Message( - Message::assistant().with_system_notification( - SystemNotificationType::InlineMessage, - "Compaction complete", - ) - ); - } else { + yield AgentEvent::Message( + Message::assistant().with_system_notification( + SystemNotificationType::InlineMessage, + "Compaction complete", + ) + ); + + if !is_manual_compact { let mut reply_stream = self.reply_internal(compacted_conversation, session, cancel_token).await?; while let Some(event) = reply_stream.next().await { yield event?; diff --git a/crates/goose/src/context_mgmt/mod.rs b/crates/goose/src/context_mgmt/mod.rs index 855f344813e3..6307cc7839a3 100644 --- a/crates/goose/src/context_mgmt/mod.rs +++ b/crates/goose/src/context_mgmt/mod.rs @@ -93,15 +93,6 @@ pub async fn compact_messages( final_token_counts.push(0); } - // Add a system notification to inform the user (user_visible=true, agent_visible=false) - let system_notification = Message::assistant().with_system_notification( - SystemNotificationType::InlineMessage, - "Conversation compacted and summarized", - ); - let system_notification_tokens: usize = 0; // Not counted since agent_visible=false - final_messages.push(system_notification); - final_token_counts.push(system_notification_tokens); - // Add the summary message (agent_visible=true, user_visible=false) let summary_msg = summary_message.with_metadata(MessageMetadata::agent_only()); // For token counting purposes, we use the output tokens (the actual summary content) From ab83a4c06c3eba0401d128164d6a593389683260 Mon Sep 17 00:00:00 2001 From: David Katz Date: Tue, 21 Oct 2025 11:21:47 -0400 Subject: [PATCH 28/55] minor deletes --- crates/goose/src/agents/agent.rs | 2 +- crates/goose/src/context_mgmt/mod.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 5e6b90dc6246..2ad6b09d6935 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -49,7 +49,7 @@ use crate::tool_monitor::RepetitionInspector; use crate::utils::is_token_cancelled; use regex::Regex; use rmcp::model::{ - CallToolRequestParam, Content, ErrorCode, ErrorData, GetPromptResult, Prompt, Role, + CallToolRequestParam, Content, ErrorCode, ErrorData, GetPromptResult, Prompt, ServerNotification, Tool, }; use serde_json::Value; diff --git a/crates/goose/src/context_mgmt/mod.rs b/crates/goose/src/context_mgmt/mod.rs index 6307cc7839a3..8a8480877246 100644 --- a/crates/goose/src/context_mgmt/mod.rs +++ b/crates/goose/src/context_mgmt/mod.rs @@ -1,5 +1,5 @@ use crate::conversation::message::MessageMetadata; -use crate::conversation::message::{Message, MessageContent, SystemNotificationType}; +use crate::conversation::message::{Message, MessageContent}; use crate::conversation::Conversation; use crate::prompt_template::render_global_file; use crate::providers::base::{Provider, ProviderUsage}; From 80380bc0ae5e01fcfa956d79ee3a4025fcd8f7e0 Mon Sep 17 00:00:00 2001 From: David Katz Date: Tue, 21 Oct 2025 12:43:07 -0400 Subject: [PATCH 29/55] clippy and rename --- crates/goose/src/agents/agent.rs | 5 +++-- ui/desktop/src/components/ChatInput.tsx | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 2ad6b09d6935..ad81fa3a6be9 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -68,6 +68,7 @@ use crate::session::SessionManager; const DEFAULT_MAX_TURNS: u32 = 1000; const COMPACTION_THINKING_TEXT: &str = "Goose is compacting the conversation..."; +const MANUAL_COMPACT_TRIGGER: &str = "Please compact this conversation"; /// Context needed for the reply function pub struct ReplyContext { @@ -910,10 +911,10 @@ impl Agent { session: Option, cancel_token: Option, ) -> Result>> { - let is_manual_compact = unfixed_conversation.messages().last().map_or(false, |msg| { + let is_manual_compact = unfixed_conversation.messages().last().is_some_and(|msg| { msg.content.iter().any(|c| { if let MessageContent::Text(text) = c { - text.text.trim() == "/compact" + text.text.trim() == MANUAL_COMPACT_TRIGGER } else { false } diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index e3f48e0f8a7f..b711dc3470ce 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -50,6 +50,9 @@ const MAX_IMAGE_SIZE_MB = 5; const TOKEN_LIMIT_DEFAULT = 128000; // fallback for custom models that the backend doesn't know about const TOOLS_MAX_SUGGESTED = 60; // max number of tools before we show a warning +// Manual compact trigger message - must match backend constant +const MANUAL_COMPACT_TRIGGER = 'Please compact this conversation'; + interface ModelLimit { pattern: string; context_limit: number; @@ -528,10 +531,8 @@ export default function ChatInput({ onCompact: () => { window.dispatchEvent(new CustomEvent('hide-alert-popover')); - // Trigger normal submission with "/compact" command - // The server will handle it through the streaming endpoint const customEvent = new CustomEvent('submit', { - detail: { value: '/compact' }, + detail: { value: MANUAL_COMPACT_TRIGGER }, }) as unknown as React.FormEvent; handleSubmit(customEvent); From b60a69fa0d9b3974456a2ea1d88b7becbf35d126 Mon Sep 17 00:00:00 2001 From: David Katz Date: Tue, 21 Oct 2025 12:54:18 -0400 Subject: [PATCH 30/55] delete manual compact routes --- crates/goose-server/src/openapi.rs | 3 - crates/goose-server/src/routes/context.rs | 68 ----------------------- crates/goose-server/src/routes/mod.rs | 2 - 3 files changed, 73 deletions(-) delete mode 100644 crates/goose-server/src/routes/context.rs diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index fb85d20ef8ee..e3b045ec2114 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -350,7 +350,6 @@ derive_utoipa!(Icon as IconSchema); super::routes::agent::update_router_tool_selector, super::routes::reply::confirm_permission, super::routes::reply::reply, - super::routes::context::manage_context, super::routes::session::list_sessions, super::routes::session::get_session, super::routes::session::get_session_insights, @@ -393,8 +392,6 @@ derive_utoipa!(Icon as IconSchema); super::routes::config_management::UpdateCustomProviderRequest, super::routes::reply::PermissionConfirmationRequest, super::routes::reply::ChatRequest, - super::routes::context::ContextManageRequest, - super::routes::context::ContextManageResponse, super::routes::session::ImportSessionRequest, super::routes::session::SessionListResponse, super::routes::session::UpdateSessionDescriptionRequest, diff --git a/crates/goose-server/src/routes/context.rs b/crates/goose-server/src/routes/context.rs deleted file mode 100644 index 97f964280269..000000000000 --- a/crates/goose-server/src/routes/context.rs +++ /dev/null @@ -1,68 +0,0 @@ -use crate::state::AppState; -use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; -use goose::conversation::{message::Message, Conversation}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use utoipa::ToSchema; - -/// Request payload for context management operations -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContextManageRequest { - /// Collection of messages to be managed - pub messages: Vec, - /// Optional session ID for session-specific agent - pub session_id: String, -} - -/// Response from context management operations -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContextManageResponse { - /// Processed messages after the operation - pub messages: Vec, - /// Token counts for each processed message - pub token_counts: Vec, -} - -#[utoipa::path( - post, - path = "/context/manage", - request_body = ContextManageRequest, - responses( - (status = 200, description = "Context managed successfully", body = ContextManageResponse), - (status = 401, description = "Unauthorized - Invalid or missing API key"), - (status = 412, description = "Precondition failed - Agent not available"), - (status = 500, description = "Internal server error") - ), - security( - ("api_key" = []) - ), - tag = "Context Management" -)] -async fn manage_context( - State(state): State>, - Json(request): Json, -) -> Result, StatusCode> { - let agent = state.get_agent_for_route(request.session_id).await?; - - let conversation = Conversation::new_unvalidated(request.messages); - - let (processed_messages, token_counts, _) = - goose::context_mgmt::compact_messages(&agent, &conversation, false) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // TODO(Douwe): store into db - - Ok(Json(ContextManageResponse { - messages: processed_messages.messages().to_vec(), - token_counts, - })) -} - -// Configure routes for this module -pub fn routes(state: Arc) -> Router { - Router::new() - .route("/context/manage", post(manage_context)) - .with_state(state) -} diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index 1d34dc29b610..2db5241c3473 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -1,7 +1,6 @@ pub mod agent; pub mod audio; pub mod config_management; -pub mod context; pub mod errors; pub mod extension; pub mod health; @@ -23,7 +22,6 @@ pub fn configure(state: Arc) -> Router { .merge(reply::routes(state.clone())) .merge(agent::routes(state.clone())) .merge(audio::routes(state.clone())) - .merge(context::routes(state.clone())) .merge(extension::routes(state.clone())) .merge(config_management::routes(state.clone())) .merge(recipe::routes(state.clone())) From cb1f51c49aa9238f10548686d454430906459002 Mon Sep 17 00:00:00 2001 From: David Katz Date: Tue, 21 Oct 2025 13:50:33 -0400 Subject: [PATCH 31/55] openapi fix --- ui/desktop/openapi.json | 90 --------------------------------- ui/desktop/src/api/sdk.gen.ts | 13 +---- ui/desktop/src/api/types.gen.ts | 59 --------------------- 3 files changed, 1 insertion(+), 161 deletions(-) diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 4b63346ecc78..7f4a698b282a 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -852,50 +852,6 @@ } } }, - "/context/manage": { - "post": { - "tags": [ - "Context Management" - ], - "operationId": "manage_context", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContextManageRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Context managed successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContextManageResponse" - } - } - } - }, - "401": { - "description": "Unauthorized - Invalid or missing API key" - }, - "412": { - "description": "Precondition failed - Agent not available" - }, - "500": { - "description": "Internal server error" - } - }, - "security": [ - { - "api_key": [] - } - ] - } - }, "/handle_openrouter": { "post": { "tags": [ @@ -2161,52 +2117,6 @@ } ] }, - "ContextManageRequest": { - "type": "object", - "description": "Request payload for context management operations", - "required": [ - "messages", - "sessionId" - ], - "properties": { - "messages": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Message" - }, - "description": "Collection of messages to be managed" - }, - "sessionId": { - "type": "string", - "description": "Optional session ID for session-specific agent" - } - } - }, - "ContextManageResponse": { - "type": "object", - "description": "Response from context management operations", - "required": [ - "messages", - "tokenCounts" - ], - "properties": { - "messages": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Message" - }, - "description": "Processed messages after the operation" - }, - "tokenCounts": { - "type": "array", - "items": { - "type": "integer", - "minimum": 0 - }, - "description": "Token counts for each processed message" - } - } - }, "Conversation": { "type": "array", "items": { diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index c965aabaf443..3e5090f8b387 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, ManageContextData, ManageContextErrors, ManageContextResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionDescriptionData, UpdateSessionDescriptionErrors, UpdateSessionDescriptionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; +import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionDescriptionData, UpdateSessionDescriptionErrors, UpdateSessionDescriptionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -245,17 +245,6 @@ export const confirmPermission = (options: }); }; -export const manageContext = (options: Options) => { - return (options.client ?? client).post({ - url: '/context/manage', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } - }); -}; - export const startOpenrouterSetup = (options?: Options) => { return (options?.client ?? client).post({ url: '/handle_openrouter', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index eebff20e2700..b8670683404e 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -67,34 +67,6 @@ export type ConfigResponse = { export type Content = RawTextContent | RawImageContent | RawEmbeddedResource | RawAudioContent | RawResource; -/** - * Request payload for context management operations - */ -export type ContextManageRequest = { - /** - * Collection of messages to be managed - */ - messages: Array; - /** - * Optional session ID for session-specific agent - */ - sessionId: string; -}; - -/** - * Response from context management operations - */ -export type ContextManageResponse = { - /** - * Processed messages after the operation - */ - messages: Array; - /** - * Token counts for each processed message - */ - tokenCounts: Array; -}; - export type Conversation = Array; export type CreateRecipeRequest = { @@ -1571,37 +1543,6 @@ export type ConfirmPermissionResponses = { 200: unknown; }; -export type ManageContextData = { - body: ContextManageRequest; - path?: never; - query?: never; - url: '/context/manage'; -}; - -export type ManageContextErrors = { - /** - * Unauthorized - Invalid or missing API key - */ - 401: unknown; - /** - * Precondition failed - Agent not available - */ - 412: unknown; - /** - * Internal server error - */ - 500: unknown; -}; - -export type ManageContextResponses = { - /** - * Context managed successfully - */ - 200: ContextManageResponse; -}; - -export type ManageContextResponse = ManageContextResponses[keyof ManageContextResponses]; - export type StartOpenrouterSetupData = { body?: never; path?: never; From b94efe563bfbaa3d834920040735d34a41cb8bc9 Mon Sep 17 00:00:00 2001 From: David Katz Date: Tue, 21 Oct 2025 13:55:23 -0400 Subject: [PATCH 32/55] lowercase --- crates/goose/src/agents/agent.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index b6731f5df90e..2a0ed0dd9513 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -64,7 +64,7 @@ use crate::session::extension_data::{EnabledExtensionsState, ExtensionState}; use crate::session::SessionManager; const DEFAULT_MAX_TURNS: u32 = 1000; -const COMPACTION_THINKING_TEXT: &str = "Goose is compacting the conversation..."; +const COMPACTION_THINKING_TEXT: &str = "goose is compacting the conversation..."; const MANUAL_COMPACT_TRIGGER: &str = "Please compact this conversation"; /// Context needed for the reply function From a8ff2ec324fee3f37f234ff3dcf0c8d9a0cd2993 Mon Sep 17 00:00:00 2001 From: David Katz Date: Tue, 21 Oct 2025 23:05:21 -0400 Subject: [PATCH 33/55] fixes wave 1 --- crates/goose/src/agents/agent.rs | 2 +- crates/goose/src/conversation/message.rs | 26 ------------------------ 2 files changed, 1 insertion(+), 27 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 2a0ed0dd9513..f0f4eb08bd62 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1165,7 +1165,7 @@ impl Agent { yield AgentEvent::Message( Message::assistant().with_system_notification( SystemNotificationType::InlineMessage, - "Context limit reached. Attempting to compact and continue conversation...", + "Context limit reached. Compacting to continue conversation...", ) ); yield AgentEvent::Message( diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index 0ca70d0b7976..c6725f4de58a 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -1203,30 +1203,4 @@ mod tests { assert!(metadata.user_visible); assert!(metadata.agent_visible); } - - #[test] - fn test_system_notification_serialization() { - let message = Message::assistant() - .with_system_notification(SystemNotificationType::InlineMessage, "Test notification"); - - let json_str = serde_json::to_string_pretty(&message).unwrap(); - println!("Serialized SystemNotification: {}", json_str); - - let value: Value = serde_json::from_str(&json_str).unwrap(); - - // Check that the content has the right structure - assert_eq!(value["content"][0]["type"], "systemNotification"); - assert_eq!(value["content"][0]["notificationType"], "inlineMessage"); - assert_eq!(value["content"][0]["msg"], "Test notification"); - } - - #[test] - fn test_system_notification_sets_correct_metadata() { - let message = Message::assistant() - .with_system_notification(SystemNotificationType::InlineMessage, "Test notification"); - - // System notifications should be user_visible=true, agent_visible=false - assert!(message.is_user_visible()); - assert!(!message.is_agent_visible()); - } } From 0841d8c86d71f7b8b903adcf81cf6e1d43411653 Mon Sep 17 00:00:00 2001 From: David Katz Date: Tue, 21 Oct 2025 23:14:03 -0400 Subject: [PATCH 34/55] review pt 2 --- ui/desktop/src/components/BaseChat.tsx | 6 ++++-- ui/desktop/src/components/BaseChat2.tsx | 4 ++-- ui/desktop/src/types/message.ts | 14 ++++++++++++++ ui/desktop/src/utils/thinkingMessage.ts | 20 -------------------- 4 files changed, 20 insertions(+), 24 deletions(-) delete mode 100644 ui/desktop/src/utils/thinkingMessage.ts diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 284a28ce5fa0..f84ebdb84aa1 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -46,7 +46,7 @@ import { useLocation } from 'react-router-dom'; import { SearchView } from './conversation/SearchView'; import { AgentHeader } from './AgentHeader'; import LoadingGoose from './LoadingGoose'; -import { getThinkingMessage } from '../utils/thinkingMessage'; +import { getThinkingMessage } from '../types/message'; import RecipeActivities from './recipes/RecipeActivities'; import PopularChatTopics from './PopularChatTopics'; import ProgressiveMessageList from './ProgressiveMessageList'; @@ -430,7 +430,9 @@ function BaseChatContent({
diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index 93846a8310dd..e45b08d4dcdc 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { SearchView } from './conversation/SearchView'; import LoadingGoose from './LoadingGoose'; -import { getThinkingMessage } from '../utils/thinkingMessage'; +import { getThinkingMessage } from '../types/message'; import PopularChatTopics from './PopularChatTopics'; import ProgressiveMessageList from './ProgressiveMessageList'; import { View, ViewOptions } from '../utils/navigationUtils'; @@ -372,7 +372,7 @@ function BaseChatContent({ message={ messages.length === 0 && !sessionLoadError ? 'loading conversation...' - : getThinkingMessage(messages, chatState) + : getThinkingMessage(messages[messages.length - 1]) } chatState={chatState} /> diff --git a/ui/desktop/src/types/message.ts b/ui/desktop/src/types/message.ts index a598556e9096..483c6ba0d381 100644 --- a/ui/desktop/src/types/message.ts +++ b/ui/desktop/src/types/message.ts @@ -70,3 +70,17 @@ export function hasCompletedToolCalls(message: Message): boolean { const toolRequests = getToolRequests(message); return toolRequests.length > 0; } + +export function getThinkingMessage(message: Message | undefined): string | undefined { + if (!message || message.role !== 'assistant') { + return undefined; + } + + for (const content of message.content) { + if (content.type === 'systemNotification' && content.notificationType === 'thinkingMessage') { + return content.msg; + } + } + + return undefined; +} diff --git a/ui/desktop/src/utils/thinkingMessage.ts b/ui/desktop/src/utils/thinkingMessage.ts deleted file mode 100644 index 2d7bdc4b9f18..000000000000 --- a/ui/desktop/src/utils/thinkingMessage.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Message } from '../api'; -import { ChatState } from '../types/chatState'; -export function getThinkingMessage(messages: Message[], chatState: ChatState): string | undefined { - if (chatState === ChatState.Idle) { - return undefined; - } - - const lastMessage = messages[messages.length - 1]; - if (!lastMessage || lastMessage.role !== 'assistant') { - return undefined; - } - - for (const content of lastMessage.content) { - if (content.type === 'systemNotification' && content.notificationType === 'thinkingMessage') { - return content.msg; - } - } - - return undefined; -} From 85aeda08d68a17b11e46cb88bbe6682bf7429ece Mon Sep 17 00:00:00 2001 From: David Katz Date: Tue, 21 Oct 2025 23:18:15 -0400 Subject: [PATCH 35/55] Review pt 2 --- ui/desktop/src/hooks/useMessageStream.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/ui/desktop/src/hooks/useMessageStream.ts b/ui/desktop/src/hooks/useMessageStream.ts index 38a93ac24817..1f340065a332 100644 --- a/ui/desktop/src/hooks/useMessageStream.ts +++ b/ui/desktop/src/hooks/useMessageStream.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useId, useReducer, useRef, useState } from 'react'; import useSWR from 'swr'; -import { createUserMessage, hasCompletedToolCalls } from '../types/message'; +import { createUserMessage, getThinkingMessage, hasCompletedToolCalls } from '../types/message'; import { Conversation, Message, Role } from '../api'; import { getSession, Session } from '../api'; @@ -307,13 +307,7 @@ export function useMessageStream({ mutateChatState(ChatState.WaitingForUserInput); } - const hasThinkingMessage = newMessage.content.some( - (content) => - content.type === 'systemNotification' && - content.notificationType === 'thinkingMessage' - ); - - if (hasThinkingMessage) { + if (getThinkingMessage(newMessage)) { mutateChatState(ChatState.Thinking); } From e7c106fffe8b2bfdbf3d9408b60b9dadf9ad7a82 Mon Sep 17 00:00:00 2001 From: David Katz Date: Wed, 22 Oct 2025 13:55:51 -0400 Subject: [PATCH 36/55] fix clippy diff --- crates/goose/src/conversation/message.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index c6725f4de58a..c50157509c87 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -714,7 +714,7 @@ impl Message { #[cfg(test)] mod tests { use crate::conversation::message::{ - Message, MessageContent, MessageMetadata, SystemNotificationType, + Message, MessageContent, MessageMetadata, }; use crate::conversation::*; use rmcp::model::{ From f32364f257fb2ed89999457c49737ed23c34cf34 Mon Sep 17 00:00:00 2001 From: David Katz Date: Wed, 22 Oct 2025 15:37:51 -0400 Subject: [PATCH 37/55] fmt --- crates/goose/src/conversation/message.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index c50157509c87..f432d3292e75 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -713,9 +713,7 @@ impl Message { #[cfg(test)] mod tests { - use crate::conversation::message::{ - Message, MessageContent, MessageMetadata, - }; + use crate::conversation::message::{Message, MessageContent, MessageMetadata}; use crate::conversation::*; use rmcp::model::{ AnnotateAble, CallToolRequestParam, PromptMessage, PromptMessageContent, PromptMessageRole, From f9c6acbecd81e1bdc1ae590f558b9d62df30aba2 Mon Sep 17 00:00:00 2001 From: David Katz Date: Wed, 22 Oct 2025 16:44:07 -0400 Subject: [PATCH 38/55] first pass --- crates/goose-cli/src/commands/acp.rs | 2 +- crates/goose-cli/src/commands/web.rs | 2 +- crates/goose-cli/src/session/mod.rs | 2 +- crates/goose-server/src/routes/reply.rs | 27 +++++++- crates/goose/examples/agent.rs | 2 +- crates/goose/src/agents/agent.rs | 73 +++++++++++++-------- crates/goose/src/agents/subagent_handler.rs | 2 +- crates/goose/src/scheduler.rs | 2 +- crates/goose/src/session/session_manager.rs | 12 ++++ crates/goose/tests/agent.rs | 12 ++-- 10 files changed, 96 insertions(+), 40 deletions(-) diff --git a/crates/goose-cli/src/commands/acp.rs b/crates/goose-cli/src/commands/acp.rs index 8ee9acfe09d4..78de0faeaabe 100644 --- a/crates/goose-cli/src/commands/acp.rs +++ b/crates/goose-cli/src/commands/acp.rs @@ -588,7 +588,7 @@ impl acp::Agent for GooseAcpAgent { } match event { - Ok(goose::agents::AgentEvent::Message(message)) => { + Ok(goose::agents::AgentEvent::Message(message, _usage)) => { // Re-acquire the lock to add message to conversation let mut sessions = self.sessions.lock().await; let session = sessions diff --git a/crates/goose-cli/src/commands/web.rs b/crates/goose-cli/src/commands/web.rs index 7ab0be7516f0..576a156bd4ef 100644 --- a/crates/goose-cli/src/commands/web.rs +++ b/crates/goose-cli/src/commands/web.rs @@ -485,7 +485,7 @@ async fn process_message_streaming( Ok(mut stream) => { while let Some(result) = stream.next().await { match result { - Ok(AgentEvent::Message(message)) => { + Ok(AgentEvent::Message(message, _usage)) => { SessionManager::add_message(&session_id, &message).await?; for content in &message.content { diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 133b3aef4886..3d4878a2aa50 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -859,7 +859,7 @@ impl CliSession { tokio::select! { result = stream.next() => { match result { - Some(Ok(AgentEvent::Message(message))) => { + Some(Ok(AgentEvent::Message(message, _usage))) => { // If it's a confirmation request, get approval but otherwise do not render/persist if let Some(MessageContent::ToolConfirmationRequest(confirmation)) = message.content.first() { output::hide_thinking(); diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index f5af162570a0..62d9742eacae 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -126,6 +126,8 @@ impl IntoResponse for SseResponse { pub enum MessageEvent { Message { message: Message, + #[serde(skip_serializing_if = "Option::is_none")] + token_state: Option, }, Error { error: String, @@ -296,13 +298,34 @@ pub async fn reply( } response = timeout(Duration::from_millis(500), stream.next()) => { match response { - Ok(Some(Ok(AgentEvent::Message(message)))) => { + Ok(Some(Ok(AgentEvent::Message(message, usage)))) => { for content in &message.content { track_tool_telemetry(content, all_messages.messages()); } all_messages.push(message.clone()); - stream_event(MessageEvent::Message { message }, &tx, &cancel_token).await; + + // Create token state if usage is available + let token_state = if let Some(provider_usage) = usage { + match SessionManager::get_session(&session_id, false).await { + Ok(session) => Some(goose::session::session_manager::TokenState { + input_tokens: provider_usage.usage.input_tokens, + output_tokens: provider_usage.usage.output_tokens, + total_tokens: provider_usage.usage.total_tokens, + accumulated_input_tokens: session.accumulated_input_tokens, + accumulated_output_tokens: session.accumulated_output_tokens, + accumulated_total_tokens: session.accumulated_total_tokens, + }), + Err(e) => { + tracing::warn!("Failed to fetch session for token state: {}", e); + None + } + } + } else { + None + }; + + stream_event(MessageEvent::Message { message, token_state }, &tx, &cancel_token).await; } Ok(Some(Ok(AgentEvent::HistoryReplaced(new_messages)))) => { all_messages = new_messages.clone(); diff --git a/crates/goose/examples/agent.rs b/crates/goose/examples/agent.rs index d57226a7a797..b29d9b202249 100644 --- a/crates/goose/examples/agent.rs +++ b/crates/goose/examples/agent.rs @@ -37,7 +37,7 @@ async fn main() { .unwrap(); let mut stream = agent.reply(conversation, None, None).await.unwrap(); - while let Some(Ok(AgentEvent::Message(message))) = stream.next().await { + while let Some(Ok(AgentEvent::Message(message, _usage))) = stream.next().await { println!("{}", serde_json::to_string_pretty(&message).unwrap()); println!("\n"); } diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index f0f4eb08bd62..29881d83549a 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -36,7 +36,7 @@ use crate::mcp_utils::ToolResult; use crate::permission::permission_inspector::PermissionInspector; use crate::permission::permission_judge::PermissionCheckResult; use crate::permission::PermissionConfirmation; -use crate::providers::base::Provider; +use crate::providers::base::{Provider, ProviderUsage}; use crate::providers::errors::ProviderError; use crate::recipe::{Author, Recipe, Response, Settings, SubRecipe}; use crate::scheduler_trait::SchedulerTrait; @@ -108,7 +108,7 @@ pub struct Agent { #[derive(Clone, Debug)] pub enum AgentEvent { - Message(Message), + Message(Message, Option), McpNotification((String, ServerNotification)), ModelChange { model: String, mode: String }, HistoryReplaced(Conversation), @@ -800,7 +800,8 @@ impl Agent { Message::assistant().with_system_notification( SystemNotificationType::InlineMessage, inline_msg, - ) + ), + None ); } @@ -808,7 +809,8 @@ impl Agent { Message::assistant().with_system_notification( SystemNotificationType::ThinkingMessage, COMPACTION_THINKING_TEXT, - ) + ), + None ); match crate::context_mgmt::compact_messages(self, &conversation_to_compact, false).await { @@ -823,7 +825,8 @@ impl Agent { Message::assistant().with_system_notification( SystemNotificationType::InlineMessage, "Compaction complete", - ) + ), + None ); if !is_manual_compact { @@ -834,9 +837,12 @@ impl Agent { } } Err(e) => { - yield AgentEvent::Message(Message::assistant().with_text( - format!("Ran into this error trying to compact: {e}.\n\nPlease try again or create a new session") - )); + yield AgentEvent::Message( + Message::assistant().with_text( + format!("Ran into this error trying to compact: {e}.\n\nPlease try again or create a new session") + ), + None + ); } } })) @@ -929,6 +935,7 @@ impl Agent { if final_output_tool.final_output.is_some() { let final_event = AgentEvent::Message( Message::assistant().with_text(final_output_tool.final_output.clone().unwrap()), + None ); yield final_event; break; @@ -937,9 +944,12 @@ impl Agent { turns_taken += 1; if turns_taken > max_turns { - yield AgentEvent::Message(Message::assistant().with_text( - "I've reached the maximum number of actions I can do without user input. Would you like me to continue?" - )); + yield AgentEvent::Message( + Message::assistant().with_text( + "I've reached the maximum number of actions I can do without user input. Would you like me to continue?" + ), + None + ); break; } @@ -1016,7 +1026,7 @@ impl Agent { .record_tool_requests(&requests_to_record) .await; - yield AgentEvent::Message(filtered_response.clone()); + yield AgentEvent::Message(filtered_response.clone(), usage.clone()); tokio::task::yield_now().await; let num_tool_requests = frontend_requests.len() + remaining_requests.len(); @@ -1034,7 +1044,7 @@ impl Agent { ); while let Some(msg) = frontend_tool_stream.try_next().await? { - yield AgentEvent::Message(msg); + yield AgentEvent::Message(msg, None); } let mode = goose_mode.clone(); @@ -1103,7 +1113,7 @@ impl Agent { ); while let Some(msg) = tool_approval_stream.try_next().await? { - yield AgentEvent::Message(msg); + yield AgentEvent::Message(msg, None); } tool_futures = { @@ -1155,7 +1165,7 @@ impl Agent { } let final_message_tool_resp = message_tool_response.lock().await.clone(); - yield AgentEvent::Message(final_message_tool_resp.clone()); + yield AgentEvent::Message(final_message_tool_resp.clone(), None); no_tools_called = false; messages_to_add.push(final_message_tool_resp); @@ -1166,13 +1176,15 @@ impl Agent { Message::assistant().with_system_notification( SystemNotificationType::InlineMessage, "Context limit reached. Compacting to continue conversation...", - ) + ), + None ); yield AgentEvent::Message( Message::assistant().with_system_notification( SystemNotificationType::ThinkingMessage, COMPACTION_THINKING_TEXT, - ) + ), + None ); match crate::context_mgmt::compact_messages(self, &conversation, true).await { @@ -1189,18 +1201,24 @@ impl Agent { } Err(e) => { error!("Error: {}", e); - yield AgentEvent::Message(Message::assistant().with_text( + yield AgentEvent::Message( + Message::assistant().with_text( format!("Ran into this error trying to compact: {e}.\n\nPlease retry if you think this is a transient or recoverable error.") - )); + ), + None + ); break; } } } Err(e) => { error!("Error: {}", e); - yield AgentEvent::Message(Message::assistant().with_text( + yield AgentEvent::Message( + Message::assistant().with_text( format!("Ran into this error: {e}.\n\nPlease retry if you think this is a transient or recoverable error.") - )); + ), + None + ); break; } } @@ -1215,11 +1233,11 @@ impl Agent { warn!("Final output tool has not been called yet. Continuing agent loop."); let message = Message::user().with_text(FINAL_OUTPUT_CONTINUATION_MESSAGE); messages_to_add.push(message.clone()); - yield AgentEvent::Message(message); + yield AgentEvent::Message(message, None); } else { let message = Message::assistant().with_text(final_output_tool.final_output.clone().unwrap()); messages_to_add.push(message.clone()); - yield AgentEvent::Message(message); + yield AgentEvent::Message(message, None); exit_chat = true; } } else if did_recovery_compact_this_iteration { @@ -1235,9 +1253,12 @@ impl Agent { } Err(e) => { error!("Retry logic failed: {}", e); - yield AgentEvent::Message(Message::assistant().with_text( - format!("Retry logic encountered an error: {}", e) - )); + yield AgentEvent::Message( + Message::assistant().with_text( + format!("Retry logic encountered an error: {}", e) + ), + None + ); exit_chat = true; } } diff --git a/crates/goose/src/agents/subagent_handler.rs b/crates/goose/src/agents/subagent_handler.rs index ad14f282f1b7..d1e498fb1859 100644 --- a/crates/goose/src/agents/subagent_handler.rs +++ b/crates/goose/src/agents/subagent_handler.rs @@ -146,7 +146,7 @@ fn get_agent_messages( .map_err(|e| anyhow!("Failed to get reply from agent: {}", e))?; while let Some(message_result) = stream.next().await { match message_result { - Ok(AgentEvent::Message(msg)) => conversation.push(msg), + Ok(AgentEvent::Message(msg, _usage)) => conversation.push(msg), Ok(AgentEvent::McpNotification(_)) | Ok(AgentEvent::ModelChange { .. }) => {} Ok(AgentEvent::HistoryReplaced(updated_conversation)) => { conversation = updated_conversation; diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index a339731498fe..67614fbd5253 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -1223,7 +1223,7 @@ async fn run_scheduled_job_internal( tokio::task::yield_now().await; match message_result { - Ok(AgentEvent::Message(msg)) => { + Ok(AgentEvent::Message(msg, _usage)) => { if msg.role == rmcp::model::Role::Assistant { tracing::info!("[Job {}] Assistant: {:?}", job.id, msg.content); } diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index e6a2c8de9a63..c46dfe78029b 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -44,6 +44,18 @@ pub struct Session { pub message_count: usize, } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct TokenState { + /// Current turn token counts + pub input_tokens: Option, + pub output_tokens: Option, + pub total_tokens: Option, + /// Accumulated token counts across all turns + pub accumulated_input_tokens: Option, + pub accumulated_output_tokens: Option, + pub accumulated_total_tokens: Option, +} + pub struct SessionUpdateBuilder { session_id: String, description: Option, diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs index e97f0bfb53c8..b95767290897 100644 --- a/crates/goose/tests/agent.rs +++ b/crates/goose/tests/agent.rs @@ -137,7 +137,7 @@ async fn run_truncate_test( let mut responses = Vec::new(); while let Some(response_result) = reply_stream.next().await { match response_result { - Ok(AgentEvent::Message(response)) => responses.push(response), + Ok(AgentEvent::Message(response, _usage)) => responses.push(response), Ok(AgentEvent::McpNotification(n)) => { println!("MCP Notification: {n:?}"); } @@ -632,7 +632,7 @@ mod final_output_tool_tests { let mut responses = Vec::new(); while let Some(response_result) = reply_stream.next().await { match response_result { - Ok(AgentEvent::Message(response)) => responses.push(response), + Ok(AgentEvent::Message(response, _usage)) => responses.push(response), Ok(_) => {} Err(e) => return Err(e), } @@ -773,7 +773,7 @@ mod final_output_tool_tests { let mut count = 0; while let Some(response_result) = reply_stream.next().await { match response_result { - Ok(AgentEvent::Message(response)) => { + Ok(AgentEvent::Message(response, _usage)) => { responses.push(response); count += 1; if count >= 4 { @@ -809,7 +809,7 @@ mod final_output_tool_tests { // Continue streaming to consume any remaining content, this lets us verify the provider saw the continuation message while let Some(response_result) = reply_stream.next().await { match response_result { - Ok(AgentEvent::Message(_response)) => { + Ok(AgentEvent::Message(_response, _usage)) => { break; // Stop after receiving the next message } Ok(_) => {} @@ -930,7 +930,7 @@ mod retry_tests { let mut responses = Vec::new(); while let Some(response_result) = reply_stream.next().await { match response_result { - Ok(AgentEvent::Message(response)) => responses.push(response), + Ok(AgentEvent::Message(response, _usage)) => responses.push(response), Ok(_) => {} Err(e) => return Err(e), } @@ -1096,7 +1096,7 @@ mod max_turns_tests { let mut responses = Vec::new(); while let Some(response_result) = reply_stream.next().await { match response_result { - Ok(AgentEvent::Message(response)) => { + Ok(AgentEvent::Message(response, _usage)) => { if let Some(MessageContent::ToolConfirmationRequest(ref req)) = response.content.first() { From d31d64569412ec375691a952e2c5784e89c4cbe3 Mon Sep 17 00:00:00 2001 From: David Katz Date: Thu, 23 Oct 2025 15:53:30 -0400 Subject: [PATCH 39/55] Working e2e --- crates/goose-server/src/routes/reply.rs | 70 +++++++++++++++++++----- crates/goose/src/agents/reply_parts.rs | 18 ++++++ ui/desktop/src/components/BaseChat.tsx | 11 +++- ui/desktop/src/components/BaseChat2.tsx | 53 +++++++++++++++--- ui/desktop/src/components/ChatInput.tsx | 7 +++ ui/desktop/src/hooks/useChatEngine.ts | 2 + ui/desktop/src/hooks/useChatStream.ts | 65 +++++++++++++++++++++- ui/desktop/src/hooks/useMessageStream.ts | 25 ++++++++- 8 files changed, 223 insertions(+), 28 deletions(-) diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index 62d9742eacae..f14a631d9b6d 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -154,12 +154,24 @@ async fn stream_event( tx: &mpsc::Sender, cancel_token: &CancellationToken, ) { + // Log token_state before serialization + if let MessageEvent::Message { ref token_state, .. } = event { + tracing::info!("[STREAM_EVENT] About to send Message with token_state: {:?}", token_state.is_some()); + if let Some(ref ts) = token_state { + tracing::info!("[STREAM_EVENT] token_state values: accumulated_total={:?}", ts.accumulated_total_tokens); + } + } + let json = serde_json::to_string(&event).unwrap_or_else(|e| { format!( r#"{{"type":"Error","error":"Failed to serialize event: {}"}}"#, e ) }); + + // Log first 500 chars of what we're actually sending + tracing::debug!("[STREAM_EVENT] Sending SSE: {}", &json.chars().take(500).collect::()); + if tx.send(format!("data: {}\n\n", json)).await.is_err() { tracing::info!("client hung up"); cancel_token.cancel(); @@ -305,24 +317,54 @@ pub async fn reply( all_messages.push(message.clone()); - // Create token state if usage is available - let token_state = if let Some(provider_usage) = usage { - match SessionManager::get_session(&session_id, false).await { - Ok(session) => Some(goose::session::session_manager::TokenState { - input_tokens: provider_usage.usage.input_tokens, - output_tokens: provider_usage.usage.output_tokens, - total_tokens: provider_usage.usage.total_tokens, + // Always fetch session and create token state so client has latest accumulated tokens + let token_state = match SessionManager::get_session(&session_id, false).await { + Ok(session) => { + // If we have new usage from this turn, use it; otherwise use current session values + let (input, output, total) = if let Some(ref provider_usage) = usage { + tracing::debug!( + "[TOKEN STATE] New usage from provider: input={:?}, output={:?}, total={:?}", + provider_usage.usage.input_tokens, + provider_usage.usage.output_tokens, + provider_usage.usage.total_tokens + ); + ( + provider_usage.usage.input_tokens, + provider_usage.usage.output_tokens, + provider_usage.usage.total_tokens, + ) + } else { + tracing::debug!( + "[TOKEN STATE] No new usage, using session values: input={:?}, output={:?}, total={:?}", + session.input_tokens, + session.output_tokens, + session.total_tokens + ); + (session.input_tokens, session.output_tokens, session.total_tokens) + }; + + let state = goose::session::session_manager::TokenState { + input_tokens: input, + output_tokens: output, + total_tokens: total, accumulated_input_tokens: session.accumulated_input_tokens, accumulated_output_tokens: session.accumulated_output_tokens, accumulated_total_tokens: session.accumulated_total_tokens, - }), - Err(e) => { - tracing::warn!("Failed to fetch session for token state: {}", e); - None - } + }; + + tracing::debug!( + "[TOKEN STATE] Sending to client: accumulated_total={:?}, accumulated_input={:?}, accumulated_output={:?}", + state.accumulated_total_tokens, + state.accumulated_input_tokens, + state.accumulated_output_tokens + ); + + Some(state) + }, + Err(e) => { + tracing::warn!("Failed to fetch session for token state: {}", e); + None } - } else { - None }; stream_event(MessageEvent::Message { message, token_state }, &tx, &cancel_token).await; diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index bc443d83746a..64213f6784f6 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -257,6 +257,15 @@ impl Agent { usage: &ProviderUsage, ) -> Result<()> { let session_id = session_config.id.as_str(); + + tracing::info!( + "[UPDATE_SESSION_METRICS] Starting update for session {}: current turn tokens - input={:?}, output={:?}, total={:?}", + &session_id[..8], + usage.usage.input_tokens, + usage.usage.output_tokens, + usage.usage.total_tokens + ); + let session = SessionManager::get_session(session_id, false).await?; let accumulate = |a: Option, b: Option| -> Option { @@ -273,6 +282,13 @@ impl Agent { let accumulated_output = accumulate(session.accumulated_output_tokens, usage.usage.output_tokens); + tracing::info!( + "[UPDATE_SESSION_METRICS] Calculated accumulated tokens - input={:?}, output={:?}, total={:?}", + accumulated_input, + accumulated_output, + accumulated_total + ); + SessionManager::update_session(session_id) .schedule_id(session_config.schedule_id.clone()) .total_tokens(usage.usage.total_tokens) @@ -284,6 +300,8 @@ impl Agent { .apply() .await?; + tracing::info!("[UPDATE_SESSION_METRICS] Successfully wrote to database for session {}", &session_id[..8]); + Ok(()) } } diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index c5a96bcdcb3b..453eca7c618d 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -132,6 +132,7 @@ function BaseChatContent({ sessionOutputTokens, localInputTokens, localOutputTokens, + tokenState, commandHistory, toolCallNotifications, sessionMetadata, @@ -442,9 +443,13 @@ function BaseChatContent({ commandHistory={commandHistory} initialValue={input || ''} setView={setView} - numTokens={sessionTokenCount} - inputTokens={sessionInputTokens || localInputTokens} - outputTokens={sessionOutputTokens || localOutputTokens} + numTokens={tokenState?.accumulated_total_tokens ?? sessionTokenCount} + inputTokens={ + tokenState?.accumulated_input_tokens ?? sessionInputTokens ?? localInputTokens + } + outputTokens={ + tokenState?.accumulated_output_tokens ?? sessionOutputTokens ?? localOutputTokens + } droppedFiles={droppedFiles} onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing messages={messages} diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index d578e18c115c..962b6f878c02 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -63,12 +63,25 @@ function BaseChatContent({ const onStreamFinish = useCallback(() => {}, []); - const { session, messages, chatState, handleSubmit, stopStreaming, sessionLoadError } = - useChatStream({ - sessionId, - onStreamFinish, - initialMessage, - }); + const { + session, + messages, + chatState, + handleSubmit, + stopStreaming, + sessionLoadError, + tokenState, + } = useChatStream({ + sessionId, + onStreamFinish, + initialMessage, + }); + + console.log('[BaseChat2] Received from useChatStream:', { + tokenState, + session_accumulated: session?.accumulated_total_tokens, + timestamp: new Date().toISOString(), + }); const handleFormSubmit = (e: React.FormEvent) => { const customEvent = e as unknown as CustomEvent; @@ -274,9 +287,31 @@ function BaseChatContent({ //commandHistory={commandHistory} initialValue={initialPrompt} setView={setView} - numTokens={session?.total_tokens || undefined} - inputTokens={session?.input_tokens || undefined} - outputTokens={session?.output_tokens || undefined} + numTokens={(() => { + const val = + tokenState?.accumulated_total_tokens ?? + session?.accumulated_total_tokens ?? + session?.total_tokens ?? + undefined; + console.log('[BaseChat2] Passing numTokens to ChatInput:', val); + return val; + })()} + inputTokens={(() => { + const val = + tokenState?.accumulated_input_tokens ?? + session?.accumulated_input_tokens ?? + undefined; + console.log('[BaseChat2] Passing inputTokens to ChatInput:', val); + return val; + })()} + outputTokens={(() => { + const val = + tokenState?.accumulated_output_tokens ?? + session?.accumulated_output_tokens ?? + undefined; + console.log('[BaseChat2] Passing outputTokens to ChatInput:', val); + return val; + })()} droppedFiles={droppedFiles} onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing messages={messages} diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 62c446b62645..9a3e1e82406a 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -118,6 +118,13 @@ export default function ChatInput({ append: _append, isExtensionsLoading = false, }: ChatInputProps) { + console.log('[ChatInput] RENDER with token props:', { + numTokens, + inputTokens, + outputTokens, + timestamp: new Date().toISOString(), + }); + const [_value, setValue] = useState(initialValue); const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback const [isFocused, setIsFocused] = useState(false); diff --git a/ui/desktop/src/hooks/useChatEngine.ts b/ui/desktop/src/hooks/useChatEngine.ts index 1c0f6b7e47a4..7c60038e6316 100644 --- a/ui/desktop/src/hooks/useChatEngine.ts +++ b/ui/desktop/src/hooks/useChatEngine.ts @@ -77,6 +77,7 @@ export const useChatEngine = ({ notifications, session, setError, + tokenState, } = useMessageStream({ api: getApiUrl('/reply'), id: chat.sessionId, @@ -451,6 +452,7 @@ export const useChatEngine = ({ sessionOutputTokens, localInputTokens, localOutputTokens, + tokenState, // UI helpers commandHistory, diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index 9fa88c750497..9b39d18b66cf 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -40,6 +40,15 @@ const log = { type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; +interface TokenState { + input_tokens?: number | null; + output_tokens?: number | null; + total_tokens?: number | null; + accumulated_input_tokens?: number | null; + accumulated_output_tokens?: number | null; + accumulated_total_tokens?: number | null; +} + interface NotificationEvent { type: 'Notification'; request_id: string; @@ -52,7 +61,7 @@ interface NotificationEvent { } type MessageEvent = - | { type: 'Message'; message: Message } + | { type: 'Message'; message: Message; token_state?: TokenState | null } | { type: 'Error'; error: string } | { type: 'Ping' } | { type: 'Finish'; reason: string } @@ -73,6 +82,7 @@ interface UseChatStreamReturn { handleSubmit: (userMessage: string) => Promise; stopStreaming: () => void; sessionLoadError?: string; + tokenState?: TokenState; } function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[] { @@ -101,6 +111,7 @@ async function streamFromResponse( response: Response, initialMessages: Message[], updateMessages: (messages: Message[]) => void, + updateTokenState: (tokenState?: TokenState) => void, onFinish: (error?: string) => void ): Promise { let chunkCount = 0; @@ -136,20 +147,59 @@ async function streamFromResponse( const data = line.slice(6); if (data === '[DONE]') continue; + // Log EVERY raw SSE line to see what's actually coming + console.log('[RAW SSE LINE]:', data.substring(0, 500)); // First 500 chars + try { const event = JSON.parse(data) as MessageEvent; + // Log parsed event type + console.log('[PARSED EVENT TYPE]:', event.type); + + // Log RAW token_state from server to see if it's arriving + if (event.type === 'Message') { + console.log( + '[MESSAGE EVENT] has token_state?', + 'token_state' in event, + event.token_state + ); + if (event.token_state) { + console.log( + '[RAW SSE DATA] token_state from server:', + JSON.stringify(event.token_state) + ); + } + } + switch (event.type) { case 'Message': { messageEventCount++; const msg = event.message; currentMessages = pushMessage(currentMessages, msg); + // Update token state if present + if (event.token_state) { + console.log('[TOKEN STATE UPDATE] Received from server:', { + accumulated_total: event.token_state.accumulated_total_tokens, + accumulated_input: event.token_state.accumulated_input_tokens, + accumulated_output: event.token_state.accumulated_output_tokens, + current_total: event.token_state.total_tokens, + messageRole: msg.role, + timestamp: new Date().toISOString(), + }); + console.log('[TOKEN STATE UPDATE] Calling updateTokenState...'); + updateTokenState(event.token_state); + console.log('[TOKEN STATE UPDATE] updateTokenState called'); + } else { + console.log('[TOKEN STATE MISSING]', { messageRole: msg.role }); + } + // Only log every 10th message event to avoid spam if (messageEventCount % 10 === 0) { log.stream('message-chunk', { eventCount: messageEventCount, messageCount: currentMessages.length, + tokenState: event.token_state, }); } @@ -218,8 +268,16 @@ export function useChatStream({ const [session, setSession] = useState(); const [sessionLoadError, setSessionLoadError] = useState(); const [chatState, setChatState] = useState(ChatState.Idle); + const [tokenState, setTokenState] = useState(); const abortControllerRef = useRef(null); + console.log('[useChatStream] RENDER with tokenState:', tokenState); + + // Monitor tokenState changes + useEffect(() => { + console.log('[useChatStream useEffect] tokenState CHANGED to:', tokenState); + }, [tokenState]); + useEffect(() => { if (session) { resultsCache.set(sessionId, { session, messages }); @@ -342,6 +400,10 @@ export function useChatStream({ response, currentMessages, (messages: Message[]) => setMessagesAndLog(messages, 'streaming'), + (newTokenState?: TokenState) => { + console.log('[useChatStream] setTokenState CALLED with:', newTokenState); + setTokenState(newTokenState); + }, onFinish ); @@ -387,5 +449,6 @@ export function useChatStream({ chatState, handleSubmit, stopStreaming, + tokenState, }; } diff --git a/ui/desktop/src/hooks/useMessageStream.ts b/ui/desktop/src/hooks/useMessageStream.ts index 44ffa2527018..c64643f136f2 100644 --- a/ui/desktop/src/hooks/useMessageStream.ts +++ b/ui/desktop/src/hooks/useMessageStream.ts @@ -28,9 +28,18 @@ export interface NotificationEvent { }; } +interface TokenState { + input_tokens?: number | null; + output_tokens?: number | null; + total_tokens?: number | null; + accumulated_input_tokens?: number | null; + accumulated_output_tokens?: number | null; + accumulated_total_tokens?: number | null; +} + // Event types for SSE stream type MessageEvent = - | { type: 'Message'; message: Message } + | { type: 'Message'; message: Message; token_state?: TokenState | null } | { type: 'Error'; error: string } | { type: 'Finish'; reason: string } | { type: 'ModelChange'; model: string; mode: string } @@ -160,6 +169,9 @@ export interface UseMessageStreamHelpers { /** Clear error state */ setError: (error: Error | undefined) => void; + + /** Real-time token state from server */ + tokenState?: TokenState; } /** @@ -192,6 +204,7 @@ export function useMessageStream({ null ); const [session, setSession] = useState(null); + const [tokenState, setTokenState] = useState(); // expose a way to update the body so we can update the session id when CLE occurs const updateMessageStreamBody = useCallback((newBody: object) => { @@ -275,6 +288,15 @@ export function useMessageStream({ // Transition from waiting to streaming on first message mutateChatState(ChatState.Streaming); + // Update token state if present + if (parsedEvent.token_state) { + console.log( + '[useMessageStream] Received token_state:', + parsedEvent.token_state + ); + setTokenState(parsedEvent.token_state); + } + // Create a new message object with the properties preserved or defaulted const newMessage: Message = { ...parsedEvent.message, @@ -643,5 +665,6 @@ export function useMessageStream({ currentModelInfo, session, setError, + tokenState, }; } From 1fd21abedbee0c0bbc9b766070823c3c45411f93 Mon Sep 17 00:00:00 2001 From: David Katz Date: Thu, 23 Oct 2025 16:01:41 -0400 Subject: [PATCH 40/55] Cleanup --- crates/goose-server/src/routes/reply.rs | 36 +---- crates/goose/src/agents/reply_parts.rs | 18 --- ui/desktop/src/components/BaseChat2.tsx | 45 ++---- ui/desktop/src/hooks/useChatStream.ts | 168 ++--------------------- ui/desktop/src/hooks/useMessageStream.ts | 4 - 5 files changed, 28 insertions(+), 243 deletions(-) diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index f14a631d9b6d..17f8e4aa9093 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -154,14 +154,6 @@ async fn stream_event( tx: &mpsc::Sender, cancel_token: &CancellationToken, ) { - // Log token_state before serialization - if let MessageEvent::Message { ref token_state, .. } = event { - tracing::info!("[STREAM_EVENT] About to send Message with token_state: {:?}", token_state.is_some()); - if let Some(ref ts) = token_state { - tracing::info!("[STREAM_EVENT] token_state values: accumulated_total={:?}", ts.accumulated_total_tokens); - } - } - let json = serde_json::to_string(&event).unwrap_or_else(|e| { format!( r#"{{"type":"Error","error":"Failed to serialize event: {}"}}"#, @@ -169,9 +161,6 @@ async fn stream_event( ) }); - // Log first 500 chars of what we're actually sending - tracing::debug!("[STREAM_EVENT] Sending SSE: {}", &json.chars().take(500).collect::()); - if tx.send(format!("data: {}\n\n", json)).await.is_err() { tracing::info!("client hung up"); cancel_token.cancel(); @@ -322,44 +311,23 @@ pub async fn reply( Ok(session) => { // If we have new usage from this turn, use it; otherwise use current session values let (input, output, total) = if let Some(ref provider_usage) = usage { - tracing::debug!( - "[TOKEN STATE] New usage from provider: input={:?}, output={:?}, total={:?}", - provider_usage.usage.input_tokens, - provider_usage.usage.output_tokens, - provider_usage.usage.total_tokens - ); ( provider_usage.usage.input_tokens, provider_usage.usage.output_tokens, provider_usage.usage.total_tokens, ) } else { - tracing::debug!( - "[TOKEN STATE] No new usage, using session values: input={:?}, output={:?}, total={:?}", - session.input_tokens, - session.output_tokens, - session.total_tokens - ); (session.input_tokens, session.output_tokens, session.total_tokens) }; - let state = goose::session::session_manager::TokenState { + Some(goose::session::session_manager::TokenState { input_tokens: input, output_tokens: output, total_tokens: total, accumulated_input_tokens: session.accumulated_input_tokens, accumulated_output_tokens: session.accumulated_output_tokens, accumulated_total_tokens: session.accumulated_total_tokens, - }; - - tracing::debug!( - "[TOKEN STATE] Sending to client: accumulated_total={:?}, accumulated_input={:?}, accumulated_output={:?}", - state.accumulated_total_tokens, - state.accumulated_input_tokens, - state.accumulated_output_tokens - ); - - Some(state) + }) }, Err(e) => { tracing::warn!("Failed to fetch session for token state: {}", e); diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index 64213f6784f6..bc443d83746a 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -257,15 +257,6 @@ impl Agent { usage: &ProviderUsage, ) -> Result<()> { let session_id = session_config.id.as_str(); - - tracing::info!( - "[UPDATE_SESSION_METRICS] Starting update for session {}: current turn tokens - input={:?}, output={:?}, total={:?}", - &session_id[..8], - usage.usage.input_tokens, - usage.usage.output_tokens, - usage.usage.total_tokens - ); - let session = SessionManager::get_session(session_id, false).await?; let accumulate = |a: Option, b: Option| -> Option { @@ -282,13 +273,6 @@ impl Agent { let accumulated_output = accumulate(session.accumulated_output_tokens, usage.usage.output_tokens); - tracing::info!( - "[UPDATE_SESSION_METRICS] Calculated accumulated tokens - input={:?}, output={:?}, total={:?}", - accumulated_input, - accumulated_output, - accumulated_total - ); - SessionManager::update_session(session_id) .schedule_id(session_config.schedule_id.clone()) .total_tokens(usage.usage.total_tokens) @@ -300,8 +284,6 @@ impl Agent { .apply() .await?; - tracing::info!("[UPDATE_SESSION_METRICS] Successfully wrote to database for session {}", &session_id[..8]); - Ok(()) } } diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index 962b6f878c02..57ed11fb51c0 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -77,12 +77,6 @@ function BaseChatContent({ initialMessage, }); - console.log('[BaseChat2] Received from useChatStream:', { - tokenState, - session_accumulated: session?.accumulated_total_tokens, - timestamp: new Date().toISOString(), - }); - const handleFormSubmit = (e: React.FormEvent) => { const customEvent = e as unknown as CustomEvent; const textValue = customEvent.detail?.value || ''; @@ -287,31 +281,20 @@ function BaseChatContent({ //commandHistory={commandHistory} initialValue={initialPrompt} setView={setView} - numTokens={(() => { - const val = - tokenState?.accumulated_total_tokens ?? - session?.accumulated_total_tokens ?? - session?.total_tokens ?? - undefined; - console.log('[BaseChat2] Passing numTokens to ChatInput:', val); - return val; - })()} - inputTokens={(() => { - const val = - tokenState?.accumulated_input_tokens ?? - session?.accumulated_input_tokens ?? - undefined; - console.log('[BaseChat2] Passing inputTokens to ChatInput:', val); - return val; - })()} - outputTokens={(() => { - const val = - tokenState?.accumulated_output_tokens ?? - session?.accumulated_output_tokens ?? - undefined; - console.log('[BaseChat2] Passing outputTokens to ChatInput:', val); - return val; - })()} + numTokens={ + tokenState?.accumulated_total_tokens ?? + session?.accumulated_total_tokens ?? + session?.total_tokens ?? + undefined + } + inputTokens={ + tokenState?.accumulated_input_tokens ?? session?.accumulated_input_tokens ?? undefined + } + outputTokens={ + tokenState?.accumulated_output_tokens ?? + session?.accumulated_output_tokens ?? + undefined + } droppedFiles={droppedFiles} onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing messages={messages} diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index 9b39d18b66cf..7807fffd603e 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -7,37 +7,6 @@ import { createUserMessage } from '../types/message'; const TextDecoder = globalThis.TextDecoder; const resultsCache = new Map(); -// Debug logging - set to false in production -const DEBUG_CHAT_STREAM = true; - -const log = { - session: (action: string, sessionId: string, details?: Record) => { - if (!DEBUG_CHAT_STREAM) return; - console.log(`[useChatStream:session] ${action}`, { - sessionId: sessionId.slice(0, 8), - ...details, - }); - }, - messages: (action: string, count: number, details?: Record) => { - if (!DEBUG_CHAT_STREAM) return; - console.log(`[useChatStream:messages] ${action}`, { - count, - ...details, - }); - }, - stream: (action: string, details?: Record) => { - if (!DEBUG_CHAT_STREAM) return; - console.log(`[useChatStream:stream] ${action}`, details); - }, - state: (newState: ChatState, details?: Record) => { - if (!DEBUG_CHAT_STREAM) return; - console.log(`[useChatStream:state] → ${newState}`, details); - }, - error: (context: string, error: unknown) => { - console.error(`[useChatStream:error] ${context}`, error); - }, -}; - type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; interface TokenState { @@ -114,9 +83,6 @@ async function streamFromResponse( updateTokenState: (tokenState?: TokenState) => void, onFinish: (error?: string) => void ): Promise { - let chunkCount = 0; - let messageEventCount = 0; - try { if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.body) throw new Error('No response body'); @@ -125,19 +91,12 @@ async function streamFromResponse( const decoder = new TextDecoder(); let currentMessages = initialMessages; - log.stream('reading-chunks'); - while (true) { const { done, value } = await reader.read(); if (done) { - log.stream('chunks-complete', { - totalChunks: chunkCount, - messageEvents: messageEventCount, - }); break; } - chunkCount++; const chunk = decoder.decode(value); const lines = chunk.split('\n'); @@ -147,96 +106,43 @@ async function streamFromResponse( const data = line.slice(6); if (data === '[DONE]') continue; - // Log EVERY raw SSE line to see what's actually coming - console.log('[RAW SSE LINE]:', data.substring(0, 500)); // First 500 chars - try { const event = JSON.parse(data) as MessageEvent; - // Log parsed event type - console.log('[PARSED EVENT TYPE]:', event.type); - - // Log RAW token_state from server to see if it's arriving - if (event.type === 'Message') { - console.log( - '[MESSAGE EVENT] has token_state?', - 'token_state' in event, - event.token_state - ); - if (event.token_state) { - console.log( - '[RAW SSE DATA] token_state from server:', - JSON.stringify(event.token_state) - ); - } - } - switch (event.type) { case 'Message': { - messageEventCount++; const msg = event.message; currentMessages = pushMessage(currentMessages, msg); // Update token state if present if (event.token_state) { - console.log('[TOKEN STATE UPDATE] Received from server:', { - accumulated_total: event.token_state.accumulated_total_tokens, - accumulated_input: event.token_state.accumulated_input_tokens, - accumulated_output: event.token_state.accumulated_output_tokens, - current_total: event.token_state.total_tokens, - messageRole: msg.role, - timestamp: new Date().toISOString(), - }); - console.log('[TOKEN STATE UPDATE] Calling updateTokenState...'); updateTokenState(event.token_state); - console.log('[TOKEN STATE UPDATE] updateTokenState called'); - } else { - console.log('[TOKEN STATE MISSING]', { messageRole: msg.role }); - } - - // Only log every 10th message event to avoid spam - if (messageEventCount % 10 === 0) { - log.stream('message-chunk', { - eventCount: messageEventCount, - messageCount: currentMessages.length, - tokenState: event.token_state, - }); } - // This calls the wrapped setMessagesAndLog with 'streaming' context updateMessages(currentMessages); break; } case 'Error': { - log.error('stream event error', event.error); + console.error('Stream event error:', event.error); onFinish('Stream error: ' + event.error); return; } case 'Finish': { - log.stream('finish-event', { reason: event.reason }); onFinish(); return; } case 'ModelChange': { - log.stream('model-change', { - model: event.model, - mode: event.mode, - }); break; } case 'UpdateConversation': { - log.messages('conversation-update', event.conversation.length); currentMessages = event.conversation; - // This calls the wrapped setMessagesAndLog with 'streaming' context updateMessages(event.conversation); break; } case 'Notification': { - // Don't log notifications, too noisy break; } case 'Ping': { - // Don't log pings break; } default: { @@ -245,14 +151,14 @@ async function streamFromResponse( } } } catch (e) { - log.error('SSE parse failed', e); + console.error('SSE parse failed:', e); onFinish('Failed to parse SSE:' + e); } } } } catch (error) { if (error instanceof Error && error.name !== 'AbortError') { - log.error('stream read error', error); + console.error('Stream read error:', error); onFinish('Stream error:' + error); } } @@ -271,28 +177,13 @@ export function useChatStream({ const [tokenState, setTokenState] = useState(); const abortControllerRef = useRef(null); - console.log('[useChatStream] RENDER with tokenState:', tokenState); - - // Monitor tokenState changes - useEffect(() => { - console.log('[useChatStream useEffect] tokenState CHANGED to:', tokenState); - }, [tokenState]); - useEffect(() => { if (session) { resultsCache.set(sessionId, { session, messages }); } }, [sessionId, session, messages]); - const renderCountRef = useRef(0); - renderCountRef.current += 1; - console.log(`useChatStream render #${renderCountRef.current}, ${session?.id}`); - - const setMessagesAndLog = useCallback((newMessages: Message[], logContext: string) => { - log.messages(logContext, newMessages.length, { - lastMessageRole: newMessages[newMessages.length - 1]?.role, - lastMessageId: newMessages[newMessages.length - 1]?.id?.slice(0, 8), - }); + const setMessagesAndLog = useCallback((newMessages: Message[]) => { setMessages(newMessages); messagesRef.current = newMessages; }, []); @@ -313,16 +204,13 @@ export function useChatStream({ if (!sessionId) return; // Reset state when sessionId changes - log.session('loading', sessionId); - setMessagesAndLog([], 'session-reset'); + setMessagesAndLog([]); setSession(undefined); setSessionLoadError(undefined); setChatState(ChatState.Thinking); let cancelled = false; - log.state(ChatState.Thinking, { reason: 'session load start' }); - (async () => { try { const response = await resumeAgent({ @@ -335,23 +223,14 @@ export function useChatStream({ if (cancelled) return; const session = response.data; - log.session('loaded', sessionId, { - messageCount: session?.conversation?.length || 0, - description: session?.description, - }); - setSession(session); - setMessagesAndLog(session?.conversation || [], 'load-session'); - - log.state(ChatState.Idle, { reason: 'session load complete' }); + setMessagesAndLog(session?.conversation || []); setChatState(ChatState.Idle); } catch (error) { if (cancelled) return; - log.error('session load failed', error); + console.error('Session load failed:', error); setSessionLoadError(error instanceof Error ? error.message : String(error)); - - log.state(ChatState.Idle, { reason: 'session load error' }); setChatState(ChatState.Idle); } })(); @@ -363,21 +242,13 @@ export function useChatStream({ const handleSubmit = useCallback( async (userMessage: string) => { - log.messages('user-submit', messagesRef.current.length + 1, { - userMessageLength: userMessage.length, - }); - const currentMessages = [...messagesRef.current, createUserMessage(userMessage)]; - setMessagesAndLog(currentMessages, 'user-entered'); - - log.state(ChatState.Streaming, { reason: 'user submit' }); + setMessagesAndLog(currentMessages); setChatState(ChatState.Streaming); abortControllerRef.current = new AbortController(); try { - log.stream('request-start', { sessionId: sessionId.slice(0, 8) }); - const response = await fetch(getApiUrl('/reply'), { method: 'POST', headers: { @@ -391,30 +262,20 @@ export function useChatStream({ signal: abortControllerRef.current.signal, }); - log.stream('response-received', { - status: response.status, - ok: response.ok, - }); - await streamFromResponse( response, currentMessages, - (messages: Message[]) => setMessagesAndLog(messages, 'streaming'), - (newTokenState?: TokenState) => { - console.log('[useChatStream] setTokenState CALLED with:', newTokenState); - setTokenState(newTokenState); - }, + (messages: Message[]) => setMessagesAndLog(messages), + setTokenState, onFinish ); - - log.stream('stream-complete'); } catch (error) { // AbortError is expected when user stops streaming if (error instanceof Error && error.name === 'AbortError') { - log.stream('stream-aborted'); + // Stream was aborted by user } else { // Unexpected error during fetch setup (streamFromResponse handles its own errors) - log.error('submit failed', error); + console.error('Submit failed:', error); onFinish('Submit error: ' + (error instanceof Error ? error.message : String(error))); } } @@ -424,15 +285,12 @@ export function useChatStream({ useEffect(() => { if (initialMessage && session && messages.length === 0 && chatState === ChatState.Idle) { - log.messages('auto-submit-initial', 0, { initialMessage: initialMessage.slice(0, 50) }); handleSubmit(initialMessage); } }, [initialMessage, session, messages.length, chatState, handleSubmit]); const stopStreaming = useCallback(() => { - log.stream('stop-requested'); abortControllerRef.current?.abort(); - log.state(ChatState.Idle, { reason: 'user stopped streaming' }); setChatState(ChatState.Idle); }, []); @@ -440,8 +298,6 @@ export function useChatStream({ const maybe_cached_messages = session ? messages : cached?.messages || []; const maybe_cached_session = session ?? cached?.session; - console.log('>> returning', sessionId, Date.now(), maybe_cached_messages, chatState); - return { sessionLoadError, messages: maybe_cached_messages, diff --git a/ui/desktop/src/hooks/useMessageStream.ts b/ui/desktop/src/hooks/useMessageStream.ts index c64643f136f2..6eb81815513e 100644 --- a/ui/desktop/src/hooks/useMessageStream.ts +++ b/ui/desktop/src/hooks/useMessageStream.ts @@ -290,10 +290,6 @@ export function useMessageStream({ // Update token state if present if (parsedEvent.token_state) { - console.log( - '[useMessageStream] Received token_state:', - parsedEvent.token_state - ); setTokenState(parsedEvent.token_state); } From 8e3cfe5d477345609d55b2f733075acb3eeb6415 Mon Sep 17 00:00:00 2001 From: David Katz Date: Thu, 23 Oct 2025 16:57:21 -0400 Subject: [PATCH 41/55] restore logs --- ui/desktop/src/hooks/useChatStream.ts | 99 ++++++++++++++++++++++++--- 1 file changed, 88 insertions(+), 11 deletions(-) diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index 7807fffd603e..081c6c7f641f 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -7,6 +7,37 @@ import { createUserMessage } from '../types/message'; const TextDecoder = globalThis.TextDecoder; const resultsCache = new Map(); +// Debug logging - set to false in production +const DEBUG_CHAT_STREAM = true; + +const log = { + session: (action: string, sessionId: string, details?: Record) => { + if (!DEBUG_CHAT_STREAM) return; + console.log(`[useChatStream:session] ${action}`, { + sessionId: sessionId.slice(0, 8), + ...details, + }); + }, + messages: (action: string, count: number, details?: Record) => { + if (!DEBUG_CHAT_STREAM) return; + console.log(`[useChatStream:messages] ${action}`, { + count, + ...details, + }); + }, + stream: (action: string, details?: Record) => { + if (!DEBUG_CHAT_STREAM) return; + console.log(`[useChatStream:stream] ${action}`, details); + }, + state: (newState: ChatState, details?: Record) => { + if (!DEBUG_CHAT_STREAM) return; + console.log(`[useChatStream:state] → ${newState}`, details); + }, + error: (context: string, error: unknown) => { + console.error(`[useChatStream:error] ${context}`, error); + }, +}; + type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; interface TokenState { @@ -91,9 +122,13 @@ async function streamFromResponse( const decoder = new TextDecoder(); let currentMessages = initialMessages; + log.stream('reading-chunks'); while (true) { const { done, value } = await reader.read(); if (done) { + log.stream('chunks-complete', { + totalMessages: currentMessages.length, + }); break; } @@ -114,6 +149,11 @@ async function streamFromResponse( const msg = event.message; currentMessages = pushMessage(currentMessages, msg); + log.stream('message-chunk', { + messageId: msg.id?.slice(0, 8), + contentCount: msg.content.length, + }); + // Update token state if present if (event.token_state) { updateTokenState(event.token_state); @@ -123,18 +163,24 @@ async function streamFromResponse( break; } case 'Error': { - console.error('Stream event error:', event.error); + log.error('stream event error', event.error); onFinish('Stream error: ' + event.error); return; } case 'Finish': { + log.stream('finish-event', { reason: event.reason }); onFinish(); return; } case 'ModelChange': { + log.stream('model-change', { + model: event.model, + mode: event.mode, + }); break; } case 'UpdateConversation': { + log.messages('conversation-update', event.conversation.length); currentMessages = event.conversation; updateMessages(event.conversation); break; @@ -151,14 +197,14 @@ async function streamFromResponse( } } } catch (e) { - console.error('SSE parse failed:', e); + log.error('SSE parse failed', e); onFinish('Failed to parse SSE:' + e); } } } } catch (error) { if (error instanceof Error && error.name !== 'AbortError') { - console.error('Stream read error:', error); + log.error('stream read error', error); onFinish('Stream error:' + error); } } @@ -183,7 +229,15 @@ export function useChatStream({ } }, [sessionId, session, messages]); - const setMessagesAndLog = useCallback((newMessages: Message[]) => { + const renderCountRef = useRef(0); + renderCountRef.current += 1; + console.log(`useChatStream render #${renderCountRef.current}, ${session?.id}`); + + const setMessagesAndLog = useCallback((newMessages: Message[], logContext: string) => { + log.messages(logContext, newMessages.length, { + lastMessageRole: newMessages[newMessages.length - 1]?.role, + lastMessageId: newMessages[newMessages.length - 1]?.id?.slice(0, 8), + }); setMessages(newMessages); messagesRef.current = newMessages; }, []); @@ -204,9 +258,11 @@ export function useChatStream({ if (!sessionId) return; // Reset state when sessionId changes - setMessagesAndLog([]); + setMessagesAndLog([], 'session-reset'); setSession(undefined); setSessionLoadError(undefined); + log.session('loading', sessionId); + log.state(ChatState.Thinking, { reason: 'session load start' }); setChatState(ChatState.Thinking); let cancelled = false; @@ -224,13 +280,18 @@ export function useChatStream({ const session = response.data; setSession(session); - setMessagesAndLog(session?.conversation || []); + log.session('loaded', sessionId, { + messageCount: session?.conversation?.length ?? 0, + }); + setMessagesAndLog(session?.conversation || [], 'session-load'); + log.state(ChatState.Idle, { reason: 'session load complete' }); setChatState(ChatState.Idle); } catch (error) { if (cancelled) return; - console.error('Session load failed:', error); + log.error('session load failed', error); setSessionLoadError(error instanceof Error ? error.message : String(error)); + log.state(ChatState.Idle, { reason: 'session load error' }); setChatState(ChatState.Idle); } })(); @@ -243,12 +304,17 @@ export function useChatStream({ const handleSubmit = useCallback( async (userMessage: string) => { const currentMessages = [...messagesRef.current, createUserMessage(userMessage)]; - setMessagesAndLog(currentMessages); + log.messages('user-submit', messagesRef.current.length + 1, { + userMessage: userMessage.slice(0, 50), + }); + setMessagesAndLog(currentMessages, 'user-submit'); + log.state(ChatState.Streaming, { reason: 'user submit' }); setChatState(ChatState.Streaming); abortControllerRef.current = new AbortController(); try { + log.stream('request-start', { sessionId: sessionId.slice(0, 8) }); const response = await fetch(getApiUrl('/reply'), { method: 'POST', headers: { @@ -262,20 +328,26 @@ export function useChatStream({ signal: abortControllerRef.current.signal, }); + log.stream('response-received', { + status: response.status, + ok: response.ok, + }); + await streamFromResponse( response, currentMessages, - (messages: Message[]) => setMessagesAndLog(messages), + (messages: Message[]) => setMessagesAndLog(messages, 'stream-update'), setTokenState, onFinish ); + log.stream('stream-complete'); } catch (error) { // AbortError is expected when user stops streaming if (error instanceof Error && error.name === 'AbortError') { - // Stream was aborted by user + log.stream('stream-aborted'); } else { // Unexpected error during fetch setup (streamFromResponse handles its own errors) - console.error('Submit failed:', error); + log.error('submit failed', error); onFinish('Submit error: ' + (error instanceof Error ? error.message : String(error))); } } @@ -285,12 +357,15 @@ export function useChatStream({ useEffect(() => { if (initialMessage && session && messages.length === 0 && chatState === ChatState.Idle) { + log.messages('auto-submit-initial', 0, { initialMessage: initialMessage.slice(0, 50) }); handleSubmit(initialMessage); } }, [initialMessage, session, messages.length, chatState, handleSubmit]); const stopStreaming = useCallback(() => { + log.stream('stop-requested'); abortControllerRef.current?.abort(); + log.state(ChatState.Idle, { reason: 'user stopped streaming' }); setChatState(ChatState.Idle); }, []); @@ -298,6 +373,8 @@ export function useChatStream({ const maybe_cached_messages = session ? messages : cached?.messages || []; const maybe_cached_session = session ?? cached?.session; + console.log('>> returning', sessionId, Date.now(), maybe_cached_messages, chatState); + return { sessionLoadError, messages: maybe_cached_messages, From b6126ee6f241e3a022bc549dd4fe2438e6a5bdd3 Mon Sep 17 00:00:00 2001 From: David Katz Date: Fri, 24 Oct 2025 13:47:11 -0400 Subject: [PATCH 42/55] token mapping fix --- ui/desktop/src/components/BaseChat.tsx | 2 +- ui/desktop/src/components/BaseChat2.tsx | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 453eca7c618d..c09e71282f54 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -443,7 +443,7 @@ function BaseChatContent({ commandHistory={commandHistory} initialValue={input || ''} setView={setView} - numTokens={tokenState?.accumulated_total_tokens ?? sessionTokenCount} + numTokens={tokenState?.total_tokens ?? sessionTokenCount} inputTokens={ tokenState?.accumulated_input_tokens ?? sessionInputTokens ?? localInputTokens } diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index 57ed11fb51c0..31a349333226 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -281,12 +281,7 @@ function BaseChatContent({ //commandHistory={commandHistory} initialValue={initialPrompt} setView={setView} - numTokens={ - tokenState?.accumulated_total_tokens ?? - session?.accumulated_total_tokens ?? - session?.total_tokens ?? - undefined - } + numTokens={tokenState?.total_tokens ?? session?.total_tokens ?? undefined} inputTokens={ tokenState?.accumulated_input_tokens ?? session?.accumulated_input_tokens ?? undefined } From 6f71766d21fb7a29d9d8fdc75fce958c411be85c Mon Sep 17 00:00:00 2001 From: David Katz Date: Fri, 24 Oct 2025 13:55:40 -0400 Subject: [PATCH 43/55] Clearer token names... --- ui/desktop/src/components/BaseChat.tsx | 6 ++--- ui/desktop/src/components/BaseChat2.tsx | 6 ++--- ui/desktop/src/components/ChatInput.tsx | 30 ++++++++++++------------- ui/desktop/src/components/hub.tsx | 6 ++--- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index c09e71282f54..8e898313e17c 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -443,11 +443,11 @@ function BaseChatContent({ commandHistory={commandHistory} initialValue={input || ''} setView={setView} - numTokens={tokenState?.total_tokens ?? sessionTokenCount} - inputTokens={ + currentTotalTokens={tokenState?.total_tokens ?? sessionTokenCount} + accumulatedInputTokens={ tokenState?.accumulated_input_tokens ?? sessionInputTokens ?? localInputTokens } - outputTokens={ + accumulatedOutputTokens={ tokenState?.accumulated_output_tokens ?? sessionOutputTokens ?? localOutputTokens } droppedFiles={droppedFiles} diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index 31a349333226..e8bdcb5f1d87 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -281,11 +281,11 @@ function BaseChatContent({ //commandHistory={commandHistory} initialValue={initialPrompt} setView={setView} - numTokens={tokenState?.total_tokens ?? session?.total_tokens ?? undefined} - inputTokens={ + currentTotalTokens={tokenState?.total_tokens ?? session?.total_tokens ?? undefined} + accumulatedInputTokens={ tokenState?.accumulated_input_tokens ?? session?.accumulated_input_tokens ?? undefined } - outputTokens={ + accumulatedOutputTokens={ tokenState?.accumulated_output_tokens ?? session?.accumulated_output_tokens ?? undefined diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 9a3e1e82406a..3251b49c9909 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -69,9 +69,9 @@ interface ChatInputProps { droppedFiles?: DroppedFile[]; onFilesProcessed?: () => void; // Callback to clear dropped files after processing setView: (view: View) => void; - numTokens?: number; - inputTokens?: number; - outputTokens?: number; + currentTotalTokens?: number; + accumulatedInputTokens?: number; + accumulatedOutputTokens?: number; messages?: Message[]; sessionCosts?: { [key: string]: { @@ -102,9 +102,9 @@ export default function ChatInput({ droppedFiles = [], onFilesProcessed, setView, - numTokens, - inputTokens, - outputTokens, + currentTotalTokens, + accumulatedInputTokens, + accumulatedOutputTokens, messages = [], disableAnimation = false, sessionCosts, @@ -119,9 +119,9 @@ export default function ChatInput({ isExtensionsLoading = false, }: ChatInputProps) { console.log('[ChatInput] RENDER with token props:', { - numTokens, - inputTokens, - outputTokens, + currentTotalTokens, + accumulatedInputTokens, + accumulatedOutputTokens, timestamp: new Date().toISOString(), }); @@ -528,16 +528,16 @@ export default function ChatInput({ clearAlerts(); // Show alert when either there is registered token usage, or we know the limit - if ((numTokens && numTokens > 0) || (isTokenLimitLoaded && tokenLimit)) { + if ((currentTotalTokens && currentTotalTokens > 0) || (isTokenLimitLoaded && tokenLimit)) { addAlert({ type: AlertType.Info, message: 'Context window', progress: { - current: numTokens || 0, + current: currentTotalTokens || 0, total: tokenLimit, }, showCompactButton: true, - compactButtonDisabled: !numTokens, + compactButtonDisabled: !currentTotalTokens, onCompact: () => { window.dispatchEvent(new CustomEvent('hide-alert-popover')); @@ -570,7 +570,7 @@ export default function ChatInput({ // We intentionally omit setView as it shouldn't trigger a re-render of alerts // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - numTokens, + currentTotalTokens, toolCount, tokenLimit, isTokenLimitLoaded, @@ -1575,8 +1575,8 @@ export default function ChatInput({ <>
diff --git a/ui/desktop/src/components/hub.tsx b/ui/desktop/src/components/hub.tsx index 4ae2e64978d9..e9d1b66c09c6 100644 --- a/ui/desktop/src/components/hub.tsx +++ b/ui/desktop/src/components/hub.tsx @@ -78,9 +78,9 @@ export default function Hub({ commandHistory={[]} initialValue="" setView={setView} - numTokens={0} - inputTokens={0} - outputTokens={0} + currentTotalTokens={0} + accumulatedInputTokens={0} + accumulatedOutputTokens={0} droppedFiles={[]} onFilesProcessed={() => {}} messages={[]} From 0ff3d356ce9f4e9752689a95a42bbd0f5a244e2b Mon Sep 17 00:00:00 2001 From: David Katz Date: Fri, 24 Oct 2025 14:43:06 -0400 Subject: [PATCH 44/55] provider always sums to total_tokens --- crates/goose/src/providers/base.rs | 24 ++++++++++++++----- crates/goose/src/providers/formats/bedrock.rs | 10 ++++---- crates/goose/src/providers/sagemaker_tgi.rs | 10 ++++---- crates/goose/src/providers/venice.rs | 10 ++++---- 4 files changed, 33 insertions(+), 21 deletions(-) diff --git a/crates/goose/src/providers/base.rs b/crates/goose/src/providers/base.rs index f8dfadee3757..1ceb80a40b3f 100644 --- a/crates/goose/src/providers/base.rs +++ b/crates/goose/src/providers/base.rs @@ -278,11 +278,11 @@ impl Add for Usage { type Output = Self; fn add(self, other: Self) -> Self { - Self { - input_tokens: sum_optionals(self.input_tokens, other.input_tokens), - output_tokens: sum_optionals(self.output_tokens, other.output_tokens), - total_tokens: sum_optionals(self.total_tokens, other.total_tokens), - } + Self::new( + sum_optionals(self.input_tokens, other.input_tokens), + sum_optionals(self.output_tokens, other.output_tokens), + sum_optionals(self.total_tokens, other.total_tokens), + ) } } @@ -298,10 +298,22 @@ impl Usage { output_tokens: Option, total_tokens: Option, ) -> Self { + // If total_tokens is not provided, calculate it from input + output + let calculated_total = if total_tokens.is_none() { + match (input_tokens, output_tokens) { + (Some(input), Some(output)) => Some(input + output), + (Some(input), None) => Some(input), + (None, Some(output)) => Some(output), + (None, None) => None, + } + } else { + total_tokens + }; + Self { input_tokens, output_tokens, - total_tokens, + total_tokens: calculated_total, } } } diff --git a/crates/goose/src/providers/formats/bedrock.rs b/crates/goose/src/providers/formats/bedrock.rs index c1d627a6778d..d6488163000b 100644 --- a/crates/goose/src/providers/formats/bedrock.rs +++ b/crates/goose/src/providers/formats/bedrock.rs @@ -345,11 +345,11 @@ pub fn from_bedrock_role(role: &bedrock::ConversationRole) -> Result { } pub fn from_bedrock_usage(usage: &bedrock::TokenUsage) -> Usage { - Usage { - input_tokens: Some(usage.input_tokens), - output_tokens: Some(usage.output_tokens), - total_tokens: Some(usage.total_tokens), - } + Usage::new( + Some(usage.input_tokens), + Some(usage.output_tokens), + Some(usage.total_tokens), + ) } pub fn from_bedrock_json(document: &Document) -> Result { diff --git a/crates/goose/src/providers/sagemaker_tgi.rs b/crates/goose/src/providers/sagemaker_tgi.rs index 9c804147cbd1..6be36d196f18 100644 --- a/crates/goose/src/providers/sagemaker_tgi.rs +++ b/crates/goose/src/providers/sagemaker_tgi.rs @@ -300,11 +300,11 @@ impl Provider for SageMakerTgiProvider { let message = self.parse_tgi_response(response)?; // TGI doesn't provide usage statistics, so we estimate - let usage = Usage { - input_tokens: Some(0), // Would need to tokenize input to get accurate count - output_tokens: Some(0), // Would need to tokenize output to get accurate count - total_tokens: Some(0), - }; + let usage = Usage::new( + Some(0), // Would need to tokenize input to get accurate count + Some(0), // Would need to tokenize output to get accurate count + Some(0), + ); // Add debug trace let debug_payload = serde_json::json!({ diff --git a/crates/goose/src/providers/venice.rs b/crates/goose/src/providers/venice.rs index 701af251be4e..d2f837ecbd5d 100644 --- a/crates/goose/src/providers/venice.rs +++ b/crates/goose/src/providers/venice.rs @@ -501,11 +501,11 @@ impl Provider for VeniceProvider { // Extract usage let usage_data = &response_json["usage"]; - let usage = Usage { - input_tokens: usage_data["prompt_tokens"].as_i64().map(|v| v as i32), - output_tokens: usage_data["completion_tokens"].as_i64().map(|v| v as i32), - total_tokens: usage_data["total_tokens"].as_i64().map(|v| v as i32), - }; + let usage = Usage::new( + usage_data["prompt_tokens"].as_i64().map(|v| v as i32), + usage_data["completion_tokens"].as_i64().map(|v| v as i32), + usage_data["total_tokens"].as_i64().map(|v| v as i32), + ); Ok(( Message::new(Role::Assistant, Utc::now().timestamp(), content), From 250fabed5762886d0c25730f63f44e03981a51ed Mon Sep 17 00:00:00 2001 From: David Katz Date: Fri, 24 Oct 2025 15:10:19 -0400 Subject: [PATCH 45/55] don't pass usage to message --- crates/goose-cli/src/commands/acp.rs | 2 +- crates/goose-cli/src/commands/web.rs | 2 +- crates/goose-cli/src/session/mod.rs | 2 +- crates/goose-server/src/routes/reply.rs | 19 ++------ crates/goose/src/agents/agent.rs | 49 ++++++++------------- crates/goose/src/agents/subagent_handler.rs | 2 +- crates/goose/src/scheduler.rs | 2 +- 7 files changed, 28 insertions(+), 50 deletions(-) diff --git a/crates/goose-cli/src/commands/acp.rs b/crates/goose-cli/src/commands/acp.rs index 78de0faeaabe..8ee9acfe09d4 100644 --- a/crates/goose-cli/src/commands/acp.rs +++ b/crates/goose-cli/src/commands/acp.rs @@ -588,7 +588,7 @@ impl acp::Agent for GooseAcpAgent { } match event { - Ok(goose::agents::AgentEvent::Message(message, _usage)) => { + Ok(goose::agents::AgentEvent::Message(message)) => { // Re-acquire the lock to add message to conversation let mut sessions = self.sessions.lock().await; let session = sessions diff --git a/crates/goose-cli/src/commands/web.rs b/crates/goose-cli/src/commands/web.rs index 576a156bd4ef..7ab0be7516f0 100644 --- a/crates/goose-cli/src/commands/web.rs +++ b/crates/goose-cli/src/commands/web.rs @@ -485,7 +485,7 @@ async fn process_message_streaming( Ok(mut stream) => { while let Some(result) = stream.next().await { match result { - Ok(AgentEvent::Message(message, _usage)) => { + Ok(AgentEvent::Message(message)) => { SessionManager::add_message(&session_id, &message).await?; for content in &message.content { diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 3d4878a2aa50..133b3aef4886 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -859,7 +859,7 @@ impl CliSession { tokio::select! { result = stream.next() => { match result { - Some(Ok(AgentEvent::Message(message, _usage))) => { + Some(Ok(AgentEvent::Message(message))) => { // If it's a confirmation request, get approval but otherwise do not render/persist if let Some(MessageContent::ToolConfirmationRequest(confirmation)) = message.content.first() { output::hide_thinking(); diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index 17f8e4aa9093..7ea5aaf8c7be 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -299,7 +299,7 @@ pub async fn reply( } response = timeout(Duration::from_millis(500), stream.next()) => { match response { - Ok(Some(Ok(AgentEvent::Message(message, usage)))) => { + Ok(Some(Ok(AgentEvent::Message(message)))) => { for content in &message.content { track_tool_telemetry(content, all_messages.messages()); } @@ -309,21 +309,10 @@ pub async fn reply( // Always fetch session and create token state so client has latest accumulated tokens let token_state = match SessionManager::get_session(&session_id, false).await { Ok(session) => { - // If we have new usage from this turn, use it; otherwise use current session values - let (input, output, total) = if let Some(ref provider_usage) = usage { - ( - provider_usage.usage.input_tokens, - provider_usage.usage.output_tokens, - provider_usage.usage.total_tokens, - ) - } else { - (session.input_tokens, session.output_tokens, session.total_tokens) - }; - Some(goose::session::session_manager::TokenState { - input_tokens: input, - output_tokens: output, - total_tokens: total, + input_tokens: session.input_tokens, + output_tokens: session.output_tokens, + total_tokens: session.total_tokens, accumulated_input_tokens: session.accumulated_input_tokens, accumulated_output_tokens: session.accumulated_output_tokens, accumulated_total_tokens: session.accumulated_total_tokens, diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 034bd0e30fb7..5a26501e9955 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -36,7 +36,7 @@ use crate::mcp_utils::ToolResult; use crate::permission::permission_inspector::PermissionInspector; use crate::permission::permission_judge::PermissionCheckResult; use crate::permission::PermissionConfirmation; -use crate::providers::base::{Provider, ProviderUsage}; +use crate::providers::base::Provider; use crate::providers::errors::ProviderError; use crate::recipe::{Author, Recipe, Response, Settings, SubRecipe}; use crate::scheduler_trait::SchedulerTrait; @@ -108,7 +108,7 @@ pub struct Agent { #[derive(Clone, Debug)] pub enum AgentEvent { - Message(Message, Option), + Message(Message), McpNotification((String, ServerNotification)), ModelChange { model: String, mode: String }, HistoryReplaced(Conversation), @@ -800,8 +800,7 @@ impl Agent { Message::assistant().with_system_notification( SystemNotificationType::InlineMessage, inline_msg, - ), - None + ) ); } @@ -809,8 +808,7 @@ impl Agent { Message::assistant().with_system_notification( SystemNotificationType::ThinkingMessage, COMPACTION_THINKING_TEXT, - ), - None + ) ); match crate::context_mgmt::compact_messages(self, &conversation_to_compact, false).await { @@ -825,8 +823,7 @@ impl Agent { Message::assistant().with_system_notification( SystemNotificationType::InlineMessage, "Compaction complete", - ), - None + ) ); if !is_manual_compact { @@ -840,8 +837,7 @@ impl Agent { yield AgentEvent::Message( Message::assistant().with_text( format!("Ran into this error trying to compact: {e}.\n\nPlease try again or create a new session") - ), - None + ) ); } } @@ -934,8 +930,7 @@ impl Agent { if let Some(final_output_tool) = self.final_output_tool.lock().await.as_ref() { if final_output_tool.final_output.is_some() { let final_event = AgentEvent::Message( - Message::assistant().with_text(final_output_tool.final_output.clone().unwrap()), - None + Message::assistant().with_text(final_output_tool.final_output.clone().unwrap()) ); yield final_event; break; @@ -947,8 +942,7 @@ impl Agent { yield AgentEvent::Message( Message::assistant().with_text( "I've reached the maximum number of actions I can do without user input. Would you like me to continue?" - ), - None + ) ); break; } @@ -1026,7 +1020,7 @@ impl Agent { .record_tool_requests(&requests_to_record) .await; - yield AgentEvent::Message(filtered_response.clone(), usage); + yield AgentEvent::Message(filtered_response.clone()); tokio::task::yield_now().await; let num_tool_requests = frontend_requests.len() + remaining_requests.len(); @@ -1044,7 +1038,7 @@ impl Agent { ); while let Some(msg) = frontend_tool_stream.try_next().await? { - yield AgentEvent::Message(msg, None); + yield AgentEvent::Message(msg); } let mode = goose_mode.clone(); @@ -1113,7 +1107,7 @@ impl Agent { ); while let Some(msg) = tool_approval_stream.try_next().await? { - yield AgentEvent::Message(msg, None); + yield AgentEvent::Message(msg); } tool_futures = { @@ -1165,7 +1159,7 @@ impl Agent { } let final_message_tool_resp = message_tool_response.lock().await.clone(); - yield AgentEvent::Message(final_message_tool_resp.clone(), None); + yield AgentEvent::Message(final_message_tool_resp.clone()); no_tools_called = false; messages_to_add.push(final_message_tool_resp); @@ -1176,15 +1170,13 @@ impl Agent { Message::assistant().with_system_notification( SystemNotificationType::InlineMessage, "Context limit reached. Compacting to continue conversation...", - ), - None + ) ); yield AgentEvent::Message( Message::assistant().with_system_notification( SystemNotificationType::ThinkingMessage, COMPACTION_THINKING_TEXT, - ), - None + ) ); match crate::context_mgmt::compact_messages(self, &conversation, true).await { @@ -1204,8 +1196,7 @@ impl Agent { yield AgentEvent::Message( Message::assistant().with_text( format!("Ran into this error trying to compact: {e}.\n\nPlease retry if you think this is a transient or recoverable error.") - ), - None + ) ); break; } @@ -1216,8 +1207,7 @@ impl Agent { yield AgentEvent::Message( Message::assistant().with_text( format!("Ran into this error: {e}.\n\nPlease retry if you think this is a transient or recoverable error.") - ), - None + ) ); break; } @@ -1233,11 +1223,11 @@ impl Agent { warn!("Final output tool has not been called yet. Continuing agent loop."); let message = Message::user().with_text(FINAL_OUTPUT_CONTINUATION_MESSAGE); messages_to_add.push(message.clone()); - yield AgentEvent::Message(message, None); + yield AgentEvent::Message(message); } else { let message = Message::assistant().with_text(final_output_tool.final_output.clone().unwrap()); messages_to_add.push(message.clone()); - yield AgentEvent::Message(message, None); + yield AgentEvent::Message(message); exit_chat = true; } } else if did_recovery_compact_this_iteration { @@ -1256,8 +1246,7 @@ impl Agent { yield AgentEvent::Message( Message::assistant().with_text( format!("Retry logic encountered an error: {}", e) - ), - None + ) ); exit_chat = true; } diff --git a/crates/goose/src/agents/subagent_handler.rs b/crates/goose/src/agents/subagent_handler.rs index d1e498fb1859..ad14f282f1b7 100644 --- a/crates/goose/src/agents/subagent_handler.rs +++ b/crates/goose/src/agents/subagent_handler.rs @@ -146,7 +146,7 @@ fn get_agent_messages( .map_err(|e| anyhow!("Failed to get reply from agent: {}", e))?; while let Some(message_result) = stream.next().await { match message_result { - Ok(AgentEvent::Message(msg, _usage)) => conversation.push(msg), + Ok(AgentEvent::Message(msg)) => conversation.push(msg), Ok(AgentEvent::McpNotification(_)) | Ok(AgentEvent::ModelChange { .. }) => {} Ok(AgentEvent::HistoryReplaced(updated_conversation)) => { conversation = updated_conversation; diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index 67614fbd5253..a339731498fe 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -1223,7 +1223,7 @@ async fn run_scheduled_job_internal( tokio::task::yield_now().await; match message_result { - Ok(AgentEvent::Message(msg, _usage)) => { + Ok(AgentEvent::Message(msg)) => { if msg.role == rmcp::model::Role::Assistant { tracing::info!("[Job {}] Assistant: {:?}", job.id, msg.content); } From 8be519b7ddccce902f24258e0eeac0c242c16baf Mon Sep 17 00:00:00 2001 From: David Katz Date: Fri, 24 Oct 2025 15:37:48 -0400 Subject: [PATCH 46/55] lingering usage refs --- crates/goose-server/src/routes/reply.rs | 1 - crates/goose/examples/agent.rs | 2 +- crates/goose/src/providers/base.rs | 1 - crates/goose/tests/agent.rs | 12 ++++++------ 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index 7ea5aaf8c7be..8fa4be2551ca 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -306,7 +306,6 @@ pub async fn reply( all_messages.push(message.clone()); - // Always fetch session and create token state so client has latest accumulated tokens let token_state = match SessionManager::get_session(&session_id, false).await { Ok(session) => { Some(goose::session::session_manager::TokenState { diff --git a/crates/goose/examples/agent.rs b/crates/goose/examples/agent.rs index b29d9b202249..d57226a7a797 100644 --- a/crates/goose/examples/agent.rs +++ b/crates/goose/examples/agent.rs @@ -37,7 +37,7 @@ async fn main() { .unwrap(); let mut stream = agent.reply(conversation, None, None).await.unwrap(); - while let Some(Ok(AgentEvent::Message(message, _usage))) = stream.next().await { + while let Some(Ok(AgentEvent::Message(message))) = stream.next().await { println!("{}", serde_json::to_string_pretty(&message).unwrap()); println!("\n"); } diff --git a/crates/goose/src/providers/base.rs b/crates/goose/src/providers/base.rs index 1ceb80a40b3f..543d41eb9fda 100644 --- a/crates/goose/src/providers/base.rs +++ b/crates/goose/src/providers/base.rs @@ -298,7 +298,6 @@ impl Usage { output_tokens: Option, total_tokens: Option, ) -> Self { - // If total_tokens is not provided, calculate it from input + output let calculated_total = if total_tokens.is_none() { match (input_tokens, output_tokens) { (Some(input), Some(output)) => Some(input + output), diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs index b95767290897..e97f0bfb53c8 100644 --- a/crates/goose/tests/agent.rs +++ b/crates/goose/tests/agent.rs @@ -137,7 +137,7 @@ async fn run_truncate_test( let mut responses = Vec::new(); while let Some(response_result) = reply_stream.next().await { match response_result { - Ok(AgentEvent::Message(response, _usage)) => responses.push(response), + Ok(AgentEvent::Message(response)) => responses.push(response), Ok(AgentEvent::McpNotification(n)) => { println!("MCP Notification: {n:?}"); } @@ -632,7 +632,7 @@ mod final_output_tool_tests { let mut responses = Vec::new(); while let Some(response_result) = reply_stream.next().await { match response_result { - Ok(AgentEvent::Message(response, _usage)) => responses.push(response), + Ok(AgentEvent::Message(response)) => responses.push(response), Ok(_) => {} Err(e) => return Err(e), } @@ -773,7 +773,7 @@ mod final_output_tool_tests { let mut count = 0; while let Some(response_result) = reply_stream.next().await { match response_result { - Ok(AgentEvent::Message(response, _usage)) => { + Ok(AgentEvent::Message(response)) => { responses.push(response); count += 1; if count >= 4 { @@ -809,7 +809,7 @@ mod final_output_tool_tests { // Continue streaming to consume any remaining content, this lets us verify the provider saw the continuation message while let Some(response_result) = reply_stream.next().await { match response_result { - Ok(AgentEvent::Message(_response, _usage)) => { + Ok(AgentEvent::Message(_response)) => { break; // Stop after receiving the next message } Ok(_) => {} @@ -930,7 +930,7 @@ mod retry_tests { let mut responses = Vec::new(); while let Some(response_result) = reply_stream.next().await { match response_result { - Ok(AgentEvent::Message(response, _usage)) => responses.push(response), + Ok(AgentEvent::Message(response)) => responses.push(response), Ok(_) => {} Err(e) => return Err(e), } @@ -1096,7 +1096,7 @@ mod max_turns_tests { let mut responses = Vec::new(); while let Some(response_result) = reply_stream.next().await { match response_result { - Ok(AgentEvent::Message(response, _usage)) => { + Ok(AgentEvent::Message(response)) => { if let Some(MessageContent::ToolConfirmationRequest(ref req)) = response.content.first() { From f3aa25216386175442342fd4e6f62709e4504967 Mon Sep 17 00:00:00 2001 From: David Katz Date: Fri, 24 Oct 2025 15:38:05 -0400 Subject: [PATCH 47/55] fmt --- crates/goose/src/providers/sagemaker_tgi.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/goose/src/providers/sagemaker_tgi.rs b/crates/goose/src/providers/sagemaker_tgi.rs index 6be36d196f18..2cb5549e658b 100644 --- a/crates/goose/src/providers/sagemaker_tgi.rs +++ b/crates/goose/src/providers/sagemaker_tgi.rs @@ -301,7 +301,7 @@ impl Provider for SageMakerTgiProvider { // TGI doesn't provide usage statistics, so we estimate let usage = Usage::new( - Some(0), // Would need to tokenize input to get accurate count + Some(0), // Would need to tokenize input to get accurate count Some(0), // Would need to tokenize output to get accurate count Some(0), ); From d8af0a05ce36757bbb38b4cd9eb27d4a4682a9bf Mon Sep 17 00:00:00 2001 From: David Katz Date: Fri, 24 Oct 2025 15:39:37 -0400 Subject: [PATCH 48/55] rename currenTtotalTokens --- ui/desktop/src/components/BaseChat.tsx | 2 +- ui/desktop/src/components/BaseChat2.tsx | 2 +- ui/desktop/src/components/ChatInput.tsx | 14 +++++++------- ui/desktop/src/components/hub.tsx | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 8e898313e17c..b4440852405e 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -443,7 +443,7 @@ function BaseChatContent({ commandHistory={commandHistory} initialValue={input || ''} setView={setView} - currentTotalTokens={tokenState?.total_tokens ?? sessionTokenCount} + totalTokens={tokenState?.total_tokens ?? sessionTokenCount} accumulatedInputTokens={ tokenState?.accumulated_input_tokens ?? sessionInputTokens ?? localInputTokens } diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index e8bdcb5f1d87..5d7b43e0a5f6 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -281,7 +281,7 @@ function BaseChatContent({ //commandHistory={commandHistory} initialValue={initialPrompt} setView={setView} - currentTotalTokens={tokenState?.total_tokens ?? session?.total_tokens ?? undefined} + totalTokens={tokenState?.total_tokens ?? session?.total_tokens ?? undefined} accumulatedInputTokens={ tokenState?.accumulated_input_tokens ?? session?.accumulated_input_tokens ?? undefined } diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 3251b49c9909..468ef730c91b 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -69,7 +69,7 @@ interface ChatInputProps { droppedFiles?: DroppedFile[]; onFilesProcessed?: () => void; // Callback to clear dropped files after processing setView: (view: View) => void; - currentTotalTokens?: number; + totalTokens?: number; accumulatedInputTokens?: number; accumulatedOutputTokens?: number; messages?: Message[]; @@ -102,7 +102,7 @@ export default function ChatInput({ droppedFiles = [], onFilesProcessed, setView, - currentTotalTokens, + totalTokens, accumulatedInputTokens, accumulatedOutputTokens, messages = [], @@ -119,7 +119,7 @@ export default function ChatInput({ isExtensionsLoading = false, }: ChatInputProps) { console.log('[ChatInput] RENDER with token props:', { - currentTotalTokens, + currentTotalTokens: totalTokens, accumulatedInputTokens, accumulatedOutputTokens, timestamp: new Date().toISOString(), @@ -528,16 +528,16 @@ export default function ChatInput({ clearAlerts(); // Show alert when either there is registered token usage, or we know the limit - if ((currentTotalTokens && currentTotalTokens > 0) || (isTokenLimitLoaded && tokenLimit)) { + if ((totalTokens && totalTokens > 0) || (isTokenLimitLoaded && tokenLimit)) { addAlert({ type: AlertType.Info, message: 'Context window', progress: { - current: currentTotalTokens || 0, + current: totalTokens || 0, total: tokenLimit, }, showCompactButton: true, - compactButtonDisabled: !currentTotalTokens, + compactButtonDisabled: !totalTokens, onCompact: () => { window.dispatchEvent(new CustomEvent('hide-alert-popover')); @@ -570,7 +570,7 @@ export default function ChatInput({ // We intentionally omit setView as it shouldn't trigger a re-render of alerts // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - currentTotalTokens, + totalTokens, toolCount, tokenLimit, isTokenLimitLoaded, diff --git a/ui/desktop/src/components/hub.tsx b/ui/desktop/src/components/hub.tsx index e9d1b66c09c6..3cae47ba04dc 100644 --- a/ui/desktop/src/components/hub.tsx +++ b/ui/desktop/src/components/hub.tsx @@ -78,7 +78,7 @@ export default function Hub({ commandHistory={[]} initialValue="" setView={setView} - currentTotalTokens={0} + totalTokens={0} accumulatedInputTokens={0} accumulatedOutputTokens={0} droppedFiles={[]} From 1c2ffcc000f19320dc27165e2c6180e2fdfd1770 Mon Sep 17 00:00:00 2001 From: David Katz Date: Fri, 24 Oct 2025 15:57:38 -0400 Subject: [PATCH 49/55] cleanup diff --- ui/desktop/src/components/ChatInput.tsx | 7 ---- ui/desktop/src/hooks/useChatStream.ts | 52 +++++++++++++++++------- ui/desktop/src/hooks/useMessageStream.ts | 7 +--- 3 files changed, 38 insertions(+), 28 deletions(-) diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 468ef730c91b..66dfa8d375e9 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -118,13 +118,6 @@ export default function ChatInput({ append: _append, isExtensionsLoading = false, }: ChatInputProps) { - console.log('[ChatInput] RENDER with token props:', { - currentTotalTokens: totalTokens, - accumulatedInputTokens, - accumulatedOutputTokens, - timestamp: new Date().toISOString(), - }); - const [_value, setValue] = useState(initialValue); const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback const [isFocused, setIsFocused] = useState(false); diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index 081c6c7f641f..afda2cef484c 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -114,6 +114,9 @@ async function streamFromResponse( updateTokenState: (tokenState?: TokenState) => void, onFinish: (error?: string) => void ): Promise { + let chunkCount = 0; + let messageEventCount = 0; + try { if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.body) throw new Error('No response body'); @@ -123,15 +126,18 @@ async function streamFromResponse( let currentMessages = initialMessages; log.stream('reading-chunks'); + while (true) { const { done, value } = await reader.read(); if (done) { log.stream('chunks-complete', { - totalMessages: currentMessages.length, + totalChunks: chunkCount, + messageEvents: messageEventCount, }); break; } + chunkCount++; const chunk = decoder.decode(value); const lines = chunk.split('\n'); @@ -146,19 +152,24 @@ async function streamFromResponse( switch (event.type) { case 'Message': { + messageEventCount++; const msg = event.message; currentMessages = pushMessage(currentMessages, msg); - log.stream('message-chunk', { - messageId: msg.id?.slice(0, 8), - contentCount: msg.content.length, - }); + // Only log every 10th message event to avoid spam + if (messageEventCount % 10 === 0) { + log.stream('message-chunk', { + eventCount: messageEventCount, + messageCount: currentMessages.length, + }); + } // Update token state if present if (event.token_state) { updateTokenState(event.token_state); } + // This calls the wrapped setMessagesAndLog with 'streaming' context updateMessages(currentMessages); break; } @@ -181,14 +192,16 @@ async function streamFromResponse( } case 'UpdateConversation': { log.messages('conversation-update', event.conversation.length); - currentMessages = event.conversation; + // This calls the wrapped setMessagesAndLog with 'streaming' context updateMessages(event.conversation); break; } case 'Notification': { + // Don't log notifications, too noisy break; } case 'Ping': { + // Don't log pings break; } default: { @@ -258,15 +271,16 @@ export function useChatStream({ if (!sessionId) return; // Reset state when sessionId changes + log.session('loading', sessionId); setMessagesAndLog([], 'session-reset'); setSession(undefined); setSessionLoadError(undefined); - log.session('loading', sessionId); - log.state(ChatState.Thinking, { reason: 'session load start' }); setChatState(ChatState.Thinking); let cancelled = false; + log.state(ChatState.Thinking, { reason: 'session load start' }); + (async () => { try { const response = await resumeAgent({ @@ -279,11 +293,14 @@ export function useChatStream({ if (cancelled) return; const session = response.data; - setSession(session); log.session('loaded', sessionId, { - messageCount: session?.conversation?.length ?? 0, + messageCount: session?.conversation?.length || 0, + description: session?.description, }); - setMessagesAndLog(session?.conversation || [], 'session-load'); + + setSession(session); + setMessagesAndLog(session?.conversation || [], 'load-session'); + log.state(ChatState.Idle, { reason: 'session load complete' }); setChatState(ChatState.Idle); } catch (error) { @@ -291,6 +308,7 @@ export function useChatStream({ log.error('session load failed', error); setSessionLoadError(error instanceof Error ? error.message : String(error)); + log.state(ChatState.Idle, { reason: 'session load error' }); setChatState(ChatState.Idle); } @@ -303,11 +321,13 @@ export function useChatStream({ const handleSubmit = useCallback( async (userMessage: string) => { - const currentMessages = [...messagesRef.current, createUserMessage(userMessage)]; log.messages('user-submit', messagesRef.current.length + 1, { - userMessage: userMessage.slice(0, 50), + userMessageLength: userMessage.length, }); - setMessagesAndLog(currentMessages, 'user-submit'); + + const currentMessages = [...messagesRef.current, createUserMessage(userMessage)]; + setMessagesAndLog(currentMessages, 'user-entered'); + log.state(ChatState.Streaming, { reason: 'user submit' }); setChatState(ChatState.Streaming); @@ -315,6 +335,7 @@ export function useChatStream({ try { log.stream('request-start', { sessionId: sessionId.slice(0, 8) }); + const response = await fetch(getApiUrl('/reply'), { method: 'POST', headers: { @@ -336,10 +357,11 @@ export function useChatStream({ await streamFromResponse( response, currentMessages, - (messages: Message[]) => setMessagesAndLog(messages, 'stream-update'), + (messages: Message[]) => setMessagesAndLog(messages, 'streaming'), setTokenState, onFinish ); + log.stream('stream-complete'); } catch (error) { // AbortError is expected when user stops streaming diff --git a/ui/desktop/src/hooks/useMessageStream.ts b/ui/desktop/src/hooks/useMessageStream.ts index 6eb81815513e..9307cb87d9cd 100644 --- a/ui/desktop/src/hooks/useMessageStream.ts +++ b/ui/desktop/src/hooks/useMessageStream.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useId, useReducer, useRef, useState } from 'react'; import useSWR from 'swr'; -import { createUserMessage, getThinkingMessage, hasCompletedToolCalls } from '../types/message'; +import { createUserMessage, hasCompletedToolCalls } from '../types/message'; import { Conversation, Message, Role } from '../api'; import { getSession, Session } from '../api'; @@ -325,10 +325,6 @@ export function useMessageStream({ mutateChatState(ChatState.WaitingForUserInput); } - if (getThinkingMessage(newMessage)) { - mutateChatState(ChatState.Thinking); - } - mutate(currentMessages, false); break; } @@ -352,7 +348,6 @@ export function useMessageStream({ } case 'UpdateConversation': { - currentMessages = parsedEvent.conversation; setMessages(parsedEvent.conversation); break; } From 66bfef0a94aa125576c9182b20729a69aee29b5d Mon Sep 17 00:00:00 2001 From: David Katz Date: Fri, 24 Oct 2025 17:27:13 -0400 Subject: [PATCH 50/55] move to openapi schema --- crates/goose-server/src/openapi.rs | 1 + crates/goose-server/src/routes/reply.rs | 4 +- crates/goose/src/conversation/message.rs | 13 + crates/goose/src/session/session_manager.rs | 12 - openapi.json | 4635 +++++++++++++++++++ ui/desktop/openapi.json | 37 + ui/desktop/src/api/types.gen.ts | 15 + ui/desktop/src/components/BaseChat.tsx | 6 +- ui/desktop/src/components/BaseChat2.tsx | 8 +- ui/desktop/src/hooks/useChatStream.ts | 11 +- ui/desktop/src/hooks/useMessageStream.ts | 11 +- 11 files changed, 4711 insertions(+), 42 deletions(-) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index fab35caadd12..322139210e01 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -401,6 +401,7 @@ derive_utoipa!(Icon as IconSchema); Message, MessageContent, MessageMetadata, + goose::conversation::message::TokenState, ContentSchema, EmbeddedResourceSchema, ImageContentSchema, diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index 8fa4be2551ca..b4a5279456e5 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -127,7 +127,7 @@ pub enum MessageEvent { Message { message: Message, #[serde(skip_serializing_if = "Option::is_none")] - token_state: Option, + token_state: Option, }, Error { error: String, @@ -308,7 +308,7 @@ pub async fn reply( let token_state = match SessionManager::get_session(&session_id, false).await { Ok(session) => { - Some(goose::session::session_manager::TokenState { + Some(goose::conversation::message::TokenState { input_tokens: session.input_tokens, output_tokens: session.output_tokens, total_tokens: session.total_tokens, diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index f432d3292e75..8025cb281ab2 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -711,6 +711,19 @@ impl Message { } } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct TokenState { + /// Current turn token counts + pub input_tokens: Option, + pub output_tokens: Option, + pub total_tokens: Option, + /// Accumulated token counts across all turns + pub accumulated_input_tokens: Option, + pub accumulated_output_tokens: Option, + pub accumulated_total_tokens: Option, +} + #[cfg(test)] mod tests { use crate::conversation::message::{Message, MessageContent, MessageMetadata}; diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 1e9344f0560f..1ec7a2960368 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -48,18 +48,6 @@ pub struct Session { pub message_count: usize, } -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct TokenState { - /// Current turn token counts - pub input_tokens: Option, - pub output_tokens: Option, - pub total_tokens: Option, - /// Accumulated token counts across all turns - pub accumulated_input_tokens: Option, - pub accumulated_output_tokens: Option, - pub accumulated_total_tokens: Option, -} - pub struct SessionUpdateBuilder { session_id: String, name: Option, diff --git a/openapi.json b/openapi.json index e69de29bb2d1..a438461e36c3 100644 --- a/openapi.json +++ b/openapi.json @@ -0,0 +1,4635 @@ + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.69s + Running `target/debug/generate_schema` +Successfully generated OpenAPI schema at /Users/dkatz/git/goose3/goose/ui/desktop/openapi.json +{ + "openapi": "3.0.3", + "info": { + "title": "goose-server", + "description": "An AI agent", + "contact": { + "name": "Block", + "email": "ai-oss-tools@block.xyz" + }, + "license": { + "name": "Apache-2.0" + }, + "version": "1.11.0" + }, + "paths": { + "/agent/resume": { + "post": { + "tags": [ + "super::routes::agent" + ], + "operationId": "resume_agent", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResumeAgentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Agent started successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request - invalid working directory" + }, + "401": { + "description": "Unauthorized - invalid secret key" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/agent/start": { + "post": { + "tags": [ + "super::routes::agent" + ], + "operationId": "start_agent", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartAgentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Agent started successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid secret key" + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/agent/tools": { + "get": { + "tags": [ + "super::routes::agent" + ], + "operationId": "get_tools", + "parameters": [ + { + "name": "extension_name", + "in": "query", + "description": "Optional extension name to filter tools", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "session_id", + "in": "query", + "description": "Required session ID to scope tools to a specific session", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tools retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ToolInfo" + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid secret key" + }, + "424": { + "description": "Agent not initialized" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/agent/update_from_session": { + "post": { + "tags": [ + "super::routes::agent" + ], + "operationId": "update_from_session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateFromSessionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Update agent from session data successfully" + }, + "401": { + "description": "Unauthorized - invalid secret key" + }, + "424": { + "description": "Agent not initialized" + } + } + } + }, + "/agent/update_provider": { + "post": { + "tags": [ + "super::routes::agent" + ], + "operationId": "update_agent_provider", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProviderRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Provider updated successfully" + }, + "400": { + "description": "Bad request - missing or invalid parameters" + }, + "401": { + "description": "Unauthorized - invalid secret key" + }, + "424": { + "description": "Agent not initialized" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/agent/update_router_tool_selector": { + "post": { + "tags": [ + "super::routes::agent" + ], + "operationId": "update_router_tool_selector", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRouterToolSelectorRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Tool selection strategy updated successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid secret key" + }, + "424": { + "description": "Agent not initialized" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/config": { + "get": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "read_all_config", + "responses": { + "200": { + "description": "All configuration values retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigResponse" + } + } + } + } + } + } + }, + "/config/backup": { + "post": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "backup_config", + "responses": { + "200": { + "description": "Config file backed up", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/config/custom-providers": { + "post": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "create_custom_provider", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCustomProviderRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Custom provider created successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Invalid request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/config/custom-providers/{id}": { + "get": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "get_custom_provider", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Custom provider retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoadedProvider" + } + } + } + }, + "404": { + "description": "Provider not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "put": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "update_custom_provider", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCustomProviderRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Custom provider updated successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Provider not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "delete": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "remove_custom_provider", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Custom provider removed successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Provider not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/config/extensions": { + "get": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "get_extensions", + "responses": { + "200": { + "description": "All extensions retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExtensionResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + }, + "post": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "add_extension", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExtensionQuery" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Extension added or updated successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Invalid request" + }, + "422": { + "description": "Could not serialize config.yaml" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/config/extensions/{name}": { + "delete": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "remove_extension", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Extension removed successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Extension not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/config/init": { + "post": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "init_config", + "responses": { + "200": { + "description": "Config initialization check completed", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/config/permissions": { + "post": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "upsert_permissions", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertPermissionsQuery" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Permission update completed", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Invalid request" + } + } + } + }, + "/config/providers": { + "get": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "providers", + "responses": { + "200": { + "description": "All configuration values retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderDetails" + } + } + } + } + } + } + } + }, + "/config/providers/{name}/models": { + "get": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "get_provider_models", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Provider name (e.g., openai)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Models fetched successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "400": { + "description": "Unknown provider, provider not configured, or authentication error" + }, + "429": { + "description": "Rate limit exceeded" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/config/read": { + "post": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "read_config", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigKeyQuery" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Configuration value retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "500": { + "description": "Unable to get the configuration value" + } + } + } + }, + "/config/recover": { + "post": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "recover_config", + "responses": { + "200": { + "description": "Config recovery attempted", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/config/remove": { + "post": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "remove_config", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigKeyQuery" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Configuration value removed successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Configuration key not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/config/upsert": { + "post": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "upsert_config", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertConfigQuery" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Configuration value upserted successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/config/validate": { + "get": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "validate_config", + "responses": { + "200": { + "description": "Config validation result", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "422": { + "description": "Config file is corrupted" + } + } + } + }, + "/confirm": { + "post": { + "tags": [ + "super::routes::reply" + ], + "operationId": "confirm_permission", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PermissionConfirmationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Permission action is confirmed", + "content": { + "application/json": { + "schema": {} + } + } + }, + "401": { + "description": "Unauthorized - invalid secret key" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/diagnostics/{session_id}": { + "get": { + "tags": [ + "super::routes::status" + ], + "operationId": "diagnostics", + "parameters": [ + { + "name": "session_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Diagnostics zip file", + "content": { + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "500": { + "description": "Failed to generate diagnostics" + } + } + } + }, + "/handle_openrouter": { + "post": { + "tags": [ + "super::routes::setup" + ], + "operationId": "start_openrouter_setup", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetupResponse" + } + } + } + } + } + } + }, + "/handle_tetrate": { + "post": { + "tags": [ + "super::routes::setup" + ], + "operationId": "start_tetrate_setup", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetupResponse" + } + } + } + } + } + } + }, + "/recipes/create": { + "post": { + "tags": [ + "Recipe Management" + ], + "operationId": "create_recipe", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Recipe created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRecipeResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "412": { + "description": "Precondition failed - Agent not available" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/recipes/decode": { + "post": { + "tags": [ + "Recipe Management" + ], + "operationId": "decode_recipe", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DecodeRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Recipe decoded successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DecodeRecipeResponse" + } + } + } + }, + "400": { + "description": "Bad request" + } + } + } + }, + "/recipes/delete": { + "post": { + "tags": [ + "Recipe Management" + ], + "operationId": "delete_recipe", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Recipe deleted successfully" + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "404": { + "description": "Recipe not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/recipes/encode": { + "post": { + "tags": [ + "Recipe Management" + ], + "operationId": "encode_recipe", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EncodeRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Recipe encoded successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EncodeRecipeResponse" + } + } + } + }, + "400": { + "description": "Bad request" + } + } + } + }, + "/recipes/list": { + "get": { + "tags": [ + "Recipe Management" + ], + "operationId": "list_recipes", + "responses": { + "200": { + "description": "Get recipe list successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListRecipeResponse" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/recipes/parse": { + "post": { + "tags": [ + "Recipe Management" + ], + "operationId": "parse_recipe", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ParseRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Recipe parsed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ParseRecipeResponse" + } + } + } + }, + "400": { + "description": "Bad request - Invalid recipe format", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/recipes/save": { + "post": { + "tags": [ + "Recipe Management" + ], + "operationId": "save_recipe", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Recipe saved to file successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveRecipeResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/recipes/scan": { + "post": { + "tags": [ + "Recipe Management" + ], + "operationId": "scan_recipe", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Recipe scanned successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanRecipeResponse" + } + } + } + } + } + } + }, + "/reply": { + "post": { + "tags": [ + "super::routes::reply" + ], + "operationId": "reply", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Streaming response initiated" + }, + "424": { + "description": "Agent not initialized" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/schedule/create": { + "post": { + "tags": [ + "schedule" + ], + "operationId": "create_schedule", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateScheduleRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Scheduled job created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduledJob" + } + } + } + }, + "400": { + "description": "Invalid cron expression or recipe file" + }, + "409": { + "description": "Job ID already exists" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/schedule/delete/{id}": { + "delete": { + "tags": [ + "schedule" + ], + "operationId": "delete_schedule", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the schedule to delete", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Scheduled job deleted successfully" + }, + "404": { + "description": "Scheduled job not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/schedule/list": { + "get": { + "tags": [ + "schedule" + ], + "operationId": "list_schedules", + "responses": { + "200": { + "description": "A list of scheduled jobs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListSchedulesResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/schedule/{id}": { + "put": { + "tags": [ + "schedule" + ], + "operationId": "update_schedule", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the schedule to update", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateScheduleRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Scheduled job updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduledJob" + } + } + } + }, + "400": { + "description": "Cannot update a currently running job or invalid request" + }, + "404": { + "description": "Scheduled job not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/schedule/{id}/inspect": { + "get": { + "tags": [ + "schedule" + ], + "operationId": "inspect_running_job", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the schedule to inspect", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Running job information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InspectJobResponse" + } + } + } + }, + "404": { + "description": "Scheduled job not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/schedule/{id}/kill": { + "post": { + "tags": [ + "schedule" + ], + "operationId": "kill_running_job", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Running job killed successfully" + } + } + } + }, + "/schedule/{id}/pause": { + "post": { + "tags": [ + "schedule" + ], + "operationId": "pause_schedule", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the schedule to pause", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Scheduled job paused successfully" + }, + "400": { + "description": "Cannot pause a currently running job" + }, + "404": { + "description": "Scheduled job not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/schedule/{id}/run_now": { + "post": { + "tags": [ + "schedule" + ], + "operationId": "run_now_handler", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the schedule to run", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scheduled job triggered successfully, returns new session ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunNowResponse" + } + } + } + }, + "404": { + "description": "Scheduled job not found" + }, + "500": { + "description": "Internal server error when trying to run the job" + } + } + } + }, + "/schedule/{id}/sessions": { + "get": { + "tags": [ + "schedule" + ], + "operationId": "sessions_handler", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the schedule", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "A list of session display info", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionDisplayInfo" + } + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/schedule/{id}/unpause": { + "post": { + "tags": [ + "schedule" + ], + "operationId": "unpause_schedule", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the schedule to unpause", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Scheduled job unpaused successfully" + }, + "404": { + "description": "Scheduled job not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/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/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/{session_id}": { + "get": { + "tags": [ + "Session Management" + ], + "operationId": "get_session", + "parameters": [ + { + "name": "session_id", + "in": "path", + "description": "Unique identifier for the session", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Session history retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "404": { + "description": "Session not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "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}/name": { + "put": { + "tags": [ + "Session Management" + ], + "operationId": "update_session_name", + "parameters": [ + { + "name": "session_id", + "in": "path", + "description": "Unique identifier for the session", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSessionNameRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Session name updated successfully" + }, + "400": { + "description": "Bad request - Name too long (max 200 characters)" + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "404": { + "description": "Session not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, + "/sessions/{session_id}/user_recipe_values": { + "put": { + "tags": [ + "Session Management" + ], + "operationId": "update_session_user_recipe_values", + "parameters": [ + { + "name": "session_id", + "in": "path", + "description": "Unique identifier for the session", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSessionUserRecipeValuesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Session user recipe values updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSessionUserRecipeValuesResponse" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "404": { + "description": "Session not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, + "/status": { + "get": { + "tags": [ + "super::routes::status" + ], + "operationId": "status", + "responses": { + "200": { + "description": "ok", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Annotations": { + "type": "object", + "properties": { + "audience": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Role" + } + }, + "lastModified": { + "type": "string", + "format": "date-time" + }, + "priority": { + "type": "number" + } + } + }, + "Author": { + "type": "object", + "properties": { + "contact": { + "type": "string", + "nullable": true + }, + "metadata": { + "type": "string", + "nullable": true + } + } + }, + "AuthorRequest": { + "type": "object", + "properties": { + "contact": { + "type": "string", + "nullable": true + }, + "metadata": { + "type": "string", + "nullable": true + } + } + }, + "ChatRequest": { + "type": "object", + "required": [ + "messages", + "session_id" + ], + "properties": { + "messages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Message" + } + }, + "recipe_name": { + "type": "string", + "nullable": true + }, + "recipe_version": { + "type": "string", + "nullable": true + }, + "session_id": { + "type": "string" + } + } + }, + "ConfigKey": { + "type": "object", + "description": "Configuration key metadata for provider setup", + "required": [ + "name", + "required", + "secret", + "oauth_flow" + ], + "properties": { + "default": { + "type": "string", + "description": "Optional default value for the key", + "nullable": true + }, + "name": { + "type": "string", + "description": "The name of the configuration key (e.g., \"API_KEY\")" + }, + "oauth_flow": { + "type": "boolean", + "description": "Whether this key should be configured using OAuth device code flow\nWhen true, the provider's configure_oauth() method will be called instead of prompting for manual input" + }, + "required": { + "type": "boolean", + "description": "Whether this key is required for the provider to function" + }, + "secret": { + "type": "boolean", + "description": "Whether this key should be stored securely (e.g., in keychain)" + } + } + }, + "ConfigKeyQuery": { + "type": "object", + "required": [ + "key", + "is_secret" + ], + "properties": { + "is_secret": { + "type": "boolean" + }, + "key": { + "type": "string" + } + } + }, + "ConfigResponse": { + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "additionalProperties": {} + } + } + }, + "Content": { + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/RawTextContent" + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/RawImageContent" + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/RawEmbeddedResource" + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/RawAudioContent" + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/RawResource" + } + ] + } + ] + }, + "Conversation": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Message" + } + }, + "CreateRecipeRequest": { + "type": "object", + "required": [ + "session_id" + ], + "properties": { + "author": { + "allOf": [ + { + "$ref": "#/components/schemas/AuthorRequest" + } + ], + "nullable": true + }, + "session_id": { + "type": "string" + } + } + }, + "CreateRecipeResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "nullable": true + }, + "recipe": { + "allOf": [ + { + "$ref": "#/components/schemas/Recipe" + } + ], + "nullable": true + } + } + }, + "CreateScheduleRequest": { + "type": "object", + "required": [ + "id", + "recipe_source", + "cron" + ], + "properties": { + "cron": { + "type": "string" + }, + "execution_mode": { + "type": "string", + "nullable": true + }, + "id": { + "type": "string" + }, + "recipe_source": { + "type": "string" + } + } + }, + "DeclarativeProviderConfig": { + "type": "object", + "required": [ + "name", + "engine", + "display_name", + "api_key_env", + "base_url", + "models" + ], + "properties": { + "api_key_env": { + "type": "string" + }, + "base_url": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "display_name": { + "type": "string" + }, + "engine": { + "$ref": "#/components/schemas/ProviderEngine" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + }, + "models": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModelInfo" + } + }, + "name": { + "type": "string" + }, + "supports_streaming": { + "type": "boolean", + "nullable": true + }, + "timeout_seconds": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 0 + } + } + }, + "DecodeRecipeRequest": { + "type": "object", + "required": [ + "deeplink" + ], + "properties": { + "deeplink": { + "type": "string" + } + } + }, + "DecodeRecipeResponse": { + "type": "object", + "required": [ + "recipe" + ], + "properties": { + "recipe": { + "$ref": "#/components/schemas/Recipe" + } + } + }, + "DeleteRecipeRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + }, + "EmbeddedResource": { + "type": "object", + "required": [ + "resource" + ], + "properties": { + "_meta": { + "type": "object", + "additionalProperties": true + }, + "annotations": { + "anyOf": [ + { + "$ref": "#/components/schemas/Annotations" + }, + { + "type": "object" + } + ] + }, + "resource": { + "$ref": "#/components/schemas/ResourceContents" + } + } + }, + "EncodeRecipeRequest": { + "type": "object", + "required": [ + "recipe" + ], + "properties": { + "recipe": { + "$ref": "#/components/schemas/Recipe" + } + } + }, + "EncodeRecipeResponse": { + "type": "object", + "required": [ + "deeplink" + ], + "properties": { + "deeplink": { + "type": "string" + } + } + }, + "Envs": { + "type": "object", + "additionalProperties": { + "type": "string", + "description": "A map of environment variables to set, e.g. API_KEY -> some_secret, HOST -> host" + } + }, + "ErrorResponse": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + }, + "ExtensionConfig": { + "oneOf": [ + { + "type": "object", + "description": "Server-sent events client with a URI endpoint", + "required": [ + "name", + "description", + "uri", + "type" + ], + "properties": { + "available_tools": { + "type": "array", + "items": { + "type": "string" + } + }, + "bundled": { + "type": "boolean", + "nullable": true + }, + "description": { + "type": "string" + }, + "env_keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "envs": { + "$ref": "#/components/schemas/Envs" + }, + "name": { + "type": "string", + "description": "The name used to identify this extension" + }, + "timeout": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 0 + }, + "type": { + "type": "string", + "enum": [ + "sse" + ] + }, + "uri": { + "type": "string" + } + } + }, + { + "type": "object", + "description": "Standard I/O client with command and arguments", + "required": [ + "name", + "description", + "cmd", + "args", + "type" + ], + "properties": { + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "available_tools": { + "type": "array", + "items": { + "type": "string" + } + }, + "bundled": { + "type": "boolean", + "nullable": true + }, + "cmd": { + "type": "string" + }, + "description": { + "type": "string" + }, + "env_keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "envs": { + "$ref": "#/components/schemas/Envs" + }, + "name": { + "type": "string", + "description": "The name used to identify this extension" + }, + "timeout": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 0 + }, + "type": { + "type": "string", + "enum": [ + "stdio" + ] + } + } + }, + { + "type": "object", + "description": "Built-in extension that is part of the bundled goose MCP server", + "required": [ + "name", + "description", + "type" + ], + "properties": { + "available_tools": { + "type": "array", + "items": { + "type": "string" + } + }, + "bundled": { + "type": "boolean", + "nullable": true + }, + "description": { + "type": "string" + }, + "display_name": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string", + "description": "The name used to identify this extension" + }, + "timeout": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 0 + }, + "type": { + "type": "string", + "enum": [ + "builtin" + ] + } + } + }, + { + "type": "object", + "description": "Platform extensions that have direct access to the agent etc and run in the agent process", + "required": [ + "name", + "description", + "type" + ], + "properties": { + "available_tools": { + "type": "array", + "items": { + "type": "string" + } + }, + "bundled": { + "type": "boolean", + "nullable": true + }, + "description": { + "type": "string" + }, + "name": { + "type": "string", + "description": "The name used to identify this extension" + }, + "type": { + "type": "string", + "enum": [ + "platform" + ] + } + } + }, + { + "type": "object", + "description": "Streamable HTTP client with a URI endpoint using MCP Streamable HTTP specification", + "required": [ + "name", + "description", + "uri", + "type" + ], + "properties": { + "available_tools": { + "type": "array", + "items": { + "type": "string" + } + }, + "bundled": { + "type": "boolean", + "nullable": true + }, + "description": { + "type": "string" + }, + "env_keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "envs": { + "$ref": "#/components/schemas/Envs" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "name": { + "type": "string", + "description": "The name used to identify this extension" + }, + "timeout": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 0 + }, + "type": { + "type": "string", + "enum": [ + "streamable_http" + ] + }, + "uri": { + "type": "string" + } + } + }, + { + "type": "object", + "description": "Frontend-provided tools that will be called through the frontend", + "required": [ + "name", + "description", + "tools", + "type" + ], + "properties": { + "available_tools": { + "type": "array", + "items": { + "type": "string" + } + }, + "bundled": { + "type": "boolean", + "nullable": true + }, + "description": { + "type": "string" + }, + "instructions": { + "type": "string", + "description": "Instructions for how to use these tools", + "nullable": true + }, + "name": { + "type": "string", + "description": "The name used to identify this extension" + }, + "tools": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Tool" + }, + "description": "The tools provided by the frontend" + }, + "type": { + "type": "string", + "enum": [ + "frontend" + ] + } + } + }, + { + "type": "object", + "description": "Inline Python code that will be executed using uvx", + "required": [ + "name", + "description", + "code", + "type" + ], + "properties": { + "available_tools": { + "type": "array", + "items": { + "type": "string" + } + }, + "code": { + "type": "string", + "description": "The Python code to execute" + }, + "dependencies": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Python package dependencies required by this extension", + "nullable": true + }, + "description": { + "type": "string" + }, + "name": { + "type": "string", + "description": "The name used to identify this extension" + }, + "timeout": { + "type": "integer", + "format": "int64", + "description": "Timeout in seconds", + "nullable": true, + "minimum": 0 + }, + "type": { + "type": "string", + "enum": [ + "inline_python" + ] + } + } + } + ], + "description": "Represents the different types of MCP extensions that can be added to the manager", + "discriminator": { + "propertyName": "type" + } + }, + "ExtensionData": { + "type": "object", + "description": "Extension data containing all extension states\nKeys are in format \"extension_name.version\" (e.g., \"todo.v0\")", + "additionalProperties": {} + }, + "ExtensionEntry": { + "allOf": [ + { + "$ref": "#/components/schemas/ExtensionConfig" + }, + { + "type": "object", + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + } + } + } + ] + }, + "ExtensionQuery": { + "type": "object", + "required": [ + "name", + "config", + "enabled" + ], + "properties": { + "config": { + "$ref": "#/components/schemas/ExtensionConfig" + }, + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + }, + "ExtensionResponse": { + "type": "object", + "required": [ + "extensions" + ], + "properties": { + "extensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExtensionEntry" + } + } + } + }, + "FrontendToolRequest": { + "type": "object", + "required": [ + "id", + "toolCall" + ], + "properties": { + "id": { + "type": "string" + }, + "toolCall": { + "type": "object" + } + } + }, + "GetToolsQuery": { + "type": "object", + "required": [ + "session_id" + ], + "properties": { + "extension_name": { + "type": "string", + "nullable": true + }, + "session_id": { + "type": "string" + } + } + }, + "Icon": { + "type": "object", + "required": [ + "src" + ], + "properties": { + "mimeType": { + "type": "string" + }, + "sizes": { + "type": "string" + }, + "src": { + "type": "string" + } + } + }, + "ImageContent": { + "type": "object", + "required": [ + "data", + "mimeType" + ], + "properties": { + "_meta": { + "type": "object", + "additionalProperties": true + }, + "annotations": { + "anyOf": [ + { + "$ref": "#/components/schemas/Annotations" + }, + { + "type": "object" + } + ] + }, + "data": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + } + }, + "ImportSessionRequest": { + "type": "object", + "required": [ + "json" + ], + "properties": { + "json": { + "type": "string" + } + } + }, + "InspectJobResponse": { + "type": "object", + "properties": { + "processStartTime": { + "type": "string", + "nullable": true + }, + "runningDurationSeconds": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "sessionId": { + "type": "string", + "nullable": true + } + } + }, + "JsonObject": { + "type": "object", + "additionalProperties": true + }, + "KillJobResponse": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + }, + "ListRecipeResponse": { + "type": "object", + "required": [ + "recipe_manifest_responses" + ], + "properties": { + "recipe_manifest_responses": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecipeManifestResponse" + } + } + } + }, + "ListSchedulesResponse": { + "type": "object", + "required": [ + "jobs" + ], + "properties": { + "jobs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduledJob" + } + } + } + }, + "LoadedProvider": { + "type": "object", + "required": [ + "config", + "is_editable" + ], + "properties": { + "config": { + "$ref": "#/components/schemas/DeclarativeProviderConfig" + }, + "is_editable": { + "type": "boolean" + } + } + }, + "Message": { + "type": "object", + "description": "A message to or from an LLM", + "required": [ + "role", + "created", + "content", + "metadata" + ], + "properties": { + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessageContent" + } + }, + "created": { + "type": "integer", + "format": "int64" + }, + "id": { + "type": "string", + "nullable": true + }, + "metadata": { + "$ref": "#/components/schemas/MessageMetadata" + }, + "role": { + "$ref": "#/components/schemas/Role" + } + } + }, + "MessageContent": { + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/TextContent" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/ImageContent" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ] + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/ToolRequest" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "toolRequest" + ] + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/ToolResponse" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "toolResponse" + ] + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/ToolConfirmationRequest" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "toolConfirmationRequest" + ] + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/FrontendToolRequest" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "frontendToolRequest" + ] + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/ThinkingContent" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "thinking" + ] + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/RedactedThinkingContent" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "redactedThinking" + ] + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/SystemNotificationContent" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "systemNotification" + ] + } + } + } + ] + } + ], + "description": "Content passed inside a message, which can be both simple content and tool content", + "discriminator": { + "propertyName": "type" + } + }, + "MessageMetadata": { + "type": "object", + "description": "Metadata for message visibility", + "required": [ + "userVisible", + "agentVisible" + ], + "properties": { + "agentVisible": { + "type": "boolean", + "description": "Whether the message should be included in the agent's context window" + }, + "userVisible": { + "type": "boolean", + "description": "Whether the message should be visible to the user in the UI" + } + } + }, + "ModelInfo": { + "type": "object", + "description": "Information about a model's capabilities", + "required": [ + "name", + "context_limit" + ], + "properties": { + "context_limit": { + "type": "integer", + "description": "The maximum context length this model supports", + "minimum": 0 + }, + "currency": { + "type": "string", + "description": "Currency for the costs (default: \"$\")", + "nullable": true + }, + "input_token_cost": { + "type": "number", + "format": "double", + "description": "Cost per token for input (optional)", + "nullable": true + }, + "name": { + "type": "string", + "description": "The name of the model" + }, + "output_token_cost": { + "type": "number", + "format": "double", + "description": "Cost per token for output (optional)", + "nullable": true + }, + "supports_cache_control": { + "type": "boolean", + "description": "Whether this model supports cache control", + "nullable": true + } + } + }, + "ParseRecipeRequest": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "content": { + "type": "string" + } + } + }, + "ParseRecipeResponse": { + "type": "object", + "required": [ + "recipe" + ], + "properties": { + "recipe": { + "$ref": "#/components/schemas/Recipe" + } + } + }, + "PermissionConfirmationRequest": { + "type": "object", + "required": [ + "id", + "action", + "session_id" + ], + "properties": { + "action": { + "type": "string" + }, + "id": { + "type": "string" + }, + "principal_type": { + "$ref": "#/components/schemas/PrincipalType" + }, + "session_id": { + "type": "string" + } + } + }, + "PermissionLevel": { + "type": "string", + "description": "Enum representing the possible permission levels for a tool.", + "enum": [ + "always_allow", + "ask_before", + "never_allow" + ] + }, + "PrincipalType": { + "type": "string", + "enum": [ + "Extension", + "Tool" + ] + }, + "ProviderDetails": { + "type": "object", + "required": [ + "name", + "metadata", + "is_configured", + "provider_type" + ], + "properties": { + "is_configured": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/ProviderMetadata" + }, + "name": { + "type": "string" + }, + "provider_type": { + "$ref": "#/components/schemas/ProviderType" + } + } + }, + "ProviderEngine": { + "type": "string", + "enum": [ + "openai", + "ollama", + "anthropic" + ] + }, + "ProviderMetadata": { + "type": "object", + "description": "Metadata about a provider's configuration requirements and capabilities", + "required": [ + "name", + "display_name", + "description", + "default_model", + "known_models", + "model_doc_link", + "config_keys" + ], + "properties": { + "config_keys": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigKey" + }, + "description": "Required configuration keys" + }, + "default_model": { + "type": "string", + "description": "The default/recommended model for this provider" + }, + "description": { + "type": "string", + "description": "Description of the provider's capabilities" + }, + "display_name": { + "type": "string", + "description": "Display name for the provider in UIs" + }, + "known_models": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModelInfo" + }, + "description": "A list of currently known models with their capabilities" + }, + "model_doc_link": { + "type": "string", + "description": "Link to the docs where models can be found" + }, + "name": { + "type": "string", + "description": "The unique identifier for this provider" + } + } + }, + "ProviderType": { + "type": "string", + "enum": [ + "Preferred", + "Builtin", + "Declarative", + "Custom" + ] + }, + "ProvidersResponse": { + "type": "object", + "required": [ + "providers" + ], + "properties": { + "providers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderDetails" + } + } + } + }, + "RawAudioContent": { + "type": "object", + "required": [ + "data", + "mimeType" + ], + "properties": { + "data": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + } + }, + "RawEmbeddedResource": { + "type": "object", + "required": [ + "resource" + ], + "properties": { + "_meta": { + "type": "object", + "additionalProperties": true + }, + "resource": { + "$ref": "#/components/schemas/ResourceContents" + } + } + }, + "RawImageContent": { + "type": "object", + "required": [ + "data", + "mimeType" + ], + "properties": { + "_meta": { + "type": "object", + "additionalProperties": true + }, + "data": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + } + }, + "RawResource": { + "type": "object", + "required": [ + "uri", + "name" + ], + "properties": { + "description": { + "type": "string" + }, + "icons": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Icon" + } + }, + "mimeType": { + "type": "string" + }, + "name": { + "type": "string" + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "title": { + "type": "string" + }, + "uri": { + "type": "string" + } + } + }, + "RawTextContent": { + "type": "object", + "required": [ + "text" + ], + "properties": { + "_meta": { + "type": "object", + "additionalProperties": true + }, + "text": { + "type": "string" + } + } + }, + "Recipe": { + "type": "object", + "required": [ + "title", + "description" + ], + "properties": { + "activities": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "author": { + "allOf": [ + { + "$ref": "#/components/schemas/Author" + } + ], + "nullable": true + }, + "context": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "description": { + "type": "string" + }, + "extensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExtensionConfig" + }, + "nullable": true + }, + "instructions": { + "type": "string", + "nullable": true + }, + "parameters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecipeParameter" + }, + "nullable": true + }, + "prompt": { + "type": "string", + "nullable": true + }, + "response": { + "allOf": [ + { + "$ref": "#/components/schemas/Response" + } + ], + "nullable": true + }, + "retry": { + "allOf": [ + { + "$ref": "#/components/schemas/RetryConfig" + } + ], + "nullable": true + }, + "settings": { + "allOf": [ + { + "$ref": "#/components/schemas/Settings" + } + ], + "nullable": true + }, + "sub_recipes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SubRecipe" + }, + "nullable": true + }, + "title": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "RecipeManifestResponse": { + "type": "object", + "required": [ + "recipe", + "lastModified", + "id" + ], + "properties": { + "id": { + "type": "string" + }, + "lastModified": { + "type": "string" + }, + "recipe": { + "$ref": "#/components/schemas/Recipe" + } + } + }, + "RecipeParameter": { + "type": "object", + "required": [ + "key", + "input_type", + "requirement", + "description" + ], + "properties": { + "default": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string" + }, + "input_type": { + "$ref": "#/components/schemas/RecipeParameterInputType" + }, + "key": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "requirement": { + "$ref": "#/components/schemas/RecipeParameterRequirement" + } + } + }, + "RecipeParameterInputType": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "date", + "file", + "select" + ] + }, + "RecipeParameterRequirement": { + "type": "string", + "enum": [ + "required", + "optional", + "user_prompt" + ] + }, + "RedactedThinkingContent": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "string" + } + } + }, + "ResourceContents": { + "anyOf": [ + { + "type": "object", + "required": [ + "uri", + "text" + ], + "properties": { + "_meta": { + "type": "object", + "additionalProperties": true + }, + "mimeType": { + "type": "string" + }, + "text": { + "type": "string" + }, + "uri": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "uri", + "blob" + ], + "properties": { + "_meta": { + "type": "object", + "additionalProperties": true + }, + "blob": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "uri": { + "type": "string" + } + } + } + ] + }, + "Response": { + "type": "object", + "properties": { + "json_schema": { + "nullable": true + } + } + }, + "ResumeAgentRequest": { + "type": "object", + "required": [ + "session_id", + "load_model_and_extensions" + ], + "properties": { + "load_model_and_extensions": { + "type": "boolean" + }, + "session_id": { + "type": "string" + } + } + }, + "RetryConfig": { + "type": "object", + "description": "Configuration for retry logic in recipe execution", + "required": [ + "max_retries", + "checks" + ], + "properties": { + "checks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SuccessCheck" + }, + "description": "List of success checks to validate recipe completion" + }, + "max_retries": { + "type": "integer", + "format": "int32", + "description": "Maximum number of retry attempts before giving up", + "minimum": 0 + }, + "on_failure": { + "type": "string", + "description": "Optional shell command to run on failure for cleanup", + "nullable": true + }, + "on_failure_timeout_seconds": { + "type": "integer", + "format": "int64", + "description": "Timeout in seconds for on_failure commands (default: 600 seconds)", + "nullable": true, + "minimum": 0 + }, + "timeout_seconds": { + "type": "integer", + "format": "int64", + "description": "Timeout in seconds for individual shell commands (default: 300 seconds)", + "nullable": true, + "minimum": 0 + } + } + }, + "Role": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "RunNowResponse": { + "type": "object", + "required": [ + "session_id" + ], + "properties": { + "session_id": { + "type": "string" + } + } + }, + "SaveRecipeRequest": { + "type": "object", + "required": [ + "recipe" + ], + "properties": { + "id": { + "type": "string", + "nullable": true + }, + "recipe": { + "$ref": "#/components/schemas/Recipe" + } + } + }, + "SaveRecipeResponse": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + }, + "ScanRecipeRequest": { + "type": "object", + "required": [ + "recipe" + ], + "properties": { + "recipe": { + "$ref": "#/components/schemas/Recipe" + } + } + }, + "ScanRecipeResponse": { + "type": "object", + "required": [ + "has_security_warnings" + ], + "properties": { + "has_security_warnings": { + "type": "boolean" + } + } + }, + "ScheduledJob": { + "type": "object", + "required": [ + "id", + "source", + "cron" + ], + "properties": { + "cron": { + "type": "string" + }, + "current_session_id": { + "type": "string", + "nullable": true + }, + "currently_running": { + "type": "boolean" + }, + "execution_mode": { + "type": "string", + "nullable": true + }, + "id": { + "type": "string" + }, + "last_run": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "paused": { + "type": "boolean" + }, + "process_start_time": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "source": { + "type": "string" + } + } + }, + "Session": { + "type": "object", + "required": [ + "id", + "working_dir", + "name", + "created_at", + "updated_at", + "extension_data", + "message_count" + ], + "properties": { + "accumulated_input_tokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "accumulated_output_tokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "accumulated_total_tokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "conversation": { + "allOf": [ + { + "$ref": "#/components/schemas/Conversation" + } + ], + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "extension_data": { + "$ref": "#/components/schemas/ExtensionData" + }, + "id": { + "type": "string" + }, + "input_tokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "message_count": { + "type": "integer", + "minimum": 0 + }, + "name": { + "type": "string" + }, + "output_tokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "recipe": { + "allOf": [ + { + "$ref": "#/components/schemas/Recipe" + } + ], + "nullable": true + }, + "schedule_id": { + "type": "string", + "nullable": true + }, + "total_tokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_recipe_values": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + }, + "user_set_name": { + "type": "boolean" + }, + "working_dir": { + "type": "string" + } + } + }, + "SessionDisplayInfo": { + "type": "object", + "required": [ + "id", + "name", + "createdAt", + "workingDir", + "messageCount" + ], + "properties": { + "accumulatedInputTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "accumulatedOutputTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "accumulatedTotalTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "createdAt": { + "type": "string" + }, + "id": { + "type": "string" + }, + "inputTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "messageCount": { + "type": "integer", + "minimum": 0 + }, + "name": { + "type": "string" + }, + "outputTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "scheduleId": { + "type": "string", + "nullable": true + }, + "totalTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "workingDir": { + "type": "string" + } + } + }, + "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" + } + } + }, + "SessionsQuery": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "Settings": { + "type": "object", + "properties": { + "goose_model": { + "type": "string", + "nullable": true + }, + "goose_provider": { + "type": "string", + "nullable": true + }, + "temperature": { + "type": "number", + "format": "float", + "nullable": true + } + } + }, + "SetupResponse": { + "type": "object", + "required": [ + "success", + "message" + ], + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, + "StartAgentRequest": { + "type": "object", + "required": [ + "working_dir" + ], + "properties": { + "recipe": { + "allOf": [ + { + "$ref": "#/components/schemas/Recipe" + } + ], + "nullable": true + }, + "recipe_deeplink": { + "type": "string", + "nullable": true + }, + "recipe_id": { + "type": "string", + "nullable": true + }, + "working_dir": { + "type": "string" + } + } + }, + "SubRecipe": { + "type": "object", + "required": [ + "name", + "path" + ], + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "sequential_when_repeated": { + "type": "boolean" + }, + "values": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + } + } + }, + "SuccessCheck": { + "oneOf": [ + { + "type": "object", + "description": "Execute a shell command and check its exit status", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string", + "description": "The shell command to execute" + }, + "type": { + "type": "string", + "enum": [ + "Shell" + ] + } + } + } + ], + "description": "A single success check to validate recipe completion", + "discriminator": { + "propertyName": "type" + } + }, + "SystemNotificationContent": { + "type": "object", + "required": [ + "notificationType", + "msg" + ], + "properties": { + "msg": { + "type": "string" + }, + "notificationType": { + "$ref": "#/components/schemas/SystemNotificationType" + } + } + }, + "SystemNotificationType": { + "type": "string", + "enum": [ + "thinkingMessage", + "inlineMessage" + ] + }, + "TextContent": { + "type": "object", + "required": [ + "text" + ], + "properties": { + "_meta": { + "type": "object", + "additionalProperties": true + }, + "annotations": { + "anyOf": [ + { + "$ref": "#/components/schemas/Annotations" + }, + { + "type": "object" + } + ] + }, + "text": { + "type": "string" + } + } + }, + "ThinkingContent": { + "type": "object", + "required": [ + "thinking", + "signature" + ], + "properties": { + "signature": { + "type": "string" + }, + "thinking": { + "type": "string" + } + } + }, + "TokenState": { + "type": "object", + "properties": { + "accumulatedInputTokens": { + "type": "integer", + "format": "int32", + "description": "Accumulated token counts across all turns", + "nullable": true + }, + "accumulatedOutputTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "accumulatedTotalTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "inputTokens": { + "type": "integer", + "format": "int32", + "description": "Current turn token counts", + "nullable": true + }, + "outputTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "totalTokens": { + "type": "integer", + "format": "int32", + "nullable": true + } + } + }, + "Tool": { + "type": "object", + "required": [ + "name", + "inputSchema" + ], + "properties": { + "annotations": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolAnnotations" + }, + { + "type": "object" + } + ] + }, + "description": { + "type": "string" + }, + "icons": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Icon" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": true + }, + "name": { + "type": "string" + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "title": { + "type": "string" + } + } + }, + "ToolAnnotations": { + "type": "object", + "properties": { + "destructiveHint": { + "type": "boolean" + }, + "idempotentHint": { + "type": "boolean" + }, + "openWorldHint": { + "type": "boolean" + }, + "readOnlyHint": { + "type": "boolean" + }, + "title": { + "type": "string" + } + } + }, + "ToolConfirmationRequest": { + "type": "object", + "required": [ + "id", + "toolName", + "arguments" + ], + "properties": { + "arguments": { + "$ref": "#/components/schemas/JsonObject" + }, + "id": { + "type": "string" + }, + "prompt": { + "type": "string", + "nullable": true + }, + "toolName": { + "type": "string" + } + } + }, + "ToolInfo": { + "type": "object", + "description": "Information about the tool used for building prompts", + "required": [ + "name", + "description", + "parameters" + ], + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "parameters": { + "type": "array", + "items": { + "type": "string" + } + }, + "permission": { + "allOf": [ + { + "$ref": "#/components/schemas/PermissionLevel" + } + ], + "nullable": true + } + } + }, + "ToolPermission": { + "type": "object", + "required": [ + "tool_name", + "permission" + ], + "properties": { + "permission": { + "$ref": "#/components/schemas/PermissionLevel" + }, + "tool_name": { + "type": "string" + } + } + }, + "ToolRequest": { + "type": "object", + "required": [ + "id", + "toolCall" + ], + "properties": { + "id": { + "type": "string" + }, + "toolCall": { + "type": "object" + } + } + }, + "ToolResponse": { + "type": "object", + "required": [ + "id", + "toolResult" + ], + "properties": { + "id": { + "type": "string" + }, + "toolResult": { + "type": "object" + } + } + }, + "UpdateCustomProviderRequest": { + "type": "object", + "required": [ + "engine", + "display_name", + "api_url", + "api_key", + "models" + ], + "properties": { + "api_key": { + "type": "string" + }, + "api_url": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "engine": { + "type": "string" + }, + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "supports_streaming": { + "type": "boolean", + "nullable": true + } + } + }, + "UpdateFromSessionRequest": { + "type": "object", + "required": [ + "session_id" + ], + "properties": { + "session_id": { + "type": "string" + } + } + }, + "UpdateProviderRequest": { + "type": "object", + "required": [ + "provider", + "session_id" + ], + "properties": { + "model": { + "type": "string", + "nullable": true + }, + "provider": { + "type": "string" + }, + "session_id": { + "type": "string" + } + } + }, + "UpdateRouterToolSelectorRequest": { + "type": "object", + "required": [ + "session_id" + ], + "properties": { + "session_id": { + "type": "string" + } + } + }, + "UpdateScheduleRequest": { + "type": "object", + "required": [ + "cron" + ], + "properties": { + "cron": { + "type": "string" + } + } + }, + "UpdateSessionNameRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Updated name for the session (max 200 characters)" + } + } + }, + "UpdateSessionUserRecipeValuesRequest": { + "type": "object", + "required": [ + "userRecipeValues" + ], + "properties": { + "userRecipeValues": { + "type": "object", + "description": "Recipe parameter values entered by the user", + "additionalProperties": { + "type": "string" + } + } + } + }, + "UpdateSessionUserRecipeValuesResponse": { + "type": "object", + "required": [ + "recipe" + ], + "properties": { + "recipe": { + "$ref": "#/components/schemas/Recipe" + } + } + }, + "UpsertConfigQuery": { + "type": "object", + "required": [ + "key", + "value", + "is_secret" + ], + "properties": { + "is_secret": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "value": {} + } + }, + "UpsertPermissionsQuery": { + "type": "object", + "required": [ + "tool_permissions" + ], + "properties": { + "tool_permissions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ToolPermission" + } + } + } + } + } + } +} diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 319bf98c82cf..8f274df7f3e2 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -4274,6 +4274,43 @@ } } }, + "TokenState": { + "type": "object", + "properties": { + "accumulatedInputTokens": { + "type": "integer", + "format": "int32", + "description": "Accumulated token counts across all turns", + "nullable": true + }, + "accumulatedOutputTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "accumulatedTotalTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "inputTokens": { + "type": "integer", + "format": "int32", + "description": "Current turn token counts", + "nullable": true + }, + "outputTokens": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "totalTokens": { + "type": "integer", + "format": "int32", + "nullable": true + } + } + }, "Tool": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index a33c06ecb100..31effd40b7cb 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -753,6 +753,21 @@ export type ThinkingContent = { thinking: string; }; +export type TokenState = { + /** + * Accumulated token counts across all turns + */ + accumulatedInputTokens?: number | null; + accumulatedOutputTokens?: number | null; + accumulatedTotalTokens?: number | null; + /** + * Current turn token counts + */ + inputTokens?: number | null; + outputTokens?: number | null; + totalTokens?: number | null; +}; + export type Tool = { annotations?: ToolAnnotations | { [key: string]: unknown; diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index b4440852405e..67090686db2c 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -443,12 +443,12 @@ function BaseChatContent({ commandHistory={commandHistory} initialValue={input || ''} setView={setView} - totalTokens={tokenState?.total_tokens ?? sessionTokenCount} + totalTokens={tokenState?.totalTokens ?? sessionTokenCount} accumulatedInputTokens={ - tokenState?.accumulated_input_tokens ?? sessionInputTokens ?? localInputTokens + tokenState?.accumulatedInputTokens ?? sessionInputTokens ?? localInputTokens } accumulatedOutputTokens={ - tokenState?.accumulated_output_tokens ?? sessionOutputTokens ?? localOutputTokens + tokenState?.accumulatedOutputTokens ?? sessionOutputTokens ?? localOutputTokens } droppedFiles={droppedFiles} onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index cd0e22bd966f..bd1facba8685 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -280,14 +280,12 @@ function BaseChatContent({ //commandHistory={commandHistory} initialValue={initialPrompt} setView={setView} - totalTokens={tokenState?.total_tokens ?? session?.total_tokens ?? undefined} + totalTokens={tokenState?.totalTokens ?? session?.total_tokens ?? undefined} accumulatedInputTokens={ - tokenState?.accumulated_input_tokens ?? session?.accumulated_input_tokens ?? undefined + tokenState?.accumulatedInputTokens ?? session?.accumulated_input_tokens ?? undefined } accumulatedOutputTokens={ - tokenState?.accumulated_output_tokens ?? - session?.accumulated_output_tokens ?? - undefined + tokenState?.accumulatedOutputTokens ?? session?.accumulated_output_tokens ?? undefined } droppedFiles={droppedFiles} onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index c863b43e0856..3559e9bc8ff9 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { ChatState } from '../types/chatState'; -import { Conversation, Message, resumeAgent, Session } from '../api'; +import { Conversation, Message, resumeAgent, Session, TokenState } from '../api'; import { getApiUrl } from '../config'; import { createUserMessage, getCompactingMessage, getThinkingMessage } from '../types/message'; @@ -40,15 +40,6 @@ const log = { type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; -interface TokenState { - input_tokens?: number | null; - output_tokens?: number | null; - total_tokens?: number | null; - accumulated_input_tokens?: number | null; - accumulated_output_tokens?: number | null; - accumulated_total_tokens?: number | null; -} - interface NotificationEvent { type: 'Notification'; request_id: string; diff --git a/ui/desktop/src/hooks/useMessageStream.ts b/ui/desktop/src/hooks/useMessageStream.ts index 97d87dc21f06..38ef8554249e 100644 --- a/ui/desktop/src/hooks/useMessageStream.ts +++ b/ui/desktop/src/hooks/useMessageStream.ts @@ -6,7 +6,7 @@ import { getCompactingMessage, hasCompletedToolCalls, } from '../types/message'; -import { Conversation, Message, Role } from '../api'; +import { Conversation, Message, Role, TokenState } from '../api'; import { getSession, Session } from '../api'; import { ChatState } from '../types/chatState'; @@ -33,15 +33,6 @@ export interface NotificationEvent { }; } -interface TokenState { - input_tokens?: number | null; - output_tokens?: number | null; - total_tokens?: number | null; - accumulated_input_tokens?: number | null; - accumulated_output_tokens?: number | null; - accumulated_total_tokens?: number | null; -} - // Event types for SSE stream type MessageEvent = | { type: 'Message'; message: Message; token_state?: TokenState | null } From 8dad9e98df18d8bd785065e5bffeb291f400e731 Mon Sep 17 00:00:00 2001 From: David Katz Date: Tue, 28 Oct 2025 15:51:59 -0400 Subject: [PATCH 51/55] rm openapi --- openapi.json | 4635 -------------------------------------------------- 1 file changed, 4635 deletions(-) delete mode 100644 openapi.json diff --git a/openapi.json b/openapi.json deleted file mode 100644 index a438461e36c3..000000000000 --- a/openapi.json +++ /dev/null @@ -1,4635 +0,0 @@ - Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.69s - Running `target/debug/generate_schema` -Successfully generated OpenAPI schema at /Users/dkatz/git/goose3/goose/ui/desktop/openapi.json -{ - "openapi": "3.0.3", - "info": { - "title": "goose-server", - "description": "An AI agent", - "contact": { - "name": "Block", - "email": "ai-oss-tools@block.xyz" - }, - "license": { - "name": "Apache-2.0" - }, - "version": "1.11.0" - }, - "paths": { - "/agent/resume": { - "post": { - "tags": [ - "super::routes::agent" - ], - "operationId": "resume_agent", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ResumeAgentRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Agent started successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Session" - } - } - } - }, - "400": { - "description": "Bad request - invalid working directory" - }, - "401": { - "description": "Unauthorized - invalid secret key" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/agent/start": { - "post": { - "tags": [ - "super::routes::agent" - ], - "operationId": "start_agent", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StartAgentRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Agent started successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Session" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "Unauthorized - invalid secret key" - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/agent/tools": { - "get": { - "tags": [ - "super::routes::agent" - ], - "operationId": "get_tools", - "parameters": [ - { - "name": "extension_name", - "in": "query", - "description": "Optional extension name to filter tools", - "required": false, - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "session_id", - "in": "query", - "description": "Required session ID to scope tools to a specific session", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Tools retrieved successfully", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ToolInfo" - } - } - } - } - }, - "401": { - "description": "Unauthorized - invalid secret key" - }, - "424": { - "description": "Agent not initialized" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/agent/update_from_session": { - "post": { - "tags": [ - "super::routes::agent" - ], - "operationId": "update_from_session", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateFromSessionRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Update agent from session data successfully" - }, - "401": { - "description": "Unauthorized - invalid secret key" - }, - "424": { - "description": "Agent not initialized" - } - } - } - }, - "/agent/update_provider": { - "post": { - "tags": [ - "super::routes::agent" - ], - "operationId": "update_agent_provider", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateProviderRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Provider updated successfully" - }, - "400": { - "description": "Bad request - missing or invalid parameters" - }, - "401": { - "description": "Unauthorized - invalid secret key" - }, - "424": { - "description": "Agent not initialized" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/agent/update_router_tool_selector": { - "post": { - "tags": [ - "super::routes::agent" - ], - "operationId": "update_router_tool_selector", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateRouterToolSelectorRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Tool selection strategy updated successfully", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } - }, - "401": { - "description": "Unauthorized - invalid secret key" - }, - "424": { - "description": "Agent not initialized" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/config": { - "get": { - "tags": [ - "super::routes::config_management" - ], - "operationId": "read_all_config", - "responses": { - "200": { - "description": "All configuration values retrieved successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConfigResponse" - } - } - } - } - } - } - }, - "/config/backup": { - "post": { - "tags": [ - "super::routes::config_management" - ], - "operationId": "backup_config", - "responses": { - "200": { - "description": "Config file backed up", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/config/custom-providers": { - "post": { - "tags": [ - "super::routes::config_management" - ], - "operationId": "create_custom_provider", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateCustomProviderRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Custom provider created successfully", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } - }, - "400": { - "description": "Invalid request" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/config/custom-providers/{id}": { - "get": { - "tags": [ - "super::routes::config_management" - ], - "operationId": "get_custom_provider", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Custom provider retrieved successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoadedProvider" - } - } - } - }, - "404": { - "description": "Provider not found" - }, - "500": { - "description": "Internal server error" - } - } - }, - "put": { - "tags": [ - "super::routes::config_management" - ], - "operationId": "update_custom_provider", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateCustomProviderRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Custom provider updated successfully", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } - }, - "404": { - "description": "Provider not found" - }, - "500": { - "description": "Internal server error" - } - } - }, - "delete": { - "tags": [ - "super::routes::config_management" - ], - "operationId": "remove_custom_provider", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Custom provider removed successfully", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } - }, - "404": { - "description": "Provider not found" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/config/extensions": { - "get": { - "tags": [ - "super::routes::config_management" - ], - "operationId": "get_extensions", - "responses": { - "200": { - "description": "All extensions retrieved successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExtensionResponse" - } - } - } - }, - "500": { - "description": "Internal server error" - } - } - }, - "post": { - "tags": [ - "super::routes::config_management" - ], - "operationId": "add_extension", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExtensionQuery" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Extension added or updated successfully", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } - }, - "400": { - "description": "Invalid request" - }, - "422": { - "description": "Could not serialize config.yaml" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/config/extensions/{name}": { - "delete": { - "tags": [ - "super::routes::config_management" - ], - "operationId": "remove_extension", - "parameters": [ - { - "name": "name", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Extension removed successfully", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } - }, - "404": { - "description": "Extension not found" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/config/init": { - "post": { - "tags": [ - "super::routes::config_management" - ], - "operationId": "init_config", - "responses": { - "200": { - "description": "Config initialization check completed", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/config/permissions": { - "post": { - "tags": [ - "super::routes::config_management" - ], - "operationId": "upsert_permissions", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpsertPermissionsQuery" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Permission update completed", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } - }, - "400": { - "description": "Invalid request" - } - } - } - }, - "/config/providers": { - "get": { - "tags": [ - "super::routes::config_management" - ], - "operationId": "providers", - "responses": { - "200": { - "description": "All configuration values retrieved successfully", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProviderDetails" - } - } - } - } - } - } - } - }, - "/config/providers/{name}/models": { - "get": { - "tags": [ - "super::routes::config_management" - ], - "operationId": "get_provider_models", - "parameters": [ - { - "name": "name", - "in": "path", - "description": "Provider name (e.g., openai)", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Models fetched successfully", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - }, - "400": { - "description": "Unknown provider, provider not configured, or authentication error" - }, - "429": { - "description": "Rate limit exceeded" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/config/read": { - "post": { - "tags": [ - "super::routes::config_management" - ], - "operationId": "read_config", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConfigKeyQuery" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Configuration value retrieved successfully", - "content": { - "application/json": { - "schema": {} - } - } - }, - "500": { - "description": "Unable to get the configuration value" - } - } - } - }, - "/config/recover": { - "post": { - "tags": [ - "super::routes::config_management" - ], - "operationId": "recover_config", - "responses": { - "200": { - "description": "Config recovery attempted", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/config/remove": { - "post": { - "tags": [ - "super::routes::config_management" - ], - "operationId": "remove_config", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConfigKeyQuery" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Configuration value removed successfully", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } - }, - "404": { - "description": "Configuration key not found" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/config/upsert": { - "post": { - "tags": [ - "super::routes::config_management" - ], - "operationId": "upsert_config", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpsertConfigQuery" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Configuration value upserted successfully", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/config/validate": { - "get": { - "tags": [ - "super::routes::config_management" - ], - "operationId": "validate_config", - "responses": { - "200": { - "description": "Config validation result", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } - }, - "422": { - "description": "Config file is corrupted" - } - } - } - }, - "/confirm": { - "post": { - "tags": [ - "super::routes::reply" - ], - "operationId": "confirm_permission", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PermissionConfirmationRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Permission action is confirmed", - "content": { - "application/json": { - "schema": {} - } - } - }, - "401": { - "description": "Unauthorized - invalid secret key" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/diagnostics/{session_id}": { - "get": { - "tags": [ - "super::routes::status" - ], - "operationId": "diagnostics", - "parameters": [ - { - "name": "session_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Diagnostics zip file", - "content": { - "application/zip": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "500": { - "description": "Failed to generate diagnostics" - } - } - } - }, - "/handle_openrouter": { - "post": { - "tags": [ - "super::routes::setup" - ], - "operationId": "start_openrouter_setup", - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetupResponse" - } - } - } - } - } - } - }, - "/handle_tetrate": { - "post": { - "tags": [ - "super::routes::setup" - ], - "operationId": "start_tetrate_setup", - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetupResponse" - } - } - } - } - } - } - }, - "/recipes/create": { - "post": { - "tags": [ - "Recipe Management" - ], - "operationId": "create_recipe", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateRecipeRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Recipe created successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateRecipeResponse" - } - } - } - }, - "400": { - "description": "Bad request" - }, - "412": { - "description": "Precondition failed - Agent not available" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/recipes/decode": { - "post": { - "tags": [ - "Recipe Management" - ], - "operationId": "decode_recipe", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DecodeRecipeRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Recipe decoded successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DecodeRecipeResponse" - } - } - } - }, - "400": { - "description": "Bad request" - } - } - } - }, - "/recipes/delete": { - "post": { - "tags": [ - "Recipe Management" - ], - "operationId": "delete_recipe", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeleteRecipeRequest" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Recipe deleted successfully" - }, - "401": { - "description": "Unauthorized - Invalid or missing API key" - }, - "404": { - "description": "Recipe not found" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/recipes/encode": { - "post": { - "tags": [ - "Recipe Management" - ], - "operationId": "encode_recipe", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EncodeRecipeRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Recipe encoded successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EncodeRecipeResponse" - } - } - } - }, - "400": { - "description": "Bad request" - } - } - } - }, - "/recipes/list": { - "get": { - "tags": [ - "Recipe Management" - ], - "operationId": "list_recipes", - "responses": { - "200": { - "description": "Get recipe list successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListRecipeResponse" - } - } - } - }, - "401": { - "description": "Unauthorized - Invalid or missing API key" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/recipes/parse": { - "post": { - "tags": [ - "Recipe Management" - ], - "operationId": "parse_recipe", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ParseRecipeRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Recipe parsed successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ParseRecipeResponse" - } - } - } - }, - "400": { - "description": "Bad request - Invalid recipe format", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/recipes/save": { - "post": { - "tags": [ - "Recipe Management" - ], - "operationId": "save_recipe", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SaveRecipeRequest" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Recipe saved to file successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SaveRecipeResponse" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/recipes/scan": { - "post": { - "tags": [ - "Recipe Management" - ], - "operationId": "scan_recipe", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ScanRecipeRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Recipe scanned successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ScanRecipeResponse" - } - } - } - } - } - } - }, - "/reply": { - "post": { - "tags": [ - "super::routes::reply" - ], - "operationId": "reply", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChatRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Streaming response initiated" - }, - "424": { - "description": "Agent not initialized" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/schedule/create": { - "post": { - "tags": [ - "schedule" - ], - "operationId": "create_schedule", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateScheduleRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Scheduled job created successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ScheduledJob" - } - } - } - }, - "400": { - "description": "Invalid cron expression or recipe file" - }, - "409": { - "description": "Job ID already exists" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/schedule/delete/{id}": { - "delete": { - "tags": [ - "schedule" - ], - "operationId": "delete_schedule", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "ID of the schedule to delete", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Scheduled job deleted successfully" - }, - "404": { - "description": "Scheduled job not found" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/schedule/list": { - "get": { - "tags": [ - "schedule" - ], - "operationId": "list_schedules", - "responses": { - "200": { - "description": "A list of scheduled jobs", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListSchedulesResponse" - } - } - } - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/schedule/{id}": { - "put": { - "tags": [ - "schedule" - ], - "operationId": "update_schedule", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "ID of the schedule to update", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateScheduleRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Scheduled job updated successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ScheduledJob" - } - } - } - }, - "400": { - "description": "Cannot update a currently running job or invalid request" - }, - "404": { - "description": "Scheduled job not found" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/schedule/{id}/inspect": { - "get": { - "tags": [ - "schedule" - ], - "operationId": "inspect_running_job", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "ID of the schedule to inspect", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Running job information", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InspectJobResponse" - } - } - } - }, - "404": { - "description": "Scheduled job not found" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/schedule/{id}/kill": { - "post": { - "tags": [ - "schedule" - ], - "operationId": "kill_running_job", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Running job killed successfully" - } - } - } - }, - "/schedule/{id}/pause": { - "post": { - "tags": [ - "schedule" - ], - "operationId": "pause_schedule", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "ID of the schedule to pause", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Scheduled job paused successfully" - }, - "400": { - "description": "Cannot pause a currently running job" - }, - "404": { - "description": "Scheduled job not found" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/schedule/{id}/run_now": { - "post": { - "tags": [ - "schedule" - ], - "operationId": "run_now_handler", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "ID of the schedule to run", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Scheduled job triggered successfully, returns new session ID", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RunNowResponse" - } - } - } - }, - "404": { - "description": "Scheduled job not found" - }, - "500": { - "description": "Internal server error when trying to run the job" - } - } - } - }, - "/schedule/{id}/sessions": { - "get": { - "tags": [ - "schedule" - ], - "operationId": "sessions_handler", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "ID of the schedule", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "format": "int32", - "minimum": 0 - } - } - ], - "responses": { - "200": { - "description": "A list of session display info", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SessionDisplayInfo" - } - } - } - } - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/schedule/{id}/unpause": { - "post": { - "tags": [ - "schedule" - ], - "operationId": "unpause_schedule", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "ID of the schedule to unpause", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Scheduled job unpaused successfully" - }, - "404": { - "description": "Scheduled job not found" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/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/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/{session_id}": { - "get": { - "tags": [ - "Session Management" - ], - "operationId": "get_session", - "parameters": [ - { - "name": "session_id", - "in": "path", - "description": "Unique identifier for the session", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Session history retrieved successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Session" - } - } - } - }, - "401": { - "description": "Unauthorized - Invalid or missing API key" - }, - "404": { - "description": "Session not found" - }, - "500": { - "description": "Internal server error" - } - }, - "security": [ - { - "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}/name": { - "put": { - "tags": [ - "Session Management" - ], - "operationId": "update_session_name", - "parameters": [ - { - "name": "session_id", - "in": "path", - "description": "Unique identifier for the session", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateSessionNameRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Session name updated successfully" - }, - "400": { - "description": "Bad request - Name too long (max 200 characters)" - }, - "401": { - "description": "Unauthorized - Invalid or missing API key" - }, - "404": { - "description": "Session not found" - }, - "500": { - "description": "Internal server error" - } - }, - "security": [ - { - "api_key": [] - } - ] - } - }, - "/sessions/{session_id}/user_recipe_values": { - "put": { - "tags": [ - "Session Management" - ], - "operationId": "update_session_user_recipe_values", - "parameters": [ - { - "name": "session_id", - "in": "path", - "description": "Unique identifier for the session", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateSessionUserRecipeValuesRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Session user recipe values updated successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateSessionUserRecipeValuesResponse" - } - } - } - }, - "401": { - "description": "Unauthorized - Invalid or missing API key" - }, - "404": { - "description": "Session not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "security": [ - { - "api_key": [] - } - ] - } - }, - "/status": { - "get": { - "tags": [ - "super::routes::status" - ], - "operationId": "status", - "responses": { - "200": { - "description": "ok", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "Annotations": { - "type": "object", - "properties": { - "audience": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Role" - } - }, - "lastModified": { - "type": "string", - "format": "date-time" - }, - "priority": { - "type": "number" - } - } - }, - "Author": { - "type": "object", - "properties": { - "contact": { - "type": "string", - "nullable": true - }, - "metadata": { - "type": "string", - "nullable": true - } - } - }, - "AuthorRequest": { - "type": "object", - "properties": { - "contact": { - "type": "string", - "nullable": true - }, - "metadata": { - "type": "string", - "nullable": true - } - } - }, - "ChatRequest": { - "type": "object", - "required": [ - "messages", - "session_id" - ], - "properties": { - "messages": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Message" - } - }, - "recipe_name": { - "type": "string", - "nullable": true - }, - "recipe_version": { - "type": "string", - "nullable": true - }, - "session_id": { - "type": "string" - } - } - }, - "ConfigKey": { - "type": "object", - "description": "Configuration key metadata for provider setup", - "required": [ - "name", - "required", - "secret", - "oauth_flow" - ], - "properties": { - "default": { - "type": "string", - "description": "Optional default value for the key", - "nullable": true - }, - "name": { - "type": "string", - "description": "The name of the configuration key (e.g., \"API_KEY\")" - }, - "oauth_flow": { - "type": "boolean", - "description": "Whether this key should be configured using OAuth device code flow\nWhen true, the provider's configure_oauth() method will be called instead of prompting for manual input" - }, - "required": { - "type": "boolean", - "description": "Whether this key is required for the provider to function" - }, - "secret": { - "type": "boolean", - "description": "Whether this key should be stored securely (e.g., in keychain)" - } - } - }, - "ConfigKeyQuery": { - "type": "object", - "required": [ - "key", - "is_secret" - ], - "properties": { - "is_secret": { - "type": "boolean" - }, - "key": { - "type": "string" - } - } - }, - "ConfigResponse": { - "type": "object", - "required": [ - "config" - ], - "properties": { - "config": { - "type": "object", - "additionalProperties": {} - } - } - }, - "Content": { - "oneOf": [ - { - "allOf": [ - { - "$ref": "#/components/schemas/RawTextContent" - } - ] - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/RawImageContent" - } - ] - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/RawEmbeddedResource" - } - ] - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/RawAudioContent" - } - ] - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/RawResource" - } - ] - } - ] - }, - "Conversation": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Message" - } - }, - "CreateRecipeRequest": { - "type": "object", - "required": [ - "session_id" - ], - "properties": { - "author": { - "allOf": [ - { - "$ref": "#/components/schemas/AuthorRequest" - } - ], - "nullable": true - }, - "session_id": { - "type": "string" - } - } - }, - "CreateRecipeResponse": { - "type": "object", - "properties": { - "error": { - "type": "string", - "nullable": true - }, - "recipe": { - "allOf": [ - { - "$ref": "#/components/schemas/Recipe" - } - ], - "nullable": true - } - } - }, - "CreateScheduleRequest": { - "type": "object", - "required": [ - "id", - "recipe_source", - "cron" - ], - "properties": { - "cron": { - "type": "string" - }, - "execution_mode": { - "type": "string", - "nullable": true - }, - "id": { - "type": "string" - }, - "recipe_source": { - "type": "string" - } - } - }, - "DeclarativeProviderConfig": { - "type": "object", - "required": [ - "name", - "engine", - "display_name", - "api_key_env", - "base_url", - "models" - ], - "properties": { - "api_key_env": { - "type": "string" - }, - "base_url": { - "type": "string" - }, - "description": { - "type": "string", - "nullable": true - }, - "display_name": { - "type": "string" - }, - "engine": { - "$ref": "#/components/schemas/ProviderEngine" - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "nullable": true - }, - "models": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ModelInfo" - } - }, - "name": { - "type": "string" - }, - "supports_streaming": { - "type": "boolean", - "nullable": true - }, - "timeout_seconds": { - "type": "integer", - "format": "int64", - "nullable": true, - "minimum": 0 - } - } - }, - "DecodeRecipeRequest": { - "type": "object", - "required": [ - "deeplink" - ], - "properties": { - "deeplink": { - "type": "string" - } - } - }, - "DecodeRecipeResponse": { - "type": "object", - "required": [ - "recipe" - ], - "properties": { - "recipe": { - "$ref": "#/components/schemas/Recipe" - } - } - }, - "DeleteRecipeRequest": { - "type": "object", - "required": [ - "id" - ], - "properties": { - "id": { - "type": "string" - } - } - }, - "EmbeddedResource": { - "type": "object", - "required": [ - "resource" - ], - "properties": { - "_meta": { - "type": "object", - "additionalProperties": true - }, - "annotations": { - "anyOf": [ - { - "$ref": "#/components/schemas/Annotations" - }, - { - "type": "object" - } - ] - }, - "resource": { - "$ref": "#/components/schemas/ResourceContents" - } - } - }, - "EncodeRecipeRequest": { - "type": "object", - "required": [ - "recipe" - ], - "properties": { - "recipe": { - "$ref": "#/components/schemas/Recipe" - } - } - }, - "EncodeRecipeResponse": { - "type": "object", - "required": [ - "deeplink" - ], - "properties": { - "deeplink": { - "type": "string" - } - } - }, - "Envs": { - "type": "object", - "additionalProperties": { - "type": "string", - "description": "A map of environment variables to set, e.g. API_KEY -> some_secret, HOST -> host" - } - }, - "ErrorResponse": { - "type": "object", - "required": [ - "message" - ], - "properties": { - "message": { - "type": "string" - } - } - }, - "ExtensionConfig": { - "oneOf": [ - { - "type": "object", - "description": "Server-sent events client with a URI endpoint", - "required": [ - "name", - "description", - "uri", - "type" - ], - "properties": { - "available_tools": { - "type": "array", - "items": { - "type": "string" - } - }, - "bundled": { - "type": "boolean", - "nullable": true - }, - "description": { - "type": "string" - }, - "env_keys": { - "type": "array", - "items": { - "type": "string" - } - }, - "envs": { - "$ref": "#/components/schemas/Envs" - }, - "name": { - "type": "string", - "description": "The name used to identify this extension" - }, - "timeout": { - "type": "integer", - "format": "int64", - "nullable": true, - "minimum": 0 - }, - "type": { - "type": "string", - "enum": [ - "sse" - ] - }, - "uri": { - "type": "string" - } - } - }, - { - "type": "object", - "description": "Standard I/O client with command and arguments", - "required": [ - "name", - "description", - "cmd", - "args", - "type" - ], - "properties": { - "args": { - "type": "array", - "items": { - "type": "string" - } - }, - "available_tools": { - "type": "array", - "items": { - "type": "string" - } - }, - "bundled": { - "type": "boolean", - "nullable": true - }, - "cmd": { - "type": "string" - }, - "description": { - "type": "string" - }, - "env_keys": { - "type": "array", - "items": { - "type": "string" - } - }, - "envs": { - "$ref": "#/components/schemas/Envs" - }, - "name": { - "type": "string", - "description": "The name used to identify this extension" - }, - "timeout": { - "type": "integer", - "format": "int64", - "nullable": true, - "minimum": 0 - }, - "type": { - "type": "string", - "enum": [ - "stdio" - ] - } - } - }, - { - "type": "object", - "description": "Built-in extension that is part of the bundled goose MCP server", - "required": [ - "name", - "description", - "type" - ], - "properties": { - "available_tools": { - "type": "array", - "items": { - "type": "string" - } - }, - "bundled": { - "type": "boolean", - "nullable": true - }, - "description": { - "type": "string" - }, - "display_name": { - "type": "string", - "nullable": true - }, - "name": { - "type": "string", - "description": "The name used to identify this extension" - }, - "timeout": { - "type": "integer", - "format": "int64", - "nullable": true, - "minimum": 0 - }, - "type": { - "type": "string", - "enum": [ - "builtin" - ] - } - } - }, - { - "type": "object", - "description": "Platform extensions that have direct access to the agent etc and run in the agent process", - "required": [ - "name", - "description", - "type" - ], - "properties": { - "available_tools": { - "type": "array", - "items": { - "type": "string" - } - }, - "bundled": { - "type": "boolean", - "nullable": true - }, - "description": { - "type": "string" - }, - "name": { - "type": "string", - "description": "The name used to identify this extension" - }, - "type": { - "type": "string", - "enum": [ - "platform" - ] - } - } - }, - { - "type": "object", - "description": "Streamable HTTP client with a URI endpoint using MCP Streamable HTTP specification", - "required": [ - "name", - "description", - "uri", - "type" - ], - "properties": { - "available_tools": { - "type": "array", - "items": { - "type": "string" - } - }, - "bundled": { - "type": "boolean", - "nullable": true - }, - "description": { - "type": "string" - }, - "env_keys": { - "type": "array", - "items": { - "type": "string" - } - }, - "envs": { - "$ref": "#/components/schemas/Envs" - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "name": { - "type": "string", - "description": "The name used to identify this extension" - }, - "timeout": { - "type": "integer", - "format": "int64", - "nullable": true, - "minimum": 0 - }, - "type": { - "type": "string", - "enum": [ - "streamable_http" - ] - }, - "uri": { - "type": "string" - } - } - }, - { - "type": "object", - "description": "Frontend-provided tools that will be called through the frontend", - "required": [ - "name", - "description", - "tools", - "type" - ], - "properties": { - "available_tools": { - "type": "array", - "items": { - "type": "string" - } - }, - "bundled": { - "type": "boolean", - "nullable": true - }, - "description": { - "type": "string" - }, - "instructions": { - "type": "string", - "description": "Instructions for how to use these tools", - "nullable": true - }, - "name": { - "type": "string", - "description": "The name used to identify this extension" - }, - "tools": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Tool" - }, - "description": "The tools provided by the frontend" - }, - "type": { - "type": "string", - "enum": [ - "frontend" - ] - } - } - }, - { - "type": "object", - "description": "Inline Python code that will be executed using uvx", - "required": [ - "name", - "description", - "code", - "type" - ], - "properties": { - "available_tools": { - "type": "array", - "items": { - "type": "string" - } - }, - "code": { - "type": "string", - "description": "The Python code to execute" - }, - "dependencies": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Python package dependencies required by this extension", - "nullable": true - }, - "description": { - "type": "string" - }, - "name": { - "type": "string", - "description": "The name used to identify this extension" - }, - "timeout": { - "type": "integer", - "format": "int64", - "description": "Timeout in seconds", - "nullable": true, - "minimum": 0 - }, - "type": { - "type": "string", - "enum": [ - "inline_python" - ] - } - } - } - ], - "description": "Represents the different types of MCP extensions that can be added to the manager", - "discriminator": { - "propertyName": "type" - } - }, - "ExtensionData": { - "type": "object", - "description": "Extension data containing all extension states\nKeys are in format \"extension_name.version\" (e.g., \"todo.v0\")", - "additionalProperties": {} - }, - "ExtensionEntry": { - "allOf": [ - { - "$ref": "#/components/schemas/ExtensionConfig" - }, - { - "type": "object", - "required": [ - "enabled" - ], - "properties": { - "enabled": { - "type": "boolean" - } - } - } - ] - }, - "ExtensionQuery": { - "type": "object", - "required": [ - "name", - "config", - "enabled" - ], - "properties": { - "config": { - "$ref": "#/components/schemas/ExtensionConfig" - }, - "enabled": { - "type": "boolean" - }, - "name": { - "type": "string" - } - } - }, - "ExtensionResponse": { - "type": "object", - "required": [ - "extensions" - ], - "properties": { - "extensions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ExtensionEntry" - } - } - } - }, - "FrontendToolRequest": { - "type": "object", - "required": [ - "id", - "toolCall" - ], - "properties": { - "id": { - "type": "string" - }, - "toolCall": { - "type": "object" - } - } - }, - "GetToolsQuery": { - "type": "object", - "required": [ - "session_id" - ], - "properties": { - "extension_name": { - "type": "string", - "nullable": true - }, - "session_id": { - "type": "string" - } - } - }, - "Icon": { - "type": "object", - "required": [ - "src" - ], - "properties": { - "mimeType": { - "type": "string" - }, - "sizes": { - "type": "string" - }, - "src": { - "type": "string" - } - } - }, - "ImageContent": { - "type": "object", - "required": [ - "data", - "mimeType" - ], - "properties": { - "_meta": { - "type": "object", - "additionalProperties": true - }, - "annotations": { - "anyOf": [ - { - "$ref": "#/components/schemas/Annotations" - }, - { - "type": "object" - } - ] - }, - "data": { - "type": "string" - }, - "mimeType": { - "type": "string" - } - } - }, - "ImportSessionRequest": { - "type": "object", - "required": [ - "json" - ], - "properties": { - "json": { - "type": "string" - } - } - }, - "InspectJobResponse": { - "type": "object", - "properties": { - "processStartTime": { - "type": "string", - "nullable": true - }, - "runningDurationSeconds": { - "type": "integer", - "format": "int64", - "nullable": true - }, - "sessionId": { - "type": "string", - "nullable": true - } - } - }, - "JsonObject": { - "type": "object", - "additionalProperties": true - }, - "KillJobResponse": { - "type": "object", - "required": [ - "message" - ], - "properties": { - "message": { - "type": "string" - } - } - }, - "ListRecipeResponse": { - "type": "object", - "required": [ - "recipe_manifest_responses" - ], - "properties": { - "recipe_manifest_responses": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RecipeManifestResponse" - } - } - } - }, - "ListSchedulesResponse": { - "type": "object", - "required": [ - "jobs" - ], - "properties": { - "jobs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ScheduledJob" - } - } - } - }, - "LoadedProvider": { - "type": "object", - "required": [ - "config", - "is_editable" - ], - "properties": { - "config": { - "$ref": "#/components/schemas/DeclarativeProviderConfig" - }, - "is_editable": { - "type": "boolean" - } - } - }, - "Message": { - "type": "object", - "description": "A message to or from an LLM", - "required": [ - "role", - "created", - "content", - "metadata" - ], - "properties": { - "content": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MessageContent" - } - }, - "created": { - "type": "integer", - "format": "int64" - }, - "id": { - "type": "string", - "nullable": true - }, - "metadata": { - "$ref": "#/components/schemas/MessageMetadata" - }, - "role": { - "$ref": "#/components/schemas/Role" - } - } - }, - "MessageContent": { - "oneOf": [ - { - "allOf": [ - { - "$ref": "#/components/schemas/TextContent" - }, - { - "type": "object", - "required": [ - "type" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "text" - ] - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/ImageContent" - }, - { - "type": "object", - "required": [ - "type" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "image" - ] - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/ToolRequest" - }, - { - "type": "object", - "required": [ - "type" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "toolRequest" - ] - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/ToolResponse" - }, - { - "type": "object", - "required": [ - "type" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "toolResponse" - ] - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/ToolConfirmationRequest" - }, - { - "type": "object", - "required": [ - "type" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "toolConfirmationRequest" - ] - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/FrontendToolRequest" - }, - { - "type": "object", - "required": [ - "type" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "frontendToolRequest" - ] - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/ThinkingContent" - }, - { - "type": "object", - "required": [ - "type" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "thinking" - ] - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/RedactedThinkingContent" - }, - { - "type": "object", - "required": [ - "type" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "redactedThinking" - ] - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/SystemNotificationContent" - }, - { - "type": "object", - "required": [ - "type" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "systemNotification" - ] - } - } - } - ] - } - ], - "description": "Content passed inside a message, which can be both simple content and tool content", - "discriminator": { - "propertyName": "type" - } - }, - "MessageMetadata": { - "type": "object", - "description": "Metadata for message visibility", - "required": [ - "userVisible", - "agentVisible" - ], - "properties": { - "agentVisible": { - "type": "boolean", - "description": "Whether the message should be included in the agent's context window" - }, - "userVisible": { - "type": "boolean", - "description": "Whether the message should be visible to the user in the UI" - } - } - }, - "ModelInfo": { - "type": "object", - "description": "Information about a model's capabilities", - "required": [ - "name", - "context_limit" - ], - "properties": { - "context_limit": { - "type": "integer", - "description": "The maximum context length this model supports", - "minimum": 0 - }, - "currency": { - "type": "string", - "description": "Currency for the costs (default: \"$\")", - "nullable": true - }, - "input_token_cost": { - "type": "number", - "format": "double", - "description": "Cost per token for input (optional)", - "nullable": true - }, - "name": { - "type": "string", - "description": "The name of the model" - }, - "output_token_cost": { - "type": "number", - "format": "double", - "description": "Cost per token for output (optional)", - "nullable": true - }, - "supports_cache_control": { - "type": "boolean", - "description": "Whether this model supports cache control", - "nullable": true - } - } - }, - "ParseRecipeRequest": { - "type": "object", - "required": [ - "content" - ], - "properties": { - "content": { - "type": "string" - } - } - }, - "ParseRecipeResponse": { - "type": "object", - "required": [ - "recipe" - ], - "properties": { - "recipe": { - "$ref": "#/components/schemas/Recipe" - } - } - }, - "PermissionConfirmationRequest": { - "type": "object", - "required": [ - "id", - "action", - "session_id" - ], - "properties": { - "action": { - "type": "string" - }, - "id": { - "type": "string" - }, - "principal_type": { - "$ref": "#/components/schemas/PrincipalType" - }, - "session_id": { - "type": "string" - } - } - }, - "PermissionLevel": { - "type": "string", - "description": "Enum representing the possible permission levels for a tool.", - "enum": [ - "always_allow", - "ask_before", - "never_allow" - ] - }, - "PrincipalType": { - "type": "string", - "enum": [ - "Extension", - "Tool" - ] - }, - "ProviderDetails": { - "type": "object", - "required": [ - "name", - "metadata", - "is_configured", - "provider_type" - ], - "properties": { - "is_configured": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/ProviderMetadata" - }, - "name": { - "type": "string" - }, - "provider_type": { - "$ref": "#/components/schemas/ProviderType" - } - } - }, - "ProviderEngine": { - "type": "string", - "enum": [ - "openai", - "ollama", - "anthropic" - ] - }, - "ProviderMetadata": { - "type": "object", - "description": "Metadata about a provider's configuration requirements and capabilities", - "required": [ - "name", - "display_name", - "description", - "default_model", - "known_models", - "model_doc_link", - "config_keys" - ], - "properties": { - "config_keys": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ConfigKey" - }, - "description": "Required configuration keys" - }, - "default_model": { - "type": "string", - "description": "The default/recommended model for this provider" - }, - "description": { - "type": "string", - "description": "Description of the provider's capabilities" - }, - "display_name": { - "type": "string", - "description": "Display name for the provider in UIs" - }, - "known_models": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ModelInfo" - }, - "description": "A list of currently known models with their capabilities" - }, - "model_doc_link": { - "type": "string", - "description": "Link to the docs where models can be found" - }, - "name": { - "type": "string", - "description": "The unique identifier for this provider" - } - } - }, - "ProviderType": { - "type": "string", - "enum": [ - "Preferred", - "Builtin", - "Declarative", - "Custom" - ] - }, - "ProvidersResponse": { - "type": "object", - "required": [ - "providers" - ], - "properties": { - "providers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProviderDetails" - } - } - } - }, - "RawAudioContent": { - "type": "object", - "required": [ - "data", - "mimeType" - ], - "properties": { - "data": { - "type": "string" - }, - "mimeType": { - "type": "string" - } - } - }, - "RawEmbeddedResource": { - "type": "object", - "required": [ - "resource" - ], - "properties": { - "_meta": { - "type": "object", - "additionalProperties": true - }, - "resource": { - "$ref": "#/components/schemas/ResourceContents" - } - } - }, - "RawImageContent": { - "type": "object", - "required": [ - "data", - "mimeType" - ], - "properties": { - "_meta": { - "type": "object", - "additionalProperties": true - }, - "data": { - "type": "string" - }, - "mimeType": { - "type": "string" - } - } - }, - "RawResource": { - "type": "object", - "required": [ - "uri", - "name" - ], - "properties": { - "description": { - "type": "string" - }, - "icons": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Icon" - } - }, - "mimeType": { - "type": "string" - }, - "name": { - "type": "string" - }, - "size": { - "type": "integer", - "minimum": 0 - }, - "title": { - "type": "string" - }, - "uri": { - "type": "string" - } - } - }, - "RawTextContent": { - "type": "object", - "required": [ - "text" - ], - "properties": { - "_meta": { - "type": "object", - "additionalProperties": true - }, - "text": { - "type": "string" - } - } - }, - "Recipe": { - "type": "object", - "required": [ - "title", - "description" - ], - "properties": { - "activities": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "author": { - "allOf": [ - { - "$ref": "#/components/schemas/Author" - } - ], - "nullable": true - }, - "context": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "description": { - "type": "string" - }, - "extensions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ExtensionConfig" - }, - "nullable": true - }, - "instructions": { - "type": "string", - "nullable": true - }, - "parameters": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RecipeParameter" - }, - "nullable": true - }, - "prompt": { - "type": "string", - "nullable": true - }, - "response": { - "allOf": [ - { - "$ref": "#/components/schemas/Response" - } - ], - "nullable": true - }, - "retry": { - "allOf": [ - { - "$ref": "#/components/schemas/RetryConfig" - } - ], - "nullable": true - }, - "settings": { - "allOf": [ - { - "$ref": "#/components/schemas/Settings" - } - ], - "nullable": true - }, - "sub_recipes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SubRecipe" - }, - "nullable": true - }, - "title": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "RecipeManifestResponse": { - "type": "object", - "required": [ - "recipe", - "lastModified", - "id" - ], - "properties": { - "id": { - "type": "string" - }, - "lastModified": { - "type": "string" - }, - "recipe": { - "$ref": "#/components/schemas/Recipe" - } - } - }, - "RecipeParameter": { - "type": "object", - "required": [ - "key", - "input_type", - "requirement", - "description" - ], - "properties": { - "default": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string" - }, - "input_type": { - "$ref": "#/components/schemas/RecipeParameterInputType" - }, - "key": { - "type": "string" - }, - "options": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "requirement": { - "$ref": "#/components/schemas/RecipeParameterRequirement" - } - } - }, - "RecipeParameterInputType": { - "type": "string", - "enum": [ - "string", - "number", - "boolean", - "date", - "file", - "select" - ] - }, - "RecipeParameterRequirement": { - "type": "string", - "enum": [ - "required", - "optional", - "user_prompt" - ] - }, - "RedactedThinkingContent": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "string" - } - } - }, - "ResourceContents": { - "anyOf": [ - { - "type": "object", - "required": [ - "uri", - "text" - ], - "properties": { - "_meta": { - "type": "object", - "additionalProperties": true - }, - "mimeType": { - "type": "string" - }, - "text": { - "type": "string" - }, - "uri": { - "type": "string" - } - } - }, - { - "type": "object", - "required": [ - "uri", - "blob" - ], - "properties": { - "_meta": { - "type": "object", - "additionalProperties": true - }, - "blob": { - "type": "string" - }, - "mimeType": { - "type": "string" - }, - "uri": { - "type": "string" - } - } - } - ] - }, - "Response": { - "type": "object", - "properties": { - "json_schema": { - "nullable": true - } - } - }, - "ResumeAgentRequest": { - "type": "object", - "required": [ - "session_id", - "load_model_and_extensions" - ], - "properties": { - "load_model_and_extensions": { - "type": "boolean" - }, - "session_id": { - "type": "string" - } - } - }, - "RetryConfig": { - "type": "object", - "description": "Configuration for retry logic in recipe execution", - "required": [ - "max_retries", - "checks" - ], - "properties": { - "checks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SuccessCheck" - }, - "description": "List of success checks to validate recipe completion" - }, - "max_retries": { - "type": "integer", - "format": "int32", - "description": "Maximum number of retry attempts before giving up", - "minimum": 0 - }, - "on_failure": { - "type": "string", - "description": "Optional shell command to run on failure for cleanup", - "nullable": true - }, - "on_failure_timeout_seconds": { - "type": "integer", - "format": "int64", - "description": "Timeout in seconds for on_failure commands (default: 600 seconds)", - "nullable": true, - "minimum": 0 - }, - "timeout_seconds": { - "type": "integer", - "format": "int64", - "description": "Timeout in seconds for individual shell commands (default: 300 seconds)", - "nullable": true, - "minimum": 0 - } - } - }, - "Role": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "string" - } - ] - }, - "RunNowResponse": { - "type": "object", - "required": [ - "session_id" - ], - "properties": { - "session_id": { - "type": "string" - } - } - }, - "SaveRecipeRequest": { - "type": "object", - "required": [ - "recipe" - ], - "properties": { - "id": { - "type": "string", - "nullable": true - }, - "recipe": { - "$ref": "#/components/schemas/Recipe" - } - } - }, - "SaveRecipeResponse": { - "type": "object", - "required": [ - "id" - ], - "properties": { - "id": { - "type": "string" - } - } - }, - "ScanRecipeRequest": { - "type": "object", - "required": [ - "recipe" - ], - "properties": { - "recipe": { - "$ref": "#/components/schemas/Recipe" - } - } - }, - "ScanRecipeResponse": { - "type": "object", - "required": [ - "has_security_warnings" - ], - "properties": { - "has_security_warnings": { - "type": "boolean" - } - } - }, - "ScheduledJob": { - "type": "object", - "required": [ - "id", - "source", - "cron" - ], - "properties": { - "cron": { - "type": "string" - }, - "current_session_id": { - "type": "string", - "nullable": true - }, - "currently_running": { - "type": "boolean" - }, - "execution_mode": { - "type": "string", - "nullable": true - }, - "id": { - "type": "string" - }, - "last_run": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "paused": { - "type": "boolean" - }, - "process_start_time": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "source": { - "type": "string" - } - } - }, - "Session": { - "type": "object", - "required": [ - "id", - "working_dir", - "name", - "created_at", - "updated_at", - "extension_data", - "message_count" - ], - "properties": { - "accumulated_input_tokens": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "accumulated_output_tokens": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "accumulated_total_tokens": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "conversation": { - "allOf": [ - { - "$ref": "#/components/schemas/Conversation" - } - ], - "nullable": true - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "extension_data": { - "$ref": "#/components/schemas/ExtensionData" - }, - "id": { - "type": "string" - }, - "input_tokens": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "message_count": { - "type": "integer", - "minimum": 0 - }, - "name": { - "type": "string" - }, - "output_tokens": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "recipe": { - "allOf": [ - { - "$ref": "#/components/schemas/Recipe" - } - ], - "nullable": true - }, - "schedule_id": { - "type": "string", - "nullable": true - }, - "total_tokens": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "user_recipe_values": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "nullable": true - }, - "user_set_name": { - "type": "boolean" - }, - "working_dir": { - "type": "string" - } - } - }, - "SessionDisplayInfo": { - "type": "object", - "required": [ - "id", - "name", - "createdAt", - "workingDir", - "messageCount" - ], - "properties": { - "accumulatedInputTokens": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "accumulatedOutputTokens": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "accumulatedTotalTokens": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "createdAt": { - "type": "string" - }, - "id": { - "type": "string" - }, - "inputTokens": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "messageCount": { - "type": "integer", - "minimum": 0 - }, - "name": { - "type": "string" - }, - "outputTokens": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "scheduleId": { - "type": "string", - "nullable": true - }, - "totalTokens": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "workingDir": { - "type": "string" - } - } - }, - "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" - } - } - }, - "SessionsQuery": { - "type": "object", - "properties": { - "limit": { - "type": "integer", - "format": "int32", - "minimum": 0 - } - } - }, - "Settings": { - "type": "object", - "properties": { - "goose_model": { - "type": "string", - "nullable": true - }, - "goose_provider": { - "type": "string", - "nullable": true - }, - "temperature": { - "type": "number", - "format": "float", - "nullable": true - } - } - }, - "SetupResponse": { - "type": "object", - "required": [ - "success", - "message" - ], - "properties": { - "message": { - "type": "string" - }, - "success": { - "type": "boolean" - } - } - }, - "StartAgentRequest": { - "type": "object", - "required": [ - "working_dir" - ], - "properties": { - "recipe": { - "allOf": [ - { - "$ref": "#/components/schemas/Recipe" - } - ], - "nullable": true - }, - "recipe_deeplink": { - "type": "string", - "nullable": true - }, - "recipe_id": { - "type": "string", - "nullable": true - }, - "working_dir": { - "type": "string" - } - } - }, - "SubRecipe": { - "type": "object", - "required": [ - "name", - "path" - ], - "properties": { - "description": { - "type": "string", - "nullable": true - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "sequential_when_repeated": { - "type": "boolean" - }, - "values": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "nullable": true - } - } - }, - "SuccessCheck": { - "oneOf": [ - { - "type": "object", - "description": "Execute a shell command and check its exit status", - "required": [ - "command", - "type" - ], - "properties": { - "command": { - "type": "string", - "description": "The shell command to execute" - }, - "type": { - "type": "string", - "enum": [ - "Shell" - ] - } - } - } - ], - "description": "A single success check to validate recipe completion", - "discriminator": { - "propertyName": "type" - } - }, - "SystemNotificationContent": { - "type": "object", - "required": [ - "notificationType", - "msg" - ], - "properties": { - "msg": { - "type": "string" - }, - "notificationType": { - "$ref": "#/components/schemas/SystemNotificationType" - } - } - }, - "SystemNotificationType": { - "type": "string", - "enum": [ - "thinkingMessage", - "inlineMessage" - ] - }, - "TextContent": { - "type": "object", - "required": [ - "text" - ], - "properties": { - "_meta": { - "type": "object", - "additionalProperties": true - }, - "annotations": { - "anyOf": [ - { - "$ref": "#/components/schemas/Annotations" - }, - { - "type": "object" - } - ] - }, - "text": { - "type": "string" - } - } - }, - "ThinkingContent": { - "type": "object", - "required": [ - "thinking", - "signature" - ], - "properties": { - "signature": { - "type": "string" - }, - "thinking": { - "type": "string" - } - } - }, - "TokenState": { - "type": "object", - "properties": { - "accumulatedInputTokens": { - "type": "integer", - "format": "int32", - "description": "Accumulated token counts across all turns", - "nullable": true - }, - "accumulatedOutputTokens": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "accumulatedTotalTokens": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "inputTokens": { - "type": "integer", - "format": "int32", - "description": "Current turn token counts", - "nullable": true - }, - "outputTokens": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "totalTokens": { - "type": "integer", - "format": "int32", - "nullable": true - } - } - }, - "Tool": { - "type": "object", - "required": [ - "name", - "inputSchema" - ], - "properties": { - "annotations": { - "anyOf": [ - { - "$ref": "#/components/schemas/ToolAnnotations" - }, - { - "type": "object" - } - ] - }, - "description": { - "type": "string" - }, - "icons": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Icon" - } - }, - "inputSchema": { - "type": "object", - "additionalProperties": true - }, - "name": { - "type": "string" - }, - "outputSchema": { - "type": "object", - "additionalProperties": true - }, - "title": { - "type": "string" - } - } - }, - "ToolAnnotations": { - "type": "object", - "properties": { - "destructiveHint": { - "type": "boolean" - }, - "idempotentHint": { - "type": "boolean" - }, - "openWorldHint": { - "type": "boolean" - }, - "readOnlyHint": { - "type": "boolean" - }, - "title": { - "type": "string" - } - } - }, - "ToolConfirmationRequest": { - "type": "object", - "required": [ - "id", - "toolName", - "arguments" - ], - "properties": { - "arguments": { - "$ref": "#/components/schemas/JsonObject" - }, - "id": { - "type": "string" - }, - "prompt": { - "type": "string", - "nullable": true - }, - "toolName": { - "type": "string" - } - } - }, - "ToolInfo": { - "type": "object", - "description": "Information about the tool used for building prompts", - "required": [ - "name", - "description", - "parameters" - ], - "properties": { - "description": { - "type": "string" - }, - "name": { - "type": "string" - }, - "parameters": { - "type": "array", - "items": { - "type": "string" - } - }, - "permission": { - "allOf": [ - { - "$ref": "#/components/schemas/PermissionLevel" - } - ], - "nullable": true - } - } - }, - "ToolPermission": { - "type": "object", - "required": [ - "tool_name", - "permission" - ], - "properties": { - "permission": { - "$ref": "#/components/schemas/PermissionLevel" - }, - "tool_name": { - "type": "string" - } - } - }, - "ToolRequest": { - "type": "object", - "required": [ - "id", - "toolCall" - ], - "properties": { - "id": { - "type": "string" - }, - "toolCall": { - "type": "object" - } - } - }, - "ToolResponse": { - "type": "object", - "required": [ - "id", - "toolResult" - ], - "properties": { - "id": { - "type": "string" - }, - "toolResult": { - "type": "object" - } - } - }, - "UpdateCustomProviderRequest": { - "type": "object", - "required": [ - "engine", - "display_name", - "api_url", - "api_key", - "models" - ], - "properties": { - "api_key": { - "type": "string" - }, - "api_url": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "engine": { - "type": "string" - }, - "models": { - "type": "array", - "items": { - "type": "string" - } - }, - "supports_streaming": { - "type": "boolean", - "nullable": true - } - } - }, - "UpdateFromSessionRequest": { - "type": "object", - "required": [ - "session_id" - ], - "properties": { - "session_id": { - "type": "string" - } - } - }, - "UpdateProviderRequest": { - "type": "object", - "required": [ - "provider", - "session_id" - ], - "properties": { - "model": { - "type": "string", - "nullable": true - }, - "provider": { - "type": "string" - }, - "session_id": { - "type": "string" - } - } - }, - "UpdateRouterToolSelectorRequest": { - "type": "object", - "required": [ - "session_id" - ], - "properties": { - "session_id": { - "type": "string" - } - } - }, - "UpdateScheduleRequest": { - "type": "object", - "required": [ - "cron" - ], - "properties": { - "cron": { - "type": "string" - } - } - }, - "UpdateSessionNameRequest": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string", - "description": "Updated name for the session (max 200 characters)" - } - } - }, - "UpdateSessionUserRecipeValuesRequest": { - "type": "object", - "required": [ - "userRecipeValues" - ], - "properties": { - "userRecipeValues": { - "type": "object", - "description": "Recipe parameter values entered by the user", - "additionalProperties": { - "type": "string" - } - } - } - }, - "UpdateSessionUserRecipeValuesResponse": { - "type": "object", - "required": [ - "recipe" - ], - "properties": { - "recipe": { - "$ref": "#/components/schemas/Recipe" - } - } - }, - "UpsertConfigQuery": { - "type": "object", - "required": [ - "key", - "value", - "is_secret" - ], - "properties": { - "is_secret": { - "type": "boolean" - }, - "key": { - "type": "string" - }, - "value": {} - } - }, - "UpsertPermissionsQuery": { - "type": "object", - "required": [ - "tool_permissions" - ], - "properties": { - "tool_permissions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ToolPermission" - } - } - } - } - } - } -} From 2e26a6cb1410369955a8f70dd8852b19f8b23fa9 Mon Sep 17 00:00:00 2001 From: David Katz Date: Tue, 28 Oct 2025 15:53:46 -0400 Subject: [PATCH 52/55] regen schema --- crates/goose/src/conversation/message.rs | 2 -- ui/desktop/openapi.json | 2 -- ui/desktop/src/api/types.gen.ts | 6 ------ 3 files changed, 10 deletions(-) diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index 8025cb281ab2..7b5c5162ab13 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -714,11 +714,9 @@ impl Message { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct TokenState { - /// Current turn token counts pub input_tokens: Option, pub output_tokens: Option, pub total_tokens: Option, - /// Accumulated token counts across all turns pub accumulated_input_tokens: Option, pub accumulated_output_tokens: Option, pub accumulated_total_tokens: Option, diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 8f274df7f3e2..57ffa4f11595 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -4280,7 +4280,6 @@ "accumulatedInputTokens": { "type": "integer", "format": "int32", - "description": "Accumulated token counts across all turns", "nullable": true }, "accumulatedOutputTokens": { @@ -4296,7 +4295,6 @@ "inputTokens": { "type": "integer", "format": "int32", - "description": "Current turn token counts", "nullable": true }, "outputTokens": { diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 31effd40b7cb..787dbacb2890 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -754,15 +754,9 @@ export type ThinkingContent = { }; export type TokenState = { - /** - * Accumulated token counts across all turns - */ accumulatedInputTokens?: number | null; accumulatedOutputTokens?: number | null; accumulatedTotalTokens?: number | null; - /** - * Current turn token counts - */ inputTokens?: number | null; outputTokens?: number | null; totalTokens?: number | null; From 79c05afc87760ed43a77e21d546bb091a7d1ab2c Mon Sep 17 00:00:00 2001 From: David Katz Date: Tue, 28 Oct 2025 16:08:37 -0400 Subject: [PATCH 53/55] schema gen --- crates/goose-server/src/openapi.rs | 6 +++--- crates/goose-server/src/routes/reply.rs | 4 ++-- ui/desktop/openapi.json | 8 ++++++++ ui/desktop/src/api/types.gen.ts | 1 + ui/desktop/src/hooks/useChatStream.ts | 1 - ui/desktop/src/hooks/useMessageStream.ts | 1 - 6 files changed, 14 insertions(+), 7 deletions(-) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index c5b2988aee6c..663ca2cbe474 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -19,8 +19,8 @@ use goose::config::declarative_providers::{ }; use goose::conversation::message::{ FrontendToolRequest, Message, MessageContent, MessageMetadata, RedactedThinkingContent, - SystemNotificationContent, SystemNotificationType, ThinkingContent, ToolConfirmationRequest, - ToolRequest, ToolResponse, + SystemNotificationContent, SystemNotificationType, ThinkingContent, TokenState, + ToolConfirmationRequest, ToolRequest, ToolResponse, }; use crate::routes::reply::MessageEvent; @@ -402,7 +402,7 @@ derive_utoipa!(Icon as IconSchema); Message, MessageContent, MessageMetadata, - goose::conversation::message::TokenState, + TokenState, ContentSchema, EmbeddedResourceSchema, ImageContentSchema, diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index cae34e8ff98d..4eb10c1e2a3e 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -8,7 +8,7 @@ use axum::{ }; use bytes::Bytes; use futures::{stream::StreamExt, Stream}; -use goose::conversation::message::{Message, MessageContent}; +use goose::conversation::message::{Message, MessageContent, TokenState}; use goose::conversation::Conversation; use goose::permission::{Permission, PermissionConfirmation}; use goose::session::SessionManager; @@ -127,7 +127,7 @@ pub enum MessageEvent { Message { message: Message, #[serde(skip_serializing_if = "Option::is_none")] - token_state: Option, + token_state: Option, }, Error { error: String, diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 302339683597..279ba4b17045 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3191,6 +3191,14 @@ "message": { "$ref": "#/components/schemas/Message" }, + "token_state": { + "allOf": [ + { + "$ref": "#/components/schemas/TokenState" + } + ], + "nullable": true + }, "type": { "type": "string", "enum": [ diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index fef192ff1226..bf84d649243a 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -362,6 +362,7 @@ export type MessageContent = (TextContent & { export type MessageEvent = { message: Message; + token_state?: TokenState | null; type: 'Message'; } | { error: string; diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index d614f09d8c84..9ee528ff084a 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -122,7 +122,6 @@ async function streamFromResponse( }); } - // Update token state if present if (event.token_state) { updateTokenState(event.token_state); } diff --git a/ui/desktop/src/hooks/useMessageStream.ts b/ui/desktop/src/hooks/useMessageStream.ts index 38ef8554249e..b2f320b2c40a 100644 --- a/ui/desktop/src/hooks/useMessageStream.ts +++ b/ui/desktop/src/hooks/useMessageStream.ts @@ -284,7 +284,6 @@ export function useMessageStream({ // Transition from waiting to streaming on first message mutateChatState(ChatState.Streaming); - // Update token state if present if (parsedEvent.token_state) { setTokenState(parsedEvent.token_state); } From 3f216551e806e23dbdf0b5e910f67c3f38878804 Mon Sep 17 00:00:00 2001 From: David Katz Date: Tue, 28 Oct 2025 19:35:16 -0400 Subject: [PATCH 54/55] dont use optionsals --- crates/goose-server/src/routes/reply.rs | 28 +++++++++++-------- crates/goose/src/conversation/message.rs | 12 ++++----- ui/desktop/openapi.json | 34 +++++++++++------------- ui/desktop/src/api/types.gen.ts | 14 +++++----- 4 files changed, 46 insertions(+), 42 deletions(-) diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index 4eb10c1e2a3e..0748399991be 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -126,8 +126,7 @@ impl IntoResponse for SseResponse { pub enum MessageEvent { Message { message: Message, - #[serde(skip_serializing_if = "Option::is_none")] - token_state: Option, + token_state: TokenState, }, Error { error: String, @@ -311,18 +310,25 @@ pub async fn reply( let token_state = match SessionManager::get_session(&session_id, false).await { Ok(session) => { - Some(goose::conversation::message::TokenState { - input_tokens: session.input_tokens, - output_tokens: session.output_tokens, - total_tokens: session.total_tokens, - accumulated_input_tokens: session.accumulated_input_tokens, - accumulated_output_tokens: session.accumulated_output_tokens, - accumulated_total_tokens: session.accumulated_total_tokens, - }) + TokenState { + input_tokens: session.input_tokens.unwrap_or(0), + output_tokens: session.output_tokens.unwrap_or(0), + total_tokens: session.total_tokens.unwrap_or(0), + accumulated_input_tokens: session.accumulated_input_tokens.unwrap_or(0), + accumulated_output_tokens: session.accumulated_output_tokens.unwrap_or(0), + accumulated_total_tokens: session.accumulated_total_tokens.unwrap_or(0), + } }, Err(e) => { tracing::warn!("Failed to fetch session for token state: {}", e); - None + TokenState { + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + accumulated_input_tokens: 0, + accumulated_output_tokens: 0, + accumulated_total_tokens: 0, + } } }; diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index 7b5c5162ab13..cc7d161dd841 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -714,12 +714,12 @@ impl Message { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct TokenState { - pub input_tokens: Option, - pub output_tokens: Option, - pub total_tokens: Option, - pub accumulated_input_tokens: Option, - pub accumulated_output_tokens: Option, - pub accumulated_total_tokens: Option, + pub input_tokens: i32, + pub output_tokens: i32, + pub total_tokens: i32, + pub accumulated_input_tokens: i32, + pub accumulated_output_tokens: i32, + pub accumulated_total_tokens: i32, } #[cfg(test)] diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 279ba4b17045..2a613451fc6e 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3185,6 +3185,7 @@ "type": "object", "required": [ "message", + "token_state", "type" ], "properties": { @@ -3192,12 +3193,7 @@ "$ref": "#/components/schemas/Message" }, "token_state": { - "allOf": [ - { - "$ref": "#/components/schemas/TokenState" - } - ], - "nullable": true + "$ref": "#/components/schemas/TokenState" }, "type": { "type": "string", @@ -4428,36 +4424,38 @@ }, "TokenState": { "type": "object", + "required": [ + "inputTokens", + "outputTokens", + "totalTokens", + "accumulatedInputTokens", + "accumulatedOutputTokens", + "accumulatedTotalTokens" + ], "properties": { "accumulatedInputTokens": { "type": "integer", - "format": "int32", - "nullable": true + "format": "int32" }, "accumulatedOutputTokens": { "type": "integer", - "format": "int32", - "nullable": true + "format": "int32" }, "accumulatedTotalTokens": { "type": "integer", - "format": "int32", - "nullable": true + "format": "int32" }, "inputTokens": { "type": "integer", - "format": "int32", - "nullable": true + "format": "int32" }, "outputTokens": { "type": "integer", - "format": "int32", - "nullable": true + "format": "int32" }, "totalTokens": { "type": "integer", - "format": "int32", - "nullable": true + "format": "int32" } } }, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index bf84d649243a..d9b9c1aac0a7 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -362,7 +362,7 @@ export type MessageContent = (TextContent & { export type MessageEvent = { message: Message; - token_state?: TokenState | null; + token_state: TokenState; type: 'Message'; } | { error: string; @@ -781,12 +781,12 @@ export type ThinkingContent = { }; export type TokenState = { - accumulatedInputTokens?: number | null; - accumulatedOutputTokens?: number | null; - accumulatedTotalTokens?: number | null; - inputTokens?: number | null; - outputTokens?: number | null; - totalTokens?: number | null; + accumulatedInputTokens: number; + accumulatedOutputTokens: number; + accumulatedTotalTokens: number; + inputTokens: number; + outputTokens: number; + totalTokens: number; }; export type Tool = { From 0dd473d7ab4983e532ce605252a2867d31a66789 Mon Sep 17 00:00:00 2001 From: David Katz Date: Tue, 28 Oct 2025 20:33:26 -0400 Subject: [PATCH 55/55] one more optional ref --- ui/desktop/src/hooks/useChatStream.ts | 17 +++++++++++------ ui/desktop/src/hooks/useMessageStream.ts | 17 +++++++++++------ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index 9ee528ff084a..64ed1912de43 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -61,7 +61,7 @@ interface UseChatStreamReturn { setRecipeUserParams: (values: Record) => Promise; stopStreaming: () => void; sessionLoadError?: string; - tokenState?: TokenState; + tokenState: TokenState; } function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[] { @@ -90,7 +90,7 @@ async function streamFromResponse( stream: AsyncIterable, initialMessages: Message[], updateMessages: (messages: Message[]) => void, - updateTokenState: (tokenState?: TokenState) => void, + updateTokenState: (tokenState: TokenState) => void, updateChatState: (state: ChatState) => void, onFinish: (error?: string) => void ): Promise { @@ -122,9 +122,7 @@ async function streamFromResponse( }); } - if (event.token_state) { - updateTokenState(event.token_state); - } + updateTokenState(event.token_state); updateMessages(currentMessages); break; @@ -178,7 +176,14 @@ export function useChatStream({ const [session, setSession] = useState(); const [sessionLoadError, setSessionLoadError] = useState(); const [chatState, setChatState] = useState(ChatState.Idle); - const [tokenState, setTokenState] = useState(); + const [tokenState, setTokenState] = useState({ + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + accumulatedInputTokens: 0, + accumulatedOutputTokens: 0, + accumulatedTotalTokens: 0, + }); const abortControllerRef = useRef(null); useEffect(() => { diff --git a/ui/desktop/src/hooks/useMessageStream.ts b/ui/desktop/src/hooks/useMessageStream.ts index b2f320b2c40a..081d264a7a54 100644 --- a/ui/desktop/src/hooks/useMessageStream.ts +++ b/ui/desktop/src/hooks/useMessageStream.ts @@ -35,7 +35,7 @@ export interface NotificationEvent { // Event types for SSE stream type MessageEvent = - | { type: 'Message'; message: Message; token_state?: TokenState | null } + | { type: 'Message'; message: Message; token_state: TokenState } | { type: 'Error'; error: string } | { type: 'Finish'; reason: string } | { type: 'ModelChange'; model: string; mode: string } @@ -167,7 +167,7 @@ export interface UseMessageStreamHelpers { setError: (error: Error | undefined) => void; /** Real-time token state from server */ - tokenState?: TokenState; + tokenState: TokenState; } /** @@ -200,7 +200,14 @@ export function useMessageStream({ null ); const [session, setSession] = useState(null); - const [tokenState, setTokenState] = useState(); + const [tokenState, setTokenState] = useState({ + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + accumulatedInputTokens: 0, + accumulatedOutputTokens: 0, + accumulatedTotalTokens: 0, + }); // expose a way to update the body so we can update the session id when CLE occurs const updateMessageStreamBody = useCallback((newBody: object) => { @@ -284,9 +291,7 @@ export function useMessageStream({ // Transition from waiting to streaming on first message mutateChatState(ChatState.Streaming); - if (parsedEvent.token_state) { - setTokenState(parsedEvent.token_state); - } + setTokenState(parsedEvent.token_state); // Create a new message object with the properties preserved or defaulted const newMessage: Message = {