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..b1e0066da556 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -185,8 +185,18 @@ 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) => { + 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"); diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 0cb509f83612..8c9da2385c60 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::{ @@ -350,7 +351,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 +393,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, @@ -420,7 +418,8 @@ derive_utoipa!(Icon as IconSchema); RedactedThinkingContent, FrontendToolRequest, ResourceContentsSchema, - ConversationCompacted, + SystemNotificationType, + SystemNotificationContent, JsonObjectSchema, RoleSchema, ProviderMetadata, 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 85afda5c822a..7deafd6b7b2d 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 recipe; @@ -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())) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index fdcb2b2eccd7..f0f4eb08bd62 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -59,11 +59,13 @@ 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, MessageContent, SystemNotificationType, ToolRequest}; 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 MANUAL_COMPACT_TRIGGER: &str = "Please compact this conversation"; /// Context needed for the reply function pub struct ReplyContext { @@ -745,77 +747,99 @@ 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 - .ok() - } else { - None - }; - - let check_result = crate::context_mgmt::check_if_compaction_needed( - self, - &unfixed_conversation, - None, - session_metadata.as_ref(), - ) - .await; + 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() == MANUAL_COMPACT_TRIGGER + } else { + false + } + }) + }); - 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) + if !is_manual_compact { + let session_metadata = if let Some(session_config) = &session { + SessionManager::get_session(&session_config.id, false) .await - { - Ok((conversation, _token_counts, _summarization_usage)) => { - (true, conversation, None) - } - Err(e) => (false, unfixed_conversation.clone(), Some(e)), - } + .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; } - Ok(false) => (false, unfixed_conversation, None), - Err(e) => (false, unfixed_conversation.clone(), Some(e)), - }; + } - 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 conversation_to_compact = unfixed_conversation.clone(); + + Ok(Box::pin(async_stream::try_stream! { + 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 + ); - 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, + inline_msg, + ) ); - yield AgentEvent::HistoryReplaced(compacted_conversation.clone()); - if let Some(session_to_store) = &session { - SessionManager::replace_conversation(&session_to_store.id, &compacted_conversation).await? - } + } + + yield AgentEvent::Message( + Message::assistant().with_system_notification( + SystemNotificationType::ThinkingMessage, + COMPACTION_THINKING_TEXT, + ) + ); + + match crate::context_mgmt::compact_messages(self, &conversation_to_compact, false).await { + Ok((compacted_conversation, _token_counts, _summarization_usage)) => { + if let Some(session_to_store) = &session { + SessionManager::replace_conversation(&session_to_store.id, &compacted_conversation).await?; + } + + yield AgentEvent::HistoryReplaced(compacted_conversation.clone()); - let mut reply_stream = self.reply_internal(compacted_conversation, session, cancel_token).await?; - while let Some(event) = reply_stream.next().await { - yield event?; + 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?; + } + } } - })) - } 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 compact: {e}.\n\nPlease try again or create a new session") + )); + } + } + })) } /// Main reply method that handles the actual agent processing @@ -1138,23 +1162,29 @@ 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. Compacting to continue conversation...", + ) + ); + yield AgentEvent::Message( + Message::assistant().with_system_notification( + SystemNotificationType::ThinkingMessage, + COMPACTION_THINKING_TEXT, + ) + ); - // 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)) => { + 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::Message( - Message::assistant().with_conversation_compacted( - "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? - } continue; } Err(e) => { diff --git a/crates/goose/src/context_mgmt/mod.rs b/crates/goose/src/context_mgmt/mod.rs index a56c87006d1f..8a8480877246 100644 --- a/crates/goose/src/context_mgmt/mod.rs +++ b/crates/goose/src/context_mgmt/mod.rs @@ -93,14 +93,6 @@ 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() - .with_conversation_compacted("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); - // 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) @@ -281,7 +273,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..f432d3292e75 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,13 @@ 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)) + .with_metadata(MessageMetadata::user_only()) } /// Set the visibility metadata for the message 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 354100b96c5c..c1d627a6778d 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 3e585e3f0a85..258d624842df 100644 --- a/crates/goose/src/providers/formats/databricks.rs +++ b/crates/goose/src/providers/formats/databricks.rs @@ -128,7 +128,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 e488272c1779..b82af34b2121 100644 --- a/crates/goose/src/providers/formats/openai.rs +++ b/crates/goose/src/providers/formats/openai.rs @@ -96,7 +96,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 05a4c000e15d..2eb77961951b 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": [] - } - ] - } - }, "/diagnostics/{session_id}": { "get": { "tags": [ @@ -2195,69 +2151,12 @@ } ] }, - "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": { "$ref": "#/components/schemas/Message" } }, - "ConversationCompacted": { - "type": "object", - "required": [ - "msg" - ], - "properties": { - "msg": { - "type": "string" - } - } - }, "CreateRecipeRequest": { "type": "object", "required": [ @@ -3249,7 +3148,7 @@ { "allOf": [ { - "$ref": "#/components/schemas/ConversationCompacted" + "$ref": "#/components/schemas/SystemNotificationContent" }, { "type": "object", @@ -3260,7 +3159,7 @@ "type": { "type": "string", "enum": [ - "conversationCompacted" + "systemNotification" ] } } @@ -4310,6 +4209,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/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 23b33eac006c..b9c1e13db532 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, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, 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, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, 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 diagnostics = (options: Options) => { return (options.client ?? client).get({ url: '/diagnostics/{session_id}', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 04299bb4d1a9..db794a291aaa 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -67,40 +67,8 @@ 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 ConversationCompacted = { - msg: string; -}; - export type CreateRecipeRequest = { author?: AuthorRequest | null; session_id: string; @@ -388,8 +356,8 @@ export type MessageContent = (TextContent & { type: 'thinking'; }) | (RedactedThinkingContent & { type: 'redactedThinking'; -}) | (ConversationCompacted & { - type: 'conversationCompacted'; +}) | (SystemNotificationContent & { + type: 'systemNotification'; }); /** @@ -762,6 +730,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; @@ -1563,37 +1538,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 DiagnosticsData = { body?: never; path: { diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index a5b336a73815..c5a96bcdcb3b 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -46,11 +46,11 @@ import { useLocation } from 'react-router-dom'; import { SearchView } from './conversation/SearchView'; import { RecipeHeader } from './RecipeHeader'; import LoadingGoose from './LoadingGoose'; +import { getThinkingMessage } from '../types/message'; 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 +115,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 { @@ -382,26 +381,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), @@ -432,15 +417,13 @@ function BaseChatContent({ {/* Fixed loading indicator at bottom left of chat container */} - {(chatState !== ChatState.Idle || loadingChat || isCompacting) && ( + {(chatState !== ChatState.Idle || loadingChat) && (
@@ -465,7 +448,6 @@ function BaseChatContent({ droppedFiles={droppedFiles} onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing messages={messages} - setMessages={setMessages} disableAnimation={disableAnimation} sessionCosts={sessionCosts} setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} @@ -515,9 +497,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 e1924fedbbd7..d578e18c115c 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -2,9 +2,9 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { SearchView } from './conversation/SearchView'; import LoadingGoose from './LoadingGoose'; +import { getThinkingMessage } from '../types/message'; import PopularChatTopics from './PopularChatTopics'; import ProgressiveMessageList from './ProgressiveMessageList'; -import { ContextManagerProvider } from './context_management/ContextManager'; import { MainPanelLayout } from './Layout/MainPanelLayout'; import ChatInput from './ChatInput'; import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area'; @@ -168,8 +168,6 @@ function BaseChatContent({ const showPopularTopics = messages.length === 0 && !initialMessage && chatState === ChatState.Idle; - // TODO(Douwe): get this from the backend - const isCompacting = false; const chat: ChatType = { messageHistoryIndex: 0, @@ -183,11 +181,10 @@ function BaseChatContent({ // Map chatState to LoadingGoose message const getLoadingMessage = (): string | undefined => { - if (isCompacting) return 'goose is compacting the conversation...'; if (messages.length === 0 && chatState === ChatState.Thinking) { return 'loading conversation...'; } - return undefined; + return getThinkingMessage(messages[messages.length - 1]); }; return (
@@ -258,7 +255,8 @@ function BaseChatContent({ ) : null} - {(chatState !== ChatState.Idle || isCompacting) && !sessionLoadError && ( + {/* Fixed loading indicator at bottom left of chat container */} + {chatState !== ChatState.Idle && !sessionLoadError && (
@@ -282,7 +280,6 @@ function BaseChatContent({ droppedFiles={droppedFiles} onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing messages={messages} - setMessages={(_m) => {}} disableAnimation={disableAnimation} sessionCosts={sessionCosts} setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} @@ -331,9 +328,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 f4d8ade5ed6b..62c446b62645 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -19,7 +19,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'; @@ -52,6 +51,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; @@ -71,7 +73,6 @@ interface ChatInputProps { inputTokens?: number; outputTokens?: number; messages?: Message[]; - setMessages: (messages: Message[]) => void; sessionCosts?: { [key: string]: { inputTokens: number; @@ -105,7 +106,6 @@ export default function ChatInput({ inputTokens, outputTokens, messages = [], - setMessages, disableAnimation = false, sessionCosts, setIsGoosehintsModalOpen, @@ -115,7 +115,7 @@ export default function ChatInput({ initialPrompt, toolCount, autoSubmit = false, - append, + append: _append, isExtensionsLoading = false, }: ChatInputProps) { const [_value, setValue] = useState(initialValue); @@ -137,7 +137,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); @@ -523,9 +522,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', @@ -534,12 +530,15 @@ export default function ChatInput({ total: tokenLimit, }, showCompactButton: true, - compactButtonDisabled, + compactButtonDisabled: !numTokens, 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 window.dispatchEvent(new CustomEvent('hide-alert-popover')); - handleManualCompaction(messages, setMessages, append, sessionId || ''); + + const customEvent = new CustomEvent('submit', { + detail: { value: MANUAL_COMPACT_TRIGGER }, + }) as unknown as React.FormEvent; + + handleSubmit(customEvent); }, compactIcon: , autoCompactThreshold: autoCompactThreshold, @@ -569,7 +568,6 @@ export default function ChatInput({ tokenLimit, isTokenLimitLoaded, addAlert, - isCompacting, clearAlerts, autoCompactThreshold, ]); @@ -940,7 +938,6 @@ export default function ChatInput({ const canSubmit = !isLoading && - !isCompacting && agentIsReady && (displayValue.trim() || pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) || @@ -1097,7 +1094,6 @@ export default function ChatInput({ e.preventDefault(); const canSubmit = !isLoading && - !isCompacting && agentIsReady && (displayValue.trim() || pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) || @@ -1152,7 +1148,6 @@ export default function ChatInput({ isAnyDroppedFileLoading || isRecording || isTranscribing || - isCompacting || !agentIsReady || isExtensionsLoading; @@ -1397,17 +1392,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 3c39a97ed5be..78f11090e419 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -18,8 +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 { useContextManager } from './context_management/ContextManager'; +import { SystemNotificationInline } from './context_management/SystemNotificationInline'; import { NotificationEvent } from '../hooks/useMessageStream'; import LoadingGoose from './LoadingGoose'; import { ChatType } from '../types/chat'; @@ -68,17 +67,12 @@ 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; - } + 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 useEffect(() => { @@ -187,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 ( @@ -196,37 +203,25 @@ export default function ProgressiveMessageList({ data-testid="message-container" > {isUser ? ( - <> - {hasCompactionMarker && hasCompactionMarker(message) ? ( - - ) : ( - !hasOnlyToolResponses(message) && ( - - ) - )} - + !hasOnlyToolResponses(message) && ( + + ) ) : ( - <> - {hasCompactionMarker && hasCompactionMarker(message) ? ( - - ) : ( - - )} - + )}
); @@ -243,7 +238,6 @@ export default function ProgressiveMessageList({ toolCallNotifications, isStreamingMessage, onMessageUpdate, - hasCompactionMarker, ]); return ( diff --git a/ui/desktop/src/components/context_management/CompactionMarker.tsx b/ui/desktop/src/components/context_management/CompactionMarker.tsx deleted file mode 100644 index 870e034cfdba..000000000000 --- a/ui/desktop/src/components/context_management/CompactionMarker.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import { Message, ConversationCompacted } 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 markerText = compactionContent?.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 deleted file mode 100644 index 7ef110aed8b5..000000000000 --- a/ui/desktop/src/components/context_management/ContextManager.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import React, { createContext, useContext, useState, useCallback } from 'react'; -import { manageContextFromBackend } from './index'; -import { Message } from '../../api'; - -// Define the context management interface -interface ContextManagerState { - isCompacting: boolean; - compactionError: string | null; -} - -interface ContextManagerActions { - handleManualCompaction: ( - messages: Message[], - setMessages: (messages: 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: 'conversationCompacted', - msg: 'Compaction failed. Please try again or start a new session.', - }, - ], - 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 === 'conversationCompacted'); - }, []); - - 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 new file mode 100644 index 000000000000..71b0763a8a5b --- /dev/null +++ b/ui/desktop/src/components/context_management/SystemNotificationInline.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Message, SystemNotificationContent } from '../../api'; + +interface SystemNotificationInlineProps { + message: Message; +} + +export const SystemNotificationInline: React.FC = ({ message }) => { + const systemNotification = message.content.find( + (content): content is SystemNotificationContent & { type: 'systemNotification' } => + content.type === 'systemNotification' && content.notificationType === 'inlineMessage' + ); + + if (!systemNotification?.msg) { + return null; + } + + 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 756d1cf14960..000000000000 --- a/ui/desktop/src/components/context_management/__tests__/CompactionMarker.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { CompactionMarker } from '../CompactionMarker'; -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 conversationCompacted 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 conversationCompacted content', () => { - const message: Message = { - ...default_message, - content: [ - { type: 'text', text: 'Some other content' }, - { type: 'conversationCompacted', msg: 'Custom compaction message' }, - ], - }; - - 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 summarizationRequested content with empty msg', () => { - const message: Message = { - ...default_message, - content: [{ type: 'conversationCompacted', msg: '' }], - }; - - render(); - - // Empty string falls back to default due to || operator - expect(screen.getByText('Conversation compacted')).toBeInTheDocument(); - }); - - it('should handle summarizationRequested content with undefined msg', () => { - const message: Message = { - ...default_message, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - content: [{ type: 'conversationCompacted' } 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 82887370e321..000000000000 --- a/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx +++ /dev/null @@ -1,283 +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 summarizationRequested content', () => { - const { result } = renderContextManager(); - const messageWithMarker: Message = { - ...default_message, - content: [{ type: 'conversationCompacted', msg: 'Compaction marker' }], - }; - - expect(result.current.hasCompactionMarker(messageWithMarker)).toBe(true); - }); - - it('should return false for messages without summarizationRequested 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 conversationCompacted', () => { - const { result } = renderContextManager(); - const mixedMessage: Message = { - ...default_message, - content: [ - { type: 'text', text: 'Some text' }, - { type: 'conversationCompacted', msg: 'Compaction marker' }, - ], - }; - - 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: 'conversationCompacted', msg: 'Conversation compacted and summarized' }, - ], - }, - { - ...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: 'conversationCompacted', msg: 'Conversation compacted and summarized' }, - ], - }, - { - ...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 8cc252badf4d..4ae2e64978d9 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'; import { startAgent } from '../api'; @@ -65,35 +64,32 @@ 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={[]} + 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 1f2a9f1f8b21..bbf618baf864 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 + /> + +
) : (
diff --git a/ui/desktop/src/hooks/useMessageStream.ts b/ui/desktop/src/hooks/useMessageStream.ts index 248c108744ff..44ffa2527018 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,6 +307,10 @@ export function useMessageStream({ mutateChatState(ChatState.WaitingForUserInput); } + if (getThinkingMessage(newMessage)) { + mutateChatState(ChatState.Thinking); + } + mutate(currentMessages, false); break; } diff --git a/ui/desktop/src/types/message.ts b/ui/desktop/src/types/message.ts index f09354c10c70..9498ffec32b1 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; +}