Replace compaction notifications with system notifications (#5218)

This commit is contained in:
David Katz
2025-10-22 16:09:44 -04:00
committed by GitHub
parent 755e9f893d
commit 23412e270f
31 changed files with 350 additions and 1060 deletions
+2 -2
View File
@@ -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(
+12 -2
View File
@@ -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(&notification.msg);
}
SystemNotificationType::InlineMessage => {
println!("\n{}", style(&notification.msg).yellow());
}
}
}
_ => {
println!("WARNING: Message content type could not be rendered");
+5 -6
View File
@@ -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,
-68
View File
@@ -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<Message>,
/// 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<Message>,
/// Token counts for each processed message
pub token_counts: Vec<usize>,
}
#[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<Arc<AppState>>,
Json(request): Json<ContextManageRequest>,
) -> Result<Json<ContextManageResponse>, 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<AppState>) -> Router {
Router::new()
.route("/context/manage", post(manage_context))
.with_state(state)
}
-2
View File
@@ -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<crate::state::AppState>) -> 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()))
+104 -74
View File
@@ -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<SessionConfig>,
cancel_token: Option<CancellationToken>,
) -> Result<BoxStream<'_, Result<AgentEvent>>> {
// 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 (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)),
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
}
})
});
if !is_manual_compact {
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;
}
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::<f64>("GOOSE_AUTO_COMPACT_THRESHOLD")
.unwrap_or(DEFAULT_COMPACTION_THRESHOLD);
let threshold_percentage = (threshold * 100.0) as u32;
let conversation_to_compact = unfixed_conversation.clone();
let compaction_msg = format!(
"Exceeded auto-compact threshold of {}%. Context has been summarized and reduced.\n\n",
threshold_percentage
Ok(Box::pin(async_stream::try_stream! {
if !is_manual_compact {
let config = crate::config::Config::global();
let threshold = config
.get_param::<f64>("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,
inline_msg,
)
);
}
yield AgentEvent::Message(
Message::assistant().with_system_notification(
SystemNotificationType::ThinkingMessage,
COMPACTION_THINKING_TEXT,
)
);
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)
);
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, &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?;
}
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::HistoryReplaced(compacted_conversation.clone());
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) => {
+3 -9
View File
@@ -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();
+31 -12
View File
@@ -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<S: Into<String>>(msg: S) -> Self {
MessageContent::ConversationCompacted(ConversationCompacted { msg: msg.into() })
pub fn system_notification<S: Into<String>>(
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<S: Into<String>>(self, msg: S) -> Self {
self.with_content(MessageContent::conversation_compacted(msg))
pub fn with_system_notification<S: Into<String>>(
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
@@ -90,7 +90,7 @@ pub fn format_messages(messages: &[Message]) -> Vec<Value> {
MessageContent::ToolConfirmationRequest(_tool_confirmation_request) => {
// Skip tool confirmation requests
}
MessageContent::ConversationCompacted(_) => {
MessageContent::SystemNotification(_) => {
// Skip
}
MessageContent::Thinking(thinking) => {
@@ -48,8 +48,8 @@ pub fn to_bedrock_message_content(content: &MessageContent) -> Result<bedrock::C
// Redacted thinking blocks are not supported in Bedrock - skip
bedrock::ContentBlock::Text("".to_string())
}
MessageContent::ConversationCompacted(_) => {
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();
@@ -128,7 +128,7 @@ fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<Data
}
}
}
MessageContent::ConversationCompacted(_) => {
MessageContent::SystemNotification(_) => {
continue;
}
MessageContent::ToolResponse(response) => {
+1 -1
View File
@@ -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 {
@@ -53,7 +53,7 @@ pub fn format_messages(messages: &[Message]) -> Vec<Value> {
MessageContent::ToolConfirmationRequest(_) => {
// Skip tool confirmation requests
}
MessageContent::ConversationCompacted(_) => {
MessageContent::SystemNotification(_) => {
// Skip
}
MessageContent::Thinking(_thinking) => {
View File
+24 -103
View File
@@ -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": [
+1 -12
View File
@@ -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<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
@@ -245,17 +245,6 @@ export const confirmPermission = <ThrowOnError extends boolean = false>(options:
});
};
export const manageContext = <ThrowOnError extends boolean = false>(options: Options<ManageContextData, ThrowOnError>) => {
return (options.client ?? client).post<ManageContextResponses, ManageContextErrors, ThrowOnError>({
url: '/context/manage',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
export const diagnostics = <ThrowOnError extends boolean = false>(options: Options<DiagnosticsData, ThrowOnError>) => {
return (options.client ?? client).get<DiagnosticsResponses, DiagnosticsErrors, ThrowOnError>({
url: '/diagnostics/{session_id}',
+9 -65
View File
@@ -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<Message>;
/**
* Optional session ID for session-specific agent
*/
sessionId: string;
};
/**
* Response from context management operations
*/
export type ContextManageResponse = {
/**
* Processed messages after the operation
*/
messages: Array<Message>;
/**
* Token counts for each processed message
*/
tokenCounts: Array<number>;
};
export type Conversation = Array<Message>;
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: {
+5 -27
View File
@@ -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<string | null>(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'}
</div>
{/* Action buttons for all errors including token limit errors */}
{/* Action button to retry last message */}
<div className="flex gap-2 mt-2">
<div
className="px-3 py-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"
onClick={async () => {
clearError();
await handleManualCompaction(
messages,
setMessages,
append,
chat.sessionId
);
}}
>
Summarize Conversation
</div>
<div
className="px-3 py-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"
onClick={async () => {
// Find the last user message
const lastUserMessage = messages.reduceRight(
(found, m) => found || (m.role === 'user' ? m : null),
@@ -432,15 +417,13 @@ function BaseChatContent({
</ScrollArea>
{/* Fixed loading indicator at bottom left of chat container */}
{(chatState !== ChatState.Idle || loadingChat || isCompacting) && (
{(chatState !== ChatState.Idle || loadingChat) && (
<div className="absolute bottom-1 left-4 z-20 pointer-events-none">
<LoadingGoose
message={
loadingChat
? 'loading conversation...'
: isCompacting
? 'goose is compacting the conversation...'
: undefined
: getThinkingMessage(messages[messages.length - 1])
}
chatState={chatState}
/>
@@ -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 (
<ContextManagerProvider>
<BaseChatContent {...props} />
</ContextManagerProvider>
);
return <BaseChatContent {...props} />;
}
+5 -12
View File
@@ -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 (
<div className="h-full flex flex-col min-h-0">
@@ -258,7 +255,8 @@ function BaseChatContent({
) : null}
</ScrollArea>
{(chatState !== ChatState.Idle || isCompacting) && !sessionLoadError && (
{/* Fixed loading indicator at bottom left of chat container */}
{chatState !== ChatState.Idle && !sessionLoadError && (
<div className="absolute bottom-1 left-4 z-20 pointer-events-none">
<LoadingGoose message={getLoadingMessage()} chatState={chatState} />
</div>
@@ -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 (
<ContextManagerProvider>
<BaseChatContent {...props} />
</ContextManagerProvider>
);
return <BaseChatContent {...props} />;
}
+20 -27
View File
@@ -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<HTMLDivElement> = useRef<HTMLDivElement>(
null
) as React.RefObject<HTMLDivElement>;
const { isCompacting, handleManualCompaction } = useContextManager();
const { getProviders, read } = useConfig();
const { getCurrentModelAndProvider, currentModel, currentProvider } = useModelAndProvider();
const [tokenLimit, setTokenLimit] = useState<number>(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: <ScrollText size={12} />,
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({
<p>
{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')}
</p>
</TooltipContent>
</Tooltip>
@@ -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 (
<div
key={message.id && `${message.id}-${message.content.length}`}
className={`relative ${index === 0 ? 'mt-0' : 'mt-4'} assistant`}
data-testid="message-container"
>
<SystemNotificationInline message={message} />
</div>
);
}
const isUser = isUserMessage(message);
return (
@@ -196,37 +203,25 @@ export default function ProgressiveMessageList({
data-testid="message-container"
>
{isUser ? (
<>
{hasCompactionMarker && hasCompactionMarker(message) ? (
<CompactionMarker message={message} />
) : (
!hasOnlyToolResponses(message) && (
<UserMessage message={message} onMessageUpdate={onMessageUpdate} />
)
)}
</>
!hasOnlyToolResponses(message) && (
<UserMessage message={message} onMessageUpdate={onMessageUpdate} />
)
) : (
<>
{hasCompactionMarker && hasCompactionMarker(message) ? (
<CompactionMarker message={message} />
) : (
<GooseMessage
sessionId={chat.sessionId}
messageHistoryIndex={chat.messageHistoryIndex}
message={message}
messages={messages}
append={append}
appendMessage={appendMessage}
toolCallNotifications={toolCallNotifications}
isStreaming={
isStreamingMessage &&
!isUser &&
index === messagesToRender.length - 1 &&
message.role === 'assistant'
}
/>
)}
</>
<GooseMessage
sessionId={chat.sessionId}
messageHistoryIndex={chat.messageHistoryIndex}
message={message}
messages={messages}
append={append}
appendMessage={appendMessage}
toolCallNotifications={toolCallNotifications}
isStreaming={
isStreamingMessage &&
!isUser &&
index === messagesToRender.length - 1 &&
message.role === 'assistant'
}
/>
)}
</div>
);
@@ -243,7 +238,6 @@ export default function ProgressiveMessageList({
toolCallNotifications,
isStreamingMessage,
onMessageUpdate,
hasCompactionMarker,
]);
return (
@@ -1,17 +0,0 @@
import React from 'react';
import { Message, ConversationCompacted } from '../../api';
interface CompactionMarkerProps {
message: Message;
}
export const CompactionMarker: React.FC<CompactionMarkerProps> = ({ message }) => {
const compactionContent = message.content.find(
(content): content is ConversationCompacted & { type: 'conversationCompacted' } =>
content.type === 'conversationCompacted'
);
const markerText = compactionContent?.msg || 'Conversation compacted';
return <div className="text-xs text-gray-400 py-2 text-left">{markerText}</div>;
};
@@ -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<void>;
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<boolean>(false);
const [compactionError, setCompactionError] = useState<string | null>(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 <ContextManagerContext.Provider value={value}>{children}</ContextManagerContext.Provider>;
};
// 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;
};
@@ -0,0 +1,19 @@
import React from 'react';
import { Message, SystemNotificationContent } from '../../api';
interface SystemNotificationInlineProps {
message: Message;
}
export const SystemNotificationInline: React.FC<SystemNotificationInlineProps> = ({ message }) => {
const systemNotification = message.content.find(
(content): content is SystemNotificationContent & { type: 'systemNotification' } =>
content.type === 'systemNotification' && content.notificationType === 'inlineMessage'
);
if (!systemNotification?.msg) {
return null;
}
return <div className="text-xs text-gray-400 py-2 text-left">{systemNotification.msg}</div>;
};
@@ -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(<CompactionMarker message={message} />);
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(<CompactionMarker message={message} />);
expect(screen.getByText('Custom compaction message')).toBeInTheDocument();
});
it('should handle empty message content array', () => {
const message: Message = {
...default_message,
content: [],
};
render(<CompactionMarker message={message} />);
expect(screen.getByText('Conversation compacted')).toBeInTheDocument();
});
it('should handle summarizationRequested content with empty msg', () => {
const message: Message = {
...default_message,
content: [{ type: 'conversationCompacted', msg: '' }],
};
render(<CompactionMarker message={message} />);
// 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(<CompactionMarker message={message} />);
// Should render the default message when msg is undefined
expect(screen.getByText('Conversation compacted')).toBeInTheDocument();
});
});
@@ -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 }) => <ContextManagerProvider>{children}</ContextManagerProvider>,
});
};
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');
});
});
});
@@ -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<ContextManageResponse> {
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;
}
+26 -30
View File
@@ -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 (
<ContextManagerProvider>
<div className="flex flex-col h-full bg-background-muted">
<div className="flex-1 flex flex-col mb-0.5">
<SessionInsights />
</div>
<ChatInput
sessionId={null}
handleSubmit={handleSubmit}
autoSubmit={false}
chatState={ChatState.Idle}
onStop={() => {}}
commandHistory={[]}
initialValue=""
setView={setView}
numTokens={0}
inputTokens={0}
outputTokens={0}
droppedFiles={[]}
onFilesProcessed={() => {}}
messages={[]}
setMessages={() => {}}
disableAnimation={false}
sessionCosts={undefined}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
isExtensionsLoading={isExtensionsLoading}
toolCount={0}
/>
<div className="flex flex-col h-full bg-background-muted">
<div className="flex-1 flex flex-col mb-0.5">
<SessionInsights />
</div>
</ContextManagerProvider>
<ChatInput
sessionId={null}
handleSubmit={handleSubmit}
autoSubmit={false}
chatState={ChatState.Idle}
onStop={() => {}}
commandHistory={[]}
initialValue=""
setView={setView}
numTokens={0}
inputTokens={0}
outputTokens={0}
droppedFiles={[]}
onFilesProcessed={() => {}}
messages={[]}
disableAnimation={false}
sessionCosts={undefined}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
isExtensionsLoading={isExtensionsLoading}
toolCount={0}
/>
</div>
);
}
@@ -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<{
</Button>
</div>
) : filteredMessages?.length > 0 ? (
<ContextManagerProvider>
<div className="max-w-4xl mx-auto w-full">
<SearchView placeholder="Search history...">
<ProgressiveMessageList
messages={filteredMessages}
chat={{
sessionId: 'session-preview',
messageHistoryIndex: filteredMessages.length,
}}
toolCallNotifications={new Map()}
append={() => {}} // 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
/>
</SearchView>
</div>
</ContextManagerProvider>
<div className="max-w-4xl mx-auto w-full">
<SearchView placeholder="Search history...">
<ProgressiveMessageList
messages={filteredMessages}
chat={{
sessionId: 'session-preview',
messageHistoryIndex: filteredMessages.length,
}}
toolCallNotifications={new Map()}
append={() => {}} // 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
/>
</SearchView>
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-textSubtle">
<MessageSquareText className="w-12 h-12 mb-4" />
+5 -1
View File
@@ -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;
}
+14
View File
@@ -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;
}