agent: Make messages vec cheap to clone (#57712)

For long threads we will spend more and more time cloning the messages
just to save them to the database, as we need a copy of everything to do
so asynchronously. Messages are really expensive to clone though and we
accumulate a lot of them really fast, so even for smaller threads we
start seeing pauses in the millisecond range. The fix to this is fairly
simple though, we never mutate the messages once pushed to the vec, so
just Arc them.

This PR also slightly changes `UserMessage` to be a bit faster to clone
as well.

Release Notes:

- Fixed a cause of stutters when interacting with the agent
This commit is contained in:
Lukas Wirth authored and GitHub committed 2026-05-26 10:00:34 +00:00
1 parent 49fe1fcad5
commit 63ff99795c
10 files changed
+69 -66

No files matched your search

+2 -2
View File
@@ -7,14 +7,14 @@ use gpui::{Entity, SharedString, Task};
use language_model::LanguageModelProviderId;
use project::{AgentId, Project};
use serde::{Deserialize, Serialize};
use std::{any::Any, error::Error, fmt, path::PathBuf, rc::Rc, sync::Arc};
use std::{any::Any, error::Error, fmt, path::PathBuf, rc::Rc};
use task::{HideStrategy, SpawnInTerminal, TaskId};
use ui::{App, IconName};
use util::path_list::PathList;
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
pub struct UserMessageId(Arc<str>);
pub struct UserMessageId(SharedString);
impl UserMessageId {
pub fn new() -> Self {
+2 -2
View File
@@ -1844,10 +1844,10 @@ impl NativeAgentConnection {
match event {
ThreadEvent::UserMessage(message) => {
acp_thread.update(cx, |thread, cx| {
for content in message.content {
for content in &*message.content {
thread.push_user_content_block(
Some(message.id.clone()),
content.into(),
content.clone().into(),
cx,
);
}
+4 -4
View File
@@ -53,7 +53,7 @@ impl From<&DbThreadMetadata> for acp_thread::AgentSessionInfo {
#[derive(Debug, Serialize, Deserialize)]
pub struct DbThread {
pub title: SharedString,
pub messages: Vec<DbMessage>,
pub messages: Vec<Arc<DbMessage>>,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub detailed_summary: Option<SharedString>,
@@ -92,7 +92,7 @@ pub struct SerializedScrollPosition {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SharedThread {
pub title: SharedString,
pub messages: Vec<DbMessage>,
pub messages: Vec<Arc<DbMessage>>,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub model: Option<DbLanguageModel>,
@@ -206,7 +206,7 @@ impl DbThread {
crate::Message::User(UserMessage {
// MessageId from old format can't be meaningfully converted, so generate a new one
id,
content,
content: Arc::from(content),
})
}
language_model::Role::Assistant => {
@@ -285,7 +285,7 @@ impl DbThread {
}
};
messages.push(message);
messages.push(Arc::new(message));
}
Ok(Self {
+2 -2
View File
@@ -4133,8 +4133,8 @@ async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) {
events.collect::<Vec<_>>().await;
thread.read_with(cx, |thread, _cx| {
assert_eq!(
thread.last_received_or_pending_message(),
Some(Message::Agent(AgentMessage {
thread.last_received_or_pending_message().as_deref(),
Some(&Message::Agent(AgentMessage {
content: vec![AgentMessageContent::Text("Done".into())],
tool_results: IndexMap::default(),
reasoning_details: None,
+41 -38
View File
@@ -123,7 +123,7 @@ enum RetryStrategy {
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Message {
User(UserMessage),
Agent(AgentMessage),
@@ -176,13 +176,16 @@ impl Message {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UserMessage {
pub id: UserMessageId,
pub content: Vec<UserMessageContent>,
pub content: Arc<[UserMessageContent]>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum UserMessageContent {
Text(String),
Mention { uri: MentionUri, content: String },
Mention {
uri: MentionUri,
content: SharedString,
},
Image(LanguageModelImage),
}
@@ -190,7 +193,7 @@ impl UserMessage {
pub fn to_markdown(&self) -> String {
let mut markdown = String::new();
for content in &self.content {
for content in &*self.content {
match content {
UserMessageContent::Text(text) => {
markdown.push_str(text);
@@ -250,7 +253,7 @@ impl UserMessage {
let mut merge_conflict_context = MERGE_CONFLICT_TAG.to_string();
let mut skills_context = OPEN_SKILLS_TAG.to_string();
for chunk in &self.content {
for chunk in &*self.content {
let chunk = match chunk {
UserMessageContent::Text(text) => {
language_model::MessageContent::Text(text.clone())
@@ -266,7 +269,7 @@ impl UserMessage {
"\n{}",
MarkdownCodeBlock {
tag: &codeblock_tag(abs_path, None),
text: &content.to_string(),
text: content,
}
)
.ok();
@@ -627,9 +630,9 @@ impl AgentMessage {
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentMessage {
pub content: Vec<AgentMessageContent>,
pub tool_results: IndexMap<LanguageModelToolUseId, LanguageModelToolResult>,
pub reasoning_details: Option<serde_json::Value>,
pub(crate) content: Vec<AgentMessageContent>,
pub(crate) tool_results: IndexMap<LanguageModelToolUseId, LanguageModelToolResult>,
pub(crate) reasoning_details: Option<Arc<serde_json::Value>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -963,7 +966,7 @@ pub struct Thread {
title_generation_failed: bool,
pending_summary_generation: Option<Shared<Task<Option<SharedString>>>>,
summary: Option<SharedString>,
messages: Vec<Message>,
messages: Vec<Arc<Message>>,
user_store: Entity<UserStore>,
/// Holds the task that handles agent interaction until the end of the turn.
/// Survives across multiple requests as the model performs tool calls and
@@ -1183,7 +1186,7 @@ impl Thread {
let (tx, rx) = mpsc::unbounded();
let stream = ThreadEventStream(tx);
for message in &self.messages {
match message {
match &**message {
Message::User(user_message) => stream.send_user_message(user_message),
Message::Agent(assistant_message) => {
for content in &assistant_message.content {
@@ -1639,13 +1642,13 @@ impl Thread {
}
pub fn last_message(&self) -> Option<&Message> {
self.messages.last()
self.messages.last().map(std::ops::Deref::deref)
}
#[cfg(any(test, feature = "test-support"))]
pub fn last_received_or_pending_message(&self) -> Option<Message> {
pub fn last_received_or_pending_message(&self) -> Option<Arc<Message>> {
if let Some(message) = self.pending_message.clone() {
Some(Message::Agent(message))
Some(Arc::new(Message::Agent(message)))
} else {
self.messages.last().cloned()
}
@@ -1802,13 +1805,13 @@ impl Thread {
// and we don't want that content to be added after we truncate
self.pending_message.take();
let Some(position) = self.messages.iter().position(
|msg| matches!(msg, Message::User(UserMessage { id, .. }) if id == &message_id),
|msg| matches!(&**msg, Message::User(UserMessage { id, .. }) if id == &message_id),
) else {
return Err(anyhow!("Message not found"));
};
for message in self.messages.drain(position..) {
match message {
match &*message {
Message::User(message) => {
self.request_token_usage.remove(&message.id);
}
@@ -1850,7 +1853,7 @@ impl Thread {
let mut previous_user_message_id: Option<&UserMessageId> = None;
for message in &self.messages {
if let Message::User(user_msg) = message {
if let Message::User(user_msg) = &**message {
if &user_msg.id == target_id {
let prev_id = previous_user_message_id?;
let usage = self.request_token_usage.get(prev_id)?;
@@ -1895,7 +1898,7 @@ impl Thread {
&mut self,
cx: &mut Context<Self>,
) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> {
self.messages.push(Message::Resume);
self.messages.push(Arc::new(Message::Resume));
cx.notify();
log::debug!("Total messages in thread: {}", self.messages.len());
@@ -1914,11 +1917,11 @@ impl Thread {
where
T: Into<UserMessageContent>,
{
let content = content.into_iter().map(Into::into).collect::<Vec<_>>();
let content = content.into_iter().map(Into::into).collect::<Arc<_>>();
log::debug!("Thread::send content: {:?}", content);
self.messages
.push(Message::User(UserMessage { id, content }));
.push(Arc::new(Message::User(UserMessage { id, content })));
cx.notify();
self.send_existing(cx)
@@ -1949,9 +1952,9 @@ impl Thread {
let content = blocks
.into_iter()
.map(|block| UserMessageContent::from_content_block(block, path_style))
.collect::<Vec<_>>();
.collect::<Arc<_>>();
self.messages
.push(Message::User(UserMessage { id, content }));
.push(Arc::new(Message::User(UserMessage { id, content })));
cx.notify();
}
@@ -1969,10 +1972,10 @@ impl Thread {
_ => "[unknown]".to_string(),
};
self.messages.push(Message::Agent(AgentMessage {
self.messages.push(Arc::new(Message::Agent(AgentMessage {
content: vec![AgentMessageContent::Text(text)],
..Default::default()
}));
})));
cx.notify();
}
@@ -2230,10 +2233,10 @@ impl Thread {
}
}
this.update(cx, |this, _cx| {
if let Some(Message::Agent(message)) = this.messages.last() {
if let Some(Message::Agent(message)) = this.last_message() {
if message.tool_results.is_empty() {
intent = CompletionIntent::UserPrompt;
this.messages.push(Message::Resume);
this.messages.push(Arc::new(Message::Resume));
}
}
})?;
@@ -2356,12 +2359,12 @@ impl Thread {
let last_message = self.pending_message();
// Store the last non-empty reasoning_details (overwrites earlier ones)
// This ensures we keep the encrypted reasoning with signatures, not the early text reasoning
if let serde_json::Value::Array(ref arr) = details {
if let serde_json::Value::Array(arr) = &details {
if !arr.is_empty() {
last_message.reasoning_details = Some(details);
last_message.reasoning_details = Some(Arc::new(details));
}
} else {
last_message.reasoning_details = Some(details);
last_message.reasoning_details = Some(Arc::new(details));
}
}
ToolUse(tool_use) => {
@@ -2895,7 +2898,7 @@ impl Thread {
self.messages
.iter()
.rev()
.find_map(|message| match message {
.find_map(|message| match &**message {
Message::User(user_message) => Some(user_message),
Message::Agent(_) => None,
Message::Resume => None,
@@ -2936,7 +2939,7 @@ impl Thread {
}
}
self.messages.push(Message::Agent(message));
self.messages.push(Arc::new(Message::Agent(message)));
self.updated_at = Utc::now();
self.clear_summary();
cx.notify()
@@ -3199,7 +3202,7 @@ impl Thread {
if ix > 0 {
markdown.push('\n');
}
match message {
match &**message {
Message::User(_) => markdown.push_str("## User\n\n"),
Message::Agent(_) => markdown.push_str("## Assistant\n\n"),
Message::Resume => {}
@@ -4411,7 +4414,7 @@ impl UserMessageContent {
match MentionUri::parse(&resource_link.uri, path_style) {
Ok(uri) => Self::Mention {
uri,
content: String::new(),
content: SharedString::default(),
},
Err(err) => {
log::error!("Failed to parse mention link: {}", err);
@@ -4424,7 +4427,7 @@ impl UserMessageContent {
match MentionUri::parse(&resource.uri, path_style) {
Ok(uri) => Self::Mention {
uri,
content: resource.text,
content: resource.text.into(),
},
Err(err) => {
log::error!("Failed to parse mention link: {}", err);
@@ -4626,14 +4629,14 @@ mod tests {
},
);
thread.messages.push(Message::Agent(AgentMessage {
thread.messages.push(Arc::new(Message::Agent(AgentMessage {
content: vec![
AgentMessageContent::ToolUse(registered_tool_use),
AgentMessageContent::ToolUse(missing_tool_use),
],
tool_results,
reasoning_details: None,
}));
})));
thread.replay(cx)
})
@@ -4784,11 +4787,11 @@ mod tests {
},
);
thread.messages.push(Message::Agent(AgentMessage {
thread.messages.push(Arc::new(Message::Agent(AgentMessage {
content: vec![AgentMessageContent::ToolUse(tool_use)],
tool_results,
reasoning_details: None,
}));
})));
}
#[gpui::test]
+1 -1
View File
@@ -293,7 +293,7 @@ pub struct LanguageModelRequestMessage {
pub content: Vec<MessageContent>,
pub cache: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_details: Option<serde_json::Value>,
pub reasoning_details: Option<Arc<serde_json::Value>>,
}
impl LanguageModelRequestMessage {
@@ -1299,7 +1299,7 @@ fn into_copilot_responses(
Role::Assistant => {
append_reasoning_details_to_response_items(
message.reasoning_details.as_ref(),
message.reasoning_details.as_deref(),
&mut replayed_reasoning_item_indexes,
&mut input_items,
);
@@ -1608,7 +1608,7 @@ mod tests {
MessageContent::Text("Done".into()),
],
cache: false,
reasoning_details: Some(json!({
reasoning_details: Some(Arc::new(json!({
"reasoning_items": [
{
"id": "r1",
@@ -1621,7 +1621,7 @@ mod tests {
"encrypted_content": "ENC"
}
]
})),
}))),
}],
..Default::default()
};
@@ -540,7 +540,7 @@ fn add_message_content_part(
new_part: open_router::MessagePart,
role: Role,
messages: &mut Vec<open_router::RequestMessage>,
reasoning_details: Option<serde_json::Value>,
reasoning_details: Option<Arc<serde_json::Value>>,
) {
match (role, messages.last_mut()) {
(Role::User, Some(open_router::RequestMessage::User { content }))
+12 -12
View File
@@ -302,14 +302,14 @@ fn append_message_to_response_items(
..
} = message;
let phase = if role == Role::Assistant {
response_message_phase_from_details(reasoning_details.as_ref())
response_message_phase_from_details(reasoning_details.as_deref())
} else {
None
};
if role == Role::Assistant {
append_reasoning_details_to_response_items(
reasoning_details.as_ref(),
reasoning_details.as_deref(),
replayed_reasoning_item_indexes,
input_items,
);
@@ -1483,7 +1483,7 @@ mod tests {
role: Role::Assistant,
content: vec![MessageContent::ToolUse(tool_use)],
cache: false,
reasoning_details: Some(json!({
reasoning_details: Some(Arc::new(json!({
"reasoning_items": [
{
"id": "rs_123",
@@ -1503,7 +1503,7 @@ mod tests {
"status": "completed",
}
]
})),
}))),
}],
tools: Vec::new(),
tool_choice: None,
@@ -1571,7 +1571,7 @@ mod tests {
role: Role::Assistant,
content: vec![MessageContent::Text("Done.".into())],
cache: false,
reasoning_details: Some(json!({
reasoning_details: Some(Arc::new(json!({
"reasoning_items": [
{
"id": "rs_123",
@@ -1585,7 +1585,7 @@ mod tests {
"status": "completed"
}
]
})),
}))),
}],
tools: Vec::new(),
tool_choice: None,
@@ -1754,7 +1754,7 @@ mod tests {
role: Role::Assistant,
content: vec![MessageContent::Text("Done.".into())],
cache: false,
reasoning_details: Some(json!({
reasoning_details: Some(Arc::new(json!({
"phase": "final_answer",
"reasoning_items": [
{
@@ -1764,7 +1764,7 @@ mod tests {
"status": "completed"
}
]
})),
}))),
}],
tools: Vec::new(),
tool_choice: None,
@@ -1846,13 +1846,13 @@ mod tests {
role: Role::Assistant,
content: vec![MessageContent::Text("First.".into())],
cache: false,
reasoning_details: Some(first_reasoning_details),
reasoning_details: Some(Arc::new(first_reasoning_details)),
},
LanguageModelRequestMessage {
role: Role::Assistant,
content: vec![MessageContent::Text("Second.".into())],
cache: false,
reasoning_details: Some(second_reasoning_details),
reasoning_details: Some(Arc::new(second_reasoning_details)),
},
],
tools: Vec::new(),
@@ -1926,7 +1926,7 @@ mod tests {
MessageContent::Text("This is visible assistant output.".into()),
],
cache: false,
reasoning_details: Some(json!({
reasoning_details: Some(Arc::new(json!({
"reasoning_items": [
{
"id": "rs_123",
@@ -1940,7 +1940,7 @@ mod tests {
"status": "completed"
}
]
})),
}))),
}],
tools: Vec::new(),
tool_choice: None,
+1 -1
View File
@@ -212,7 +212,7 @@ pub enum RequestMessage {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
tool_calls: Vec<ToolCall>,
#[serde(default, skip_serializing_if = "Option::is_none")]
reasoning_details: Option<serde_json::Value>,
reasoning_details: Option<std::sync::Arc<serde_json::Value>>,
},
User {
content: MessageContent,