diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index c42694cd8c6..369040375a9 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -182,6 +182,7 @@ pub enum AgentThreadEntry { AssistantMessage(AssistantMessage), ToolCall(ToolCall), CompletedPlan(Vec), + ContextCompaction, } impl AgentThreadEntry { @@ -191,6 +192,7 @@ impl AgentThreadEntry { Self::AssistantMessage(message) => message.indented, Self::ToolCall(_) => false, Self::CompletedPlan(_) => false, + Self::ContextCompaction => false, } } @@ -207,6 +209,7 @@ impl AgentThreadEntry { } md } + Self::ContextCompaction => "--- Context Compacted ---\n\n".to_string(), } } @@ -1467,7 +1470,8 @@ impl AcpThread { }) => return true, AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) - | AgentThreadEntry::CompletedPlan(_) => {} + | AgentThreadEntry::CompletedPlan(_) + | AgentThreadEntry::ContextCompaction => {} } } false @@ -1495,7 +1499,8 @@ impl AcpThread { } AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) - | AgentThreadEntry::CompletedPlan(_) => {} + | AgentThreadEntry::CompletedPlan(_) + | AgentThreadEntry::ContextCompaction => {} } } @@ -1514,7 +1519,8 @@ impl AcpThread { } AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) - | AgentThreadEntry::CompletedPlan(_) => {} + | AgentThreadEntry::CompletedPlan(_) + | AgentThreadEntry::ContextCompaction => {} } } @@ -1525,9 +1531,9 @@ impl AcpThread { for entry in self.entries.iter().rev() { match entry { AgentThreadEntry::UserMessage(..) => return false, - AgentThreadEntry::AssistantMessage(..) | AgentThreadEntry::CompletedPlan(..) => { - continue; - } + AgentThreadEntry::AssistantMessage(..) + | AgentThreadEntry::CompletedPlan(..) + | AgentThreadEntry::ContextCompaction => continue, AgentThreadEntry::ToolCall(..) => return true, } } @@ -1871,6 +1877,10 @@ impl AcpThread { cx.emit(AcpThreadEvent::NewEntry); } + pub fn push_context_compaction(&mut self, cx: &mut Context) { + self.push_entry(AgentThreadEntry::ContextCompaction, cx); + } + pub fn can_set_title(&mut self, cx: &mut Context) -> bool { self.connection.set_title(&self.session_id, cx).is_some() } diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 9f8dc9f242e..a50931d4217 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -1853,6 +1853,11 @@ impl NativeAgentConnection { thread.update_retry_status(status, cx) })?; } + ThreadEvent::ContextCompaction => { + acp_thread.update(cx, |thread, cx| { + thread.push_context_compaction(cx); + })?; + } ThreadEvent::Stop(stop_reason) => { log::debug!("Assistant message complete: {:?}", stop_reason); return Ok(acp::PromptResponse::new(stop_reason)); diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 4a5efc2e1cc..aaa746e7ead 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -123,11 +123,39 @@ enum RetryStrategy { }, } -#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Serialize, Deserialize)] pub enum Message { User(UserMessage), Agent(AgentMessage), Resume, + Compaction(CompactionInfo), +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub enum CompactionInfo { + Summary(SharedString), + ProviderNative { + provider: LanguageModelProviderId, + items: Vec, + }, +} + +impl CompactionInfo { + fn to_request(&self) -> Vec { + match self { + Self::Summary(summary) => vec![LanguageModelRequestMessage { + role: Role::User, + content: vec![format!( + "The previous conversation was compacted. Use this summary as context:\n\n{}", + summary + ) + .into()], + cache: false, + reasoning_details: None, + }], + Self::ProviderNative { .. } => Vec::new(), + } + } } impl Message { @@ -148,6 +176,7 @@ impl Message { } } Message::Agent(message) => message.to_request(), + Message::Compaction(info) => info.to_request(), Message::Resume => vec![LanguageModelRequestMessage { role: Role::User, content: vec!["Continue where you left off".into()], @@ -162,12 +191,13 @@ impl Message { Message::User(message) => message.to_markdown(), Message::Agent(message) => message.to_markdown(), Message::Resume => "[resume]\n".into(), + Message::Compaction(_) => "--- Context Compacted ---\n".into(), } } pub fn role(&self) -> Role { match self { - Message::User(_) | Message::Resume => Role::User, + Message::User(_) | Message::Resume | Message::Compaction(_) => Role::User, Message::Agent(_) => Role::Assistant, } } @@ -688,6 +718,7 @@ pub enum ThreadEvent { ToolCallAuthorization(ToolCallAuthorization), SubagentSpawned(acp::SessionId), Retry(acp_thread::RetryStatus), + ContextCompaction, Stop(acp::StopReason), } @@ -1225,6 +1256,7 @@ impl Thread { } } Message::Resume => {} + Message::Compaction(_) => stream.send_context_compaction(), } } rx @@ -1834,7 +1866,7 @@ impl Thread { Message::User(message) => { self.request_token_usage.remove(&message.id); } - Message::Agent(_) | Message::Resume => {} + Message::Agent(_) | Message::Resume | Message::Compaction(_) => {} } } self.clear_summary(); @@ -2919,8 +2951,7 @@ impl Thread { .rev() .find_map(|message| match &**message { Message::User(user_message) => Some(user_message), - Message::Agent(_) => None, - Message::Resume => None, + Message::Agent(_) | Message::Resume | Message::Compaction(_) => None, }) } @@ -3225,7 +3256,7 @@ impl Thread { match &**message { Message::User(_) => markdown.push_str("## User\n\n"), Message::Agent(_) => markdown.push_str("## Assistant\n\n"), - Message::Resume => {} + Message::Resume | Message::Compaction(_) => {} } markdown.push_str(&message.to_markdown()); } @@ -3801,6 +3832,12 @@ impl ThreadEventStream { self.0.unbounded_send(Ok(ThreadEvent::Retry(status))).ok(); } + fn send_context_compaction(&self) { + self.0 + .unbounded_send(Ok(ThreadEvent::ContextCompaction)) + .ok(); + } + fn send_stop(&self, reason: acp::StopReason) { self.0.unbounded_send(Ok(ThreadEvent::Stop(reason))).ok(); } @@ -4543,6 +4580,75 @@ mod tests { }) } + #[test] + fn test_summary_compaction_renders_for_request_and_markdown() { + let message = Message::Compaction(CompactionInfo::Summary("Older context".into())); + + assert_eq!(message.role(), Role::User); + assert_eq!(message.to_markdown(), "--- Context Compacted ---\n"); + + let request_messages = message.to_request(); + assert_eq!(request_messages.len(), 1); + assert_eq!(request_messages[0].role, Role::User); + assert!(!request_messages[0].cache); + assert_eq!(request_messages[0].reasoning_details, None); + assert_eq!(request_messages[0].content.len(), 1); + let language_model::MessageContent::Text(text) = &request_messages[0].content[0] else { + panic!("expected text summary context"); + }; + assert_eq!( + text.as_str(), + "The previous conversation was compacted. Use this summary as context:\n\nOlder context" + ); + } + + #[gpui::test] + async fn test_replay_emits_context_compaction(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + let user_message_id = UserMessageId::new(); + + let mut replay_events = cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.messages.push(Arc::new(Message::User(UserMessage { + id: user_message_id.clone(), + content: vec![UserMessageContent::Text("before".to_string())].into(), + }))); + thread + .messages + .push(Arc::new(Message::Compaction(CompactionInfo::Summary( + "summary".into(), + )))); + thread.messages.push(Arc::new(Message::Agent(AgentMessage { + content: vec![AgentMessageContent::Text("after".to_string())], + ..Default::default() + }))); + + thread.replay(cx) + }) + }); + + let event = replay_events.next().await; + assert!( + matches!( + &event, + Some(Ok(ThreadEvent::UserMessage(UserMessage { id, .. }))) if id == &user_message_id + ), + "expected replayed user message, got {event:?}" + ); + + let event = replay_events.next().await; + assert!( + matches!(&event, Some(Ok(ThreadEvent::ContextCompaction))), + "expected context compaction event, got {event:?}" + ); + + let event = replay_events.next().await; + assert!( + matches!(&event, Some(Ok(ThreadEvent::AgentText(text))) if text == "after"), + "expected replayed agent text, got {event:?}" + ); + } + fn setup_parent_with_subagents( cx: &mut TestAppContext, parent: &Entity, diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index f972c6f72ab..6a26bef3228 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -3621,6 +3621,7 @@ mod tests { acp_thread::AgentThreadEntry::AssistantMessage(_) => "assistant", acp_thread::AgentThreadEntry::ToolCall(_) => "tool_call", acp_thread::AgentThreadEntry::CompletedPlan(_) => "plan", + acp_thread::AgentThreadEntry::ContextCompaction => "compaction", }) .collect::>() }); diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 3eec4c93ff0..b19168d38b7 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -5393,6 +5393,19 @@ impl ThreadView { AgentThreadEntry::CompletedPlan(entries) => { self.render_completed_plan(entries, window, cx) } + AgentThreadEntry::ContextCompaction => h_flex() + .id(("context_compaction", entry_ix)) + .px_5() + .py_1() + .gap_2() + .child(Divider::horizontal()) + .child( + Label::new("Context Compacted") + .size(LabelSize::Custom(self.tool_name_font_size())) + .color(Color::Muted), + ) + .child(Divider::horizontal()) + .into_any(), }; let is_subagent_output = self.is_subagent() @@ -6502,7 +6515,8 @@ impl ThreadView { } AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) - | AgentThreadEntry::CompletedPlan(_) => {} + | AgentThreadEntry::CompletedPlan(_) + | AgentThreadEntry::ContextCompaction => {} } } diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index 9d2b030638f..85db4e32e48 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -232,6 +232,11 @@ impl EntryViewState { self.set_entry(index, Entry::CompletedPlan); } } + AgentThreadEntry::ContextCompaction => { + if !matches!(self.entries.get(index), Some(Entry::ContextCompaction)) { + self.set_entry(index, Entry::ContextCompaction); + } + } }; } @@ -252,7 +257,8 @@ impl EntryViewState { match entry { Entry::UserMessage { .. } | Entry::AssistantMessage { .. } - | Entry::CompletedPlan => {} + | Entry::CompletedPlan + | Entry::ContextCompaction => {} Entry::ToolCall(ToolCallEntry { content, .. }) => { for view in content.values() { if let Ok(diff_editor) = view.clone().downcast::() { @@ -321,6 +327,7 @@ pub enum Entry { AssistantMessage(AssistantMessageEntry), ToolCall(ToolCallEntry), CompletedPlan, + ContextCompaction, } impl Entry { @@ -329,14 +336,17 @@ impl Entry { Self::UserMessage(editor) => Some(editor.read(cx).focus_handle(cx)), Self::AssistantMessage(message) => Some(message.focus_handle.clone()), Self::ToolCall(tool_call) => Some(tool_call.focus_handle.clone()), - Self::CompletedPlan => None, + Self::CompletedPlan | Self::ContextCompaction => None, } } pub fn message_editor(&self) -> Option<&Entity> { match self { Self::UserMessage(editor) => Some(editor), - Self::AssistantMessage(_) | Self::ToolCall(_) | Self::CompletedPlan => None, + Self::AssistantMessage(_) + | Self::ToolCall(_) + | Self::CompletedPlan + | Self::ContextCompaction => None, } } @@ -363,7 +373,10 @@ impl Entry { ) -> Option { match self { Self::AssistantMessage(message) => message.scroll_handle_for_chunk(chunk_ix), - Self::UserMessage(_) | Self::ToolCall(_) | Self::CompletedPlan => None, + Self::UserMessage(_) + | Self::ToolCall(_) + | Self::CompletedPlan + | Self::ContextCompaction => None, } } @@ -378,7 +391,10 @@ impl Entry { pub fn has_content(&self) -> bool { match self { Self::ToolCall(ToolCallEntry { content, .. }) => !content.is_empty(), - Self::UserMessage(_) | Self::AssistantMessage(_) | Self::CompletedPlan => false, + Self::UserMessage(_) + | Self::AssistantMessage(_) + | Self::CompletedPlan + | Self::ContextCompaction => false, } } } @@ -395,7 +411,7 @@ impl Focusable for Entry { Self::UserMessage(editor) => editor.read(cx).focus_handle(cx), Self::AssistantMessage(message) => message.focus_handle.clone(), Self::ToolCall(tool_call) => tool_call.focus_handle.clone(), - Self::CompletedPlan => cx.focus_handle(), + Self::CompletedPlan | Self::ContextCompaction => cx.focus_handle(), } } } diff --git a/crates/language_model_core/src/language_model_core.rs b/crates/language_model_core/src/language_model_core.rs index b25c837bc46..c2b7011ab2b 100644 --- a/crates/language_model_core/src/language_model_core.rs +++ b/crates/language_model_core/src/language_model_core.rs @@ -371,7 +371,7 @@ pub struct LanguageModelId(pub SharedString); #[derive(Clone, Eq, PartialEq, Hash, Debug, Ord, PartialOrd)] pub struct LanguageModelName(pub SharedString); -#[derive(Clone, Eq, PartialEq, Hash, Debug, Ord, PartialOrd)] +#[derive(Clone, Eq, PartialEq, Hash, Debug, Ord, PartialOrd, Serialize, Deserialize)] pub struct LanguageModelProviderId(pub SharedString); #[derive(Clone, Eq, PartialEq, Hash, Debug, Ord, PartialOrd)]