Replace compaction notifications with system notifications (#5218)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()))
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+24
-103
@@ -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": [
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user