Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35ff43de7f | ||
|
|
d7fbc09bcf |
Generated
+1
@@ -11612,6 +11612,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"diesel",
|
"diesel",
|
||||||
"diesel_migrations",
|
"diesel_migrations",
|
||||||
|
"galaxy_agent_core",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"warp_multi_agent_api",
|
"warp_multi_agent_api",
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ pub fn convert_conversation_data_to_ai_conversation(
|
|||||||
let agent_conversation_data = match restoration_mode {
|
let agent_conversation_data = match restoration_mode {
|
||||||
RestorationMode::Fork => AgentConversationData {
|
RestorationMode::Fork => AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
|
provider_context: None,
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
local_agent_groups: Vec::new(),
|
local_agent_groups: Vec::new(),
|
||||||
tool_titles: Default::default(),
|
tool_titles: Default::default(),
|
||||||
@@ -99,6 +100,7 @@ pub fn convert_conversation_data_to_ai_conversation(
|
|||||||
},
|
},
|
||||||
RestorationMode::Continue => AgentConversationData {
|
RestorationMode::Continue => AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
|
provider_context: None,
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
local_agent_groups: Vec::new(),
|
local_agent_groups: Vec::new(),
|
||||||
tool_titles: Default::default(),
|
tool_titles: Default::default(),
|
||||||
@@ -357,7 +359,13 @@ impl ConvertToExchanges for &api::Task {
|
|||||||
let task_id = TaskId::new(api_message.task_id.clone());
|
let task_id = TaskId::new(api_message.task_id.clone());
|
||||||
// Check if request_id has changed - if so, create an exchange from accumulated messages
|
// Check if request_id has changed - if so, create an exchange from accumulated messages
|
||||||
let message_request_id = if api_message.request_id.is_empty() {
|
let message_request_id = if api_message.request_id.is_empty() {
|
||||||
None
|
// Older local snapshots omitted request IDs. A user query still
|
||||||
|
// gives us an unambiguous boundary; keep following output with it.
|
||||||
|
if matches!(message, api::message::Message::UserQuery(_)) {
|
||||||
|
Some(api_message.id.clone())
|
||||||
|
} else {
|
||||||
|
current_request_id.clone()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Some(api_message.request_id.clone())
|
Some(api_message.request_id.clone())
|
||||||
};
|
};
|
||||||
@@ -464,12 +472,18 @@ impl ConvertToExchanges for &api::Task {
|
|||||||
}
|
}
|
||||||
api::message::Message::ToolCallResult(tool_call_result) => {
|
api::message::Message::ToolCallResult(tool_call_result) => {
|
||||||
// Try to convert tool call result - returns None for ServerToolCalls
|
// Try to convert tool call result - returns None for ServerToolCalls
|
||||||
if let Some(input) = convert_tool_call_result_to_input(
|
if let Some(mut input) = convert_tool_call_result_to_input(
|
||||||
&task_id,
|
&task_id,
|
||||||
tool_call_result,
|
tool_call_result,
|
||||||
&tool_call_map,
|
&tool_call_map,
|
||||||
&mut document_versions,
|
&mut document_versions,
|
||||||
) {
|
) {
|
||||||
|
crate::ai::agent::provider_history::restore_result_details(
|
||||||
|
&mut input,
|
||||||
|
&api_message.server_message_data,
|
||||||
|
message_map.get(tool_call_result.tool_call_id.as_str())
|
||||||
|
.map(|proposal| proposal.server_message_data.as_str()),
|
||||||
|
);
|
||||||
// Add tool call result as input
|
// Add tool call result as input
|
||||||
current_inputs.push(input);
|
current_inputs.push(input);
|
||||||
}
|
}
|
||||||
@@ -1865,15 +1879,19 @@ fn create_exchange_from_messages(
|
|||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
// Fall back to any timestamp from the messages in this exchange
|
// Message IDs are a set, so choose the earliest timestamp rather than
|
||||||
|
// whichever message happens to be visited first.
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
message_ids.iter().find_map(|message_id| {
|
message_ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|message_id| {
|
||||||
message_map.get(message_id.as_str()).and_then(|message| {
|
message_map.get(message_id.as_str()).and_then(|message| {
|
||||||
message.timestamp.as_ref().map(|timestamp| {
|
message.timestamp.as_ref().map(|timestamp| {
|
||||||
proto_timestamp_to_local_datetime(timestamp.seconds, timestamp.nanos)
|
proto_timestamp_to_local_datetime(timestamp.seconds, timestamp.nanos)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
.min()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,8 @@ fn convert_start_agent_v2_execution_mode(
|
|||||||
/// Unexpected errors when trying to convert an [`api::Message`] to an [`AIAgentOutputMessage`].
|
/// Unexpected errors when trying to convert an [`api::Message`] to an [`AIAgentOutputMessage`].
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum MessageToAIAgentOutputMessageError {
|
pub enum MessageToAIAgentOutputMessageError {
|
||||||
|
#[error("Invalid saved provider tool: {0}")]
|
||||||
|
SavedProviderTool(String),
|
||||||
#[error("Missing expected message")]
|
#[error("Missing expected message")]
|
||||||
MissingMessage,
|
MissingMessage,
|
||||||
#[error("Error converting tool to action: {0:?}")]
|
#[error("Error converting tool to action: {0:?}")]
|
||||||
@@ -262,6 +264,16 @@ impl ConvertAPIMessageToClientOutputMessage for api::Message {
|
|||||||
self,
|
self,
|
||||||
params: ConversionParams,
|
params: ConversionParams,
|
||||||
) -> Result<MaybeAIAgentOutputMessage, MessageToAIAgentOutputMessageError> {
|
) -> Result<MaybeAIAgentOutputMessage, MessageToAIAgentOutputMessageError> {
|
||||||
|
if let Some(action) =
|
||||||
|
crate::ai::agent::provider_history::restore_tool(&self.server_message_data)
|
||||||
|
{
|
||||||
|
return Ok(MaybeAIAgentOutputMessage::Message(
|
||||||
|
AIAgentOutputMessage::action(
|
||||||
|
MessageId::new(self.id),
|
||||||
|
action.map_err(MessageToAIAgentOutputMessageError::SavedProviderTool)?,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
let Some(message) = self.message else {
|
let Some(message) = self.message else {
|
||||||
// In shared-session streams we can receive skeleton placeholder task messages without payloads.
|
// In shared-session streams we can receive skeleton placeholder task messages without payloads.
|
||||||
// Treat them as having no client representation rather than erroring and aborting ingestion entirely.
|
// Treat them as having no client representation rather than erroring and aborting ingestion entirely.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use galaxy_core::ui::theme::GalaxyTheme;
|
|||||||
use galaxyui::color::ColorU;
|
use galaxyui::color::ColorU;
|
||||||
use galaxyui::{AppContext, EntityId, ModelContext, SingletonEntity};
|
use galaxyui::{AppContext, EntityId, ModelContext, SingletonEntity};
|
||||||
use itertools::Itertools as _;
|
use itertools::Itertools as _;
|
||||||
use persistence::model::LocalAgentGroup;
|
use persistence::model::{LocalAgentGroup, ProviderConversationContext};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use vec1::{Size0Error, Vec1};
|
use vec1::{Size0Error, Vec1};
|
||||||
@@ -69,7 +69,6 @@ use crate::persistence::model::{
|
|||||||
};
|
};
|
||||||
use crate::persistence::ModelEvent;
|
use crate::persistence::ModelEvent;
|
||||||
use crate::server::ids::ServerId;
|
use crate::server::ids::ServerId;
|
||||||
use crate::terminal::general_settings::GeneralSettings;
|
|
||||||
use crate::terminal::model::block::{
|
use crate::terminal::model::block::{
|
||||||
AgentInteractionMetadata, AgentViewVisibility, BlockId, SerializedAIMetadata, SerializedBlock,
|
AgentInteractionMetadata, AgentViewVisibility, BlockId, SerializedAIMetadata, SerializedBlock,
|
||||||
};
|
};
|
||||||
@@ -453,11 +452,27 @@ impl AIConversation {
|
|||||||
tasks: Vec<api::Task>,
|
tasks: Vec<api::Task>,
|
||||||
conversation_data: Option<AgentConversationData>,
|
conversation_data: Option<AgentConversationData>,
|
||||||
) -> Result<Self, RestoreConversationError> {
|
) -> Result<Self, RestoreConversationError> {
|
||||||
let bedrock_message_history: Vec<crate::ai::provider::convert::ConversationMessage> = tasks
|
let provider_context = conversation_data
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|data| data.provider_context.clone());
|
||||||
|
let (bedrock_message_history, tool_result_archive) = match provider_context {
|
||||||
|
Some(context) => (context.messages, context.tool_result_archive),
|
||||||
|
None => {
|
||||||
|
// Legacy rows have only UI task messages. Preserve that fallback, but
|
||||||
|
// do not use it for new rows, whose provider context may be compacted.
|
||||||
|
let messages: Vec<_> = tasks
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|task| task.messages.iter())
|
.flat_map(|task| task.messages.iter())
|
||||||
.filter_map(crate::ai::provider::request_translator::convert_proto_message)
|
.filter_map(crate::ai::provider::request_translator::convert_proto_message)
|
||||||
.collect();
|
.collect();
|
||||||
|
// Legacy UI messages do not share provider-message indices. Do not
|
||||||
|
// truncate them using a provider compaction count: retain everything
|
||||||
|
// recoverable and rebuild recall from the available tool messages.
|
||||||
|
let mut archive = Vec::new();
|
||||||
|
galaxy_agent_core::archive_tool_results(&mut archive, messages.clone());
|
||||||
|
(messages, archive)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let (task_store, todo_lists, status) = if tasks.is_empty() {
|
let (task_store, todo_lists, status) = if tasks.is_empty() {
|
||||||
// Bypass `derive_status_from_root_task`: it would return `Success`
|
// Bypass `derive_status_from_root_task`: it would return `Success`
|
||||||
@@ -575,7 +590,6 @@ impl AIConversation {
|
|||||||
last_event_sequence,
|
last_event_sequence,
|
||||||
pinned,
|
pinned,
|
||||||
progressive_summary,
|
progressive_summary,
|
||||||
messages_summarized_up_to,
|
|
||||||
) = if let Some(data) = conversation_data {
|
) = if let Some(data) = conversation_data {
|
||||||
let server_conversation_token = data
|
let server_conversation_token = data
|
||||||
.server_conversation_token
|
.server_conversation_token
|
||||||
@@ -626,7 +640,6 @@ impl AIConversation {
|
|||||||
data.last_event_sequence,
|
data.last_event_sequence,
|
||||||
data.pinned,
|
data.pinned,
|
||||||
data.progressive_summary.clone(),
|
data.progressive_summary.clone(),
|
||||||
data.messages_summarized_up_to,
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
(
|
(
|
||||||
@@ -647,7 +660,6 @@ impl AIConversation {
|
|||||||
None,
|
None,
|
||||||
false,
|
false,
|
||||||
None,
|
None,
|
||||||
0,
|
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -715,9 +727,10 @@ impl AIConversation {
|
|||||||
orchestration_configs: HashMap::new(),
|
orchestration_configs: HashMap::new(),
|
||||||
pinned,
|
pinned,
|
||||||
bedrock_message_history,
|
bedrock_message_history,
|
||||||
tool_result_archive: Vec::new(),
|
tool_result_archive,
|
||||||
progressive_summary,
|
progressive_summary,
|
||||||
messages_summarized_up_to,
|
// The previous response stream no longer exists; its drain count cannot apply.
|
||||||
|
messages_summarized_up_to: 0,
|
||||||
current_context_tokens: 0,
|
current_context_tokens: 0,
|
||||||
latest_model_call_usage: ProviderUsage::default(),
|
latest_model_call_usage: ProviderUsage::default(),
|
||||||
has_pending_progressive_summary: false,
|
has_pending_progressive_summary: false,
|
||||||
@@ -841,6 +854,19 @@ impl AIConversation {
|
|||||||
&mut self.bedrock_message_history
|
&mut self.bedrock_message_history
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn provider_context(&self) -> ProviderConversationContext {
|
||||||
|
ProviderConversationContext {
|
||||||
|
messages: self.bedrock_message_history.clone(),
|
||||||
|
tool_result_archive: self.tool_result_archive.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_provider_context(&mut self, context: ProviderConversationContext) {
|
||||||
|
self.bedrock_message_history = context.messages;
|
||||||
|
self.tool_result_archive = context.tool_result_archive;
|
||||||
|
self.messages_summarized_up_to = 0;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn tool_result_archive(&self) -> &[crate::ai::provider::convert::ConversationMessage] {
|
pub fn tool_result_archive(&self) -> &[crate::ai::provider::convert::ConversationMessage] {
|
||||||
&self.tool_result_archive
|
&self.tool_result_archive
|
||||||
}
|
}
|
||||||
@@ -2404,6 +2430,61 @@ impl AIConversation {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn save_provider_tool(
|
||||||
|
&mut self,
|
||||||
|
stream_id: &ResponseStreamId,
|
||||||
|
context: &crate::ai::runtime::ProviderActionContext,
|
||||||
|
call: &galaxy_agent_core::ToolCall,
|
||||||
|
action: &AIAgentAction,
|
||||||
|
ctx: &mut ModelContext<BlocklistAIHistoryModel>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let task_id = TaskId::new(context.task_id().to_owned());
|
||||||
|
let exchange_id = self
|
||||||
|
.added_exchanges_by_response
|
||||||
|
.get(stream_id)
|
||||||
|
.and_then(|exchanges| {
|
||||||
|
exchanges
|
||||||
|
.iter()
|
||||||
|
.find(|exchange| exchange.task_id == task_id)
|
||||||
|
})
|
||||||
|
.map(|exchange| exchange.exchange_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing provider tool exchange"))?;
|
||||||
|
let message = super::provider_history::tool_message(context, call, action)?;
|
||||||
|
self.task_store
|
||||||
|
.modify_task(&task_id, |task| {
|
||||||
|
task.save_display_message(message, exchange_id.to_string())
|
||||||
|
})
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing provider tool task"))??;
|
||||||
|
self.write_updated_conversation_state(ctx);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn save_provider_tool_result(
|
||||||
|
&mut self,
|
||||||
|
result: &super::AIAgentActionResult,
|
||||||
|
ctx: &mut ModelContext<BlocklistAIHistoryModel>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let request_id = self
|
||||||
|
.get_task(&result.task_id)
|
||||||
|
.and_then(|task| {
|
||||||
|
task.messages()
|
||||||
|
.find(|message| message.id == result.id.to_string())
|
||||||
|
})
|
||||||
|
.map(|message| message.request_id.clone());
|
||||||
|
// Hosted/session-owned tools already persist their results through API messages.
|
||||||
|
let Some(request_id) = request_id else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let message = super::provider_history::result_message(result)?;
|
||||||
|
self.task_store
|
||||||
|
.modify_task(&result.task_id, |task| {
|
||||||
|
task.save_display_message(message, request_id)
|
||||||
|
})
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing provider result task"))??;
|
||||||
|
self.write_updated_conversation_state(ctx);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn ensure_response_exchange_for_task(
|
fn ensure_response_exchange_for_task(
|
||||||
&mut self,
|
&mut self,
|
||||||
stream_id: &ResponseStreamId,
|
stream_id: &ResponseStreamId,
|
||||||
@@ -4016,10 +4097,8 @@ impl AIConversation {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if session restoration is enabled before writing any state.
|
// Conversation history is durable even when automatic session reopening is disabled.
|
||||||
if !*GeneralSettings::as_ref(ctx).restore_session
|
if !AppExecutionMode::as_ref(ctx).can_save_session() {
|
||||||
|| !AppExecutionMode::as_ref(ctx).can_save_session()
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4064,6 +4143,7 @@ impl AIConversation {
|
|||||||
.filter_map(|task| task.source_for_persistence())
|
.filter_map(|task| task.source_for_persistence())
|
||||||
.collect(),
|
.collect(),
|
||||||
conversation_data: AgentConversationData {
|
conversation_data: AgentConversationData {
|
||||||
|
provider_context: Some(self.provider_context()),
|
||||||
agent_backend: self.agent_backend.clone(),
|
agent_backend: self.agent_backend.clone(),
|
||||||
active_provider_run_json: self.active_provider_run_json.clone(),
|
active_provider_run_json: self.active_provider_run_json.clone(),
|
||||||
local_agent_groups: self.local_agent_groups.clone(),
|
local_agent_groups: self.local_agent_groups.clone(),
|
||||||
@@ -4097,14 +4177,9 @@ impl AIConversation {
|
|||||||
messages_summarized_up_to: self.messages_summarized_up_to,
|
messages_summarized_up_to: self.messages_summarized_up_to,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
ctx.spawn(
|
|
||||||
async move {
|
|
||||||
if let Err(e) = sqlite_sender.send(event) {
|
if let Err(e) = sqlite_sender.send(event) {
|
||||||
log::warn!("Failed to send updated AI tasks to sqlite writer thread: {e:?}");
|
log::error!("Failed to send updated AI tasks to sqlite writer thread: {e:?}");
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|_, _, _| {},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn rollback_transaction(&mut self, response_stream_id: &ResponseStreamId) {
|
pub fn rollback_transaction(&mut self, response_stream_id: &ResponseStreamId) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use galaxy_agent_core::{ContentPart, ConversationMessage, MessageContent, MessageRole};
|
||||||
use galaxy_core::features::FeatureFlag;
|
use galaxy_core::features::FeatureFlag;
|
||||||
use warp_multi_agent_api as api;
|
use warp_multi_agent_api as api;
|
||||||
|
|
||||||
@@ -8,7 +9,9 @@ use super::{
|
|||||||
ConversationStatus, RestoreConversationError,
|
ConversationStatus, RestoreConversationError,
|
||||||
};
|
};
|
||||||
use crate::ai::artifacts::Artifact;
|
use crate::ai::artifacts::Artifact;
|
||||||
use crate::persistence::model::{AcpConversationData, AgentBackend, AgentConversationData};
|
use crate::persistence::model::{
|
||||||
|
AcpConversationData, AgentBackend, AgentConversationData, ProviderConversationContext,
|
||||||
|
};
|
||||||
|
|
||||||
fn restored_conversation(conversation_data: Option<AgentConversationData>) -> AIConversation {
|
fn restored_conversation(conversation_data: Option<AgentConversationData>) -> AIConversation {
|
||||||
AIConversation::new_restored(
|
AIConversation::new_restored(
|
||||||
@@ -706,3 +709,62 @@ fn fetched_memories_dedupes_keeping_first_position_and_latest_data() {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restored_provider_context_preserves_compacted_history_and_tool_archive() {
|
||||||
|
let context = ProviderConversationContext {
|
||||||
|
messages: vec![ConversationMessage {
|
||||||
|
role: MessageRole::Assistant,
|
||||||
|
content: MessageContent::MultiPart(vec![
|
||||||
|
ContentPart::Reasoning {
|
||||||
|
text: "retained reasoning".into(),
|
||||||
|
signature: Some("signature".into()),
|
||||||
|
},
|
||||||
|
ContentPart::Text("retained response".into()),
|
||||||
|
]),
|
||||||
|
}],
|
||||||
|
tool_result_archive: vec![ConversationMessage {
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: MessageContent::ToolResult {
|
||||||
|
tool_use_id: "old-tool".into(),
|
||||||
|
content: "original output".into(),
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let data = AgentConversationData {
|
||||||
|
provider_context: Some(context.clone()),
|
||||||
|
progressive_summary: Some("Older history".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let data = serde_json::from_str(&serde_json::to_string(&data).unwrap()).unwrap();
|
||||||
|
let restored = restored_conversation(Some(data));
|
||||||
|
assert_eq!(restored.provider_context(), context);
|
||||||
|
assert_eq!(restored.progressive_summary(), Some("Older history"));
|
||||||
|
assert!(restored.active_provider_run_json().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_empty_provider_context_does_not_reconstruct_old_task_messages() {
|
||||||
|
let data = AgentConversationData {
|
||||||
|
provider_context: Some(ProviderConversationContext {
|
||||||
|
messages: Vec::new(),
|
||||||
|
tool_result_archive: Vec::new(),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let task = api::Task {
|
||||||
|
id: "root-task".into(),
|
||||||
|
messages: vec![user_query_message("query", "request", "old query")],
|
||||||
|
dependencies: None,
|
||||||
|
description: String::new(),
|
||||||
|
summary: String::new(),
|
||||||
|
server_data: String::new(),
|
||||||
|
};
|
||||||
|
let legacy =
|
||||||
|
AIConversation::new_restored(AIConversationId::new(), vec![task.clone()], None).unwrap();
|
||||||
|
assert!(!legacy.bedrock_message_history().is_empty());
|
||||||
|
let restored =
|
||||||
|
AIConversation::new_restored(AIConversationId::new(), vec![task], Some(data)).unwrap();
|
||||||
|
assert!(restored.bedrock_message_history().is_empty());
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub(crate) mod api;
|
|||||||
pub(crate) mod comment;
|
pub(crate) mod comment;
|
||||||
pub(crate) mod icons;
|
pub(crate) mod icons;
|
||||||
pub(crate) mod linearization;
|
pub(crate) mod linearization;
|
||||||
|
pub(crate) mod provider_history;
|
||||||
pub(crate) mod redaction;
|
pub(crate) mod redaction;
|
||||||
pub(crate) mod runtime_activity;
|
pub(crate) mod runtime_activity;
|
||||||
pub(crate) mod task;
|
pub(crate) mod task;
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
//! Durable display history for direct providers, independent of their compacted model transcript.
|
||||||
|
use galaxy_agent_core::ToolCall;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use warp_multi_agent_api as api;
|
||||||
|
|
||||||
|
use super::{AIAgentAction, AIAgentActionResult};
|
||||||
|
use crate::ai::runtime::ProviderActionContext;
|
||||||
|
|
||||||
|
const TOOL_PREFIX: &str = "galaxy:provider-tool:v1:";
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct SavedTool {
|
||||||
|
context: ProviderActionContext,
|
||||||
|
call: ToolCall,
|
||||||
|
#[serde(default)]
|
||||||
|
delegated_base_prompt: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
question_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn tool_message(
|
||||||
|
context: &ProviderActionContext,
|
||||||
|
call: &ToolCall,
|
||||||
|
action: &AIAgentAction,
|
||||||
|
) -> anyhow::Result<api::Message> {
|
||||||
|
// Keep the native call as well as the compatibility representation. The native
|
||||||
|
// form retains MCP aliases and distinctions such as notebook vs. plan tools.
|
||||||
|
let compatible = context.history_compatibility_call(call);
|
||||||
|
let event = crate::ai::provider::response_translator::build_tool_call_message(
|
||||||
|
context.task_id(),
|
||||||
|
&call.id,
|
||||||
|
&compatible.name,
|
||||||
|
&compatible.arguments.to_string(),
|
||||||
|
);
|
||||||
|
let mut message = match event.r#type {
|
||||||
|
Some(api::response_event::Type::ClientActions(actions)) => actions
|
||||||
|
.actions
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|action| match action.action {
|
||||||
|
Some(api::client_action::Action::AddMessagesToTask(messages)) => {
|
||||||
|
messages.messages.into_iter().next()
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing tool history message"))?,
|
||||||
|
_ => anyhow::bail!("missing tool history event"),
|
||||||
|
};
|
||||||
|
// Native action IDs must remain identical across execution and restoration.
|
||||||
|
message.id = call.id.clone();
|
||||||
|
if let Some(api::message::Message::ToolCall(tool)) = &mut message.message {
|
||||||
|
tool.tool_call_id = call.id.clone();
|
||||||
|
} else {
|
||||||
|
message.message = Some(api::message::Message::ToolCall(api::message::ToolCall {
|
||||||
|
tool_call_id: call.id.clone(),
|
||||||
|
tool: None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
message.server_message_data = format!(
|
||||||
|
"{TOOL_PREFIX}{}",
|
||||||
|
serde_json::to_string(&SavedTool {
|
||||||
|
context: context.clone(),
|
||||||
|
call: call.clone(),
|
||||||
|
question_ids: match &action.action {
|
||||||
|
super::AIAgentActionType::AskUserQuestion { questions } => questions
|
||||||
|
.iter()
|
||||||
|
.map(|question| question.question_id.clone())
|
||||||
|
.collect(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
},
|
||||||
|
delegated_base_prompt: match &action.action {
|
||||||
|
super::AIAgentActionType::RunAgents(request) => Some(request.base_prompt.clone()),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
})?
|
||||||
|
);
|
||||||
|
Ok(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn restore_tool(data: &str) -> Option<Result<AIAgentAction, String>> {
|
||||||
|
let json = data.strip_prefix(TOOL_PREFIX)?;
|
||||||
|
Some(
|
||||||
|
serde_json::from_str::<SavedTool>(json)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
.and_then(|saved| {
|
||||||
|
let mut action = saved.context.restored_action_from_tool_call(&saved.call)?;
|
||||||
|
if let (super::AIAgentActionType::RunAgents(request), Some(base_prompt)) =
|
||||||
|
(&mut action.action, saved.delegated_base_prompt)
|
||||||
|
{
|
||||||
|
request.base_prompt = base_prompt;
|
||||||
|
}
|
||||||
|
if let super::AIAgentActionType::AskUserQuestion { questions } = &mut action.action
|
||||||
|
{
|
||||||
|
for (question, id) in questions.iter_mut().zip(saved.question_ids) {
|
||||||
|
question.question_id = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(action)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESULT_PREFIX: &str = "galaxy:provider-result:v1:";
|
||||||
|
|
||||||
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
|
struct SavedResultDetails {
|
||||||
|
cancelled: bool,
|
||||||
|
file_edit: Option<(String, usize, usize)>,
|
||||||
|
command_error: Option<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supplement fields that the server's request/result protocol does not round-trip.
|
||||||
|
/// This is display metadata and is never used to execute an action.
|
||||||
|
pub(crate) fn restore_result_details(
|
||||||
|
input: &mut super::AIAgentInput,
|
||||||
|
data: &str,
|
||||||
|
proposal_data: Option<&str>,
|
||||||
|
) {
|
||||||
|
let Some(json) = data.strip_prefix(RESULT_PREFIX) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(details) = serde_json::from_str::<SavedResultDetails>(json) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let super::AIAgentInput::ActionResult { result, .. } = input else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if details.cancelled {
|
||||||
|
if let Some(Ok(action)) = proposal_data.and_then(restore_tool) {
|
||||||
|
result.result = action.action.cancelled_result();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let (
|
||||||
|
super::AIAgentActionResultType::RequestFileEdits(super::RequestFileEditsResult::Success {
|
||||||
|
diff,
|
||||||
|
lines_added,
|
||||||
|
lines_removed,
|
||||||
|
..
|
||||||
|
}),
|
||||||
|
Some((saved_diff, added, removed)),
|
||||||
|
) = (&mut result.result, details.file_edit)
|
||||||
|
{
|
||||||
|
*diff = saved_diff;
|
||||||
|
*lines_added = added;
|
||||||
|
*lines_removed = removed;
|
||||||
|
}
|
||||||
|
if let Some((command, message)) = details.command_error {
|
||||||
|
result.result = super::AIAgentActionResultType::RequestCommandOutput(
|
||||||
|
super::RequestCommandOutputResult::ExecutionError { command, message },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileGlob remains part of saved history even though the server deprecated it.
|
||||||
|
#[allow(deprecated)]
|
||||||
|
pub(crate) fn result_message(result: &AIAgentActionResult) -> anyhow::Result<api::Message> {
|
||||||
|
use api::message::tool_call_result::Result as Output;
|
||||||
|
use api::request::input::tool_call_result::Result as Input;
|
||||||
|
let details = SavedResultDetails {
|
||||||
|
cancelled: result.result.is_cancelled(),
|
||||||
|
file_edit: match &result.result {
|
||||||
|
super::AIAgentActionResultType::RequestFileEdits(
|
||||||
|
super::RequestFileEditsResult::Success {
|
||||||
|
diff,
|
||||||
|
lines_added,
|
||||||
|
lines_removed,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
) => Some((diff.clone(), *lines_added, *lines_removed)),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
command_error: match &result.result {
|
||||||
|
super::AIAgentActionResultType::RequestCommandOutput(
|
||||||
|
super::RequestCommandOutputResult::ExecutionError { command, message },
|
||||||
|
) => Some((command.clone(), message.clone())),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let input: api::request::input::user_inputs::user_input::Input = result.clone().try_into()?;
|
||||||
|
let api::request::input::user_inputs::user_input::Input::ToolCallResult(input) = input else {
|
||||||
|
anyhow::bail!("expected tool result");
|
||||||
|
};
|
||||||
|
// These protobuf oneofs use different field numbers; map variants explicitly.
|
||||||
|
let payload = input.result.map(|result| match result {
|
||||||
|
Input::RunShellCommand(value) => Output::RunShellCommand(value),
|
||||||
|
Input::ReadFiles(value) => Output::ReadFiles(value),
|
||||||
|
Input::SearchCodebase(value) => Output::SearchCodebase(value),
|
||||||
|
Input::ApplyFileDiffs(value) => Output::ApplyFileDiffs(value),
|
||||||
|
Input::SuggestPlan(value) => Output::SuggestPlan(value),
|
||||||
|
Input::SuggestCreatePlan(value) => Output::SuggestCreatePlan(value),
|
||||||
|
Input::Grep(value) => Output::Grep(value),
|
||||||
|
Input::FileGlob(value) => Output::FileGlob(value),
|
||||||
|
Input::ReadMcpResource(value) => Output::ReadMcpResource(value),
|
||||||
|
Input::CallMcpTool(value) => Output::CallMcpTool(value),
|
||||||
|
Input::WriteToLongRunningShellCommand(value) => {
|
||||||
|
Output::WriteToLongRunningShellCommand(value)
|
||||||
|
}
|
||||||
|
Input::SuggestNewConversation(value) => Output::SuggestNewConversation(value),
|
||||||
|
Input::FileGlobV2(value) => Output::FileGlobV2(value),
|
||||||
|
Input::SuggestPrompt(value) => Output::SuggestPrompt(value),
|
||||||
|
Input::OpenCodeReview(value) => Output::OpenCodeReview(value),
|
||||||
|
Input::InitProject(value) => Output::InitProject(value),
|
||||||
|
Input::ReadDocuments(value) => Output::ReadDocuments(value),
|
||||||
|
Input::EditDocuments(value) => Output::EditDocuments(value),
|
||||||
|
Input::CreateDocuments(value) => Output::CreateDocuments(value),
|
||||||
|
Input::ReadShellCommandOutput(value) => Output::ReadShellCommandOutput(value),
|
||||||
|
Input::UseComputer(value) => Output::UseComputer(value),
|
||||||
|
Input::InsertReviewComments(value) => Output::InsertReviewComments(value),
|
||||||
|
Input::RequestComputerUse(value) => Output::RequestComputerUseResult(value),
|
||||||
|
Input::ReadSkill(value) => Output::ReadSkill(value),
|
||||||
|
Input::FetchConversation(value) => Output::FetchConversation(value),
|
||||||
|
Input::StartAgent(value) => Output::StartAgent(value),
|
||||||
|
Input::SendMessageToAgent(value) => Output::SendMessageToAgent(value),
|
||||||
|
Input::TransferShellCommandControlToUser(value) => {
|
||||||
|
Output::TransferShellCommandControlToUser(value)
|
||||||
|
}
|
||||||
|
Input::AskUserQuestion(value) => Output::AskUserQuestion(value),
|
||||||
|
Input::StartAgentV2(value) => Output::StartAgentV2(value),
|
||||||
|
Input::UploadFileArtifact(value) => Output::UploadFileArtifact(value),
|
||||||
|
Input::RunAgentsResult(value) => Output::RunAgentsResult(value),
|
||||||
|
Input::WaitForEvents(value) => Output::WaitForEvents(value),
|
||||||
|
});
|
||||||
|
Ok(api::Message {
|
||||||
|
id: format!("galaxy-tool-result:{}", result.id),
|
||||||
|
server_message_data: format!("{RESULT_PREFIX}{}", serde_json::to_string(&details)?),
|
||||||
|
task_id: result.task_id.to_string(),
|
||||||
|
message: Some(api::message::Message::ToolCallResult(
|
||||||
|
api::message::ToolCallResult {
|
||||||
|
tool_call_id: input.tool_call_id,
|
||||||
|
context: None,
|
||||||
|
result: payload,
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "provider_history_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
use galaxy_agent_core::ToolCall;
|
||||||
|
use prost::Message;
|
||||||
|
use serde_json::json;
|
||||||
|
use warp_multi_agent_api as api;
|
||||||
|
|
||||||
|
use super::{result_message, tool_message};
|
||||||
|
use crate::ai::agent::api::convert_conversation::ConvertToExchanges;
|
||||||
|
use crate::ai::agent::{
|
||||||
|
AIAgentActionResult, AIAgentActionResultType, AIAgentInput, GrepResult,
|
||||||
|
RequestCommandOutputResult, RunAgentsAgentOutcome, RunAgentsAgentOutcomeKind,
|
||||||
|
RunAgentsLaunchedExecutionMode, RunAgentsResult,
|
||||||
|
};
|
||||||
|
use crate::ai::runtime::ProviderActionContext;
|
||||||
|
|
||||||
|
fn query(id: &str) -> api::Message {
|
||||||
|
api::Message {
|
||||||
|
id: id.to_owned(),
|
||||||
|
task_id: "task".into(),
|
||||||
|
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
|
||||||
|
query: id.to_owned(),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn saved_tools_and_results_restore_as_individual_panes_across_turns() {
|
||||||
|
let context = ProviderActionContext::new_for_test("task");
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
ToolCall {
|
||||||
|
id: "edit-call".into(),
|
||||||
|
name: "apply_file_diffs".into(),
|
||||||
|
arguments: json!({"summary": "Update file", "diffs": [{"file_path": "/project/file", "search": "before", "replace": "after"}]}),
|
||||||
|
},
|
||||||
|
AIAgentActionResultType::RequestFileEdits(
|
||||||
|
crate::ai::agent::RequestFileEditsResult::Success {
|
||||||
|
diff: "-before\n+after".into(),
|
||||||
|
updated_files: Vec::new(),
|
||||||
|
deleted_files: Vec::new(),
|
||||||
|
lines_added: 1,
|
||||||
|
lines_removed: 1,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ToolCall {
|
||||||
|
id: "cancelled-grep".into(),
|
||||||
|
name: "grep".into(),
|
||||||
|
arguments: json!({"queries": ["needle"], "path": "/project"}),
|
||||||
|
},
|
||||||
|
AIAgentActionResultType::Grep(GrepResult::Cancelled),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ToolCall {
|
||||||
|
id: "shell-call".into(),
|
||||||
|
name: "run_shell_command".into(),
|
||||||
|
arguments: json!({"command": "cat large.txt"}),
|
||||||
|
},
|
||||||
|
AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::Completed {
|
||||||
|
block_id: "shell-block".to_owned().into(),
|
||||||
|
command: "cat large.txt".into(),
|
||||||
|
output: "complete output line\n".repeat(10_000),
|
||||||
|
exit_code: galaxy_core::command::ExitCode::from(0),
|
||||||
|
start_ts: None,
|
||||||
|
completed_ts: None,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ToolCall {
|
||||||
|
id: "grep-call".into(),
|
||||||
|
name: "grep".into(),
|
||||||
|
arguments: json!({"queries": ["needle"], "path": "/project"}),
|
||||||
|
},
|
||||||
|
AIAgentActionResultType::Grep(GrepResult::Error("path unavailable".into())),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ToolCall {
|
||||||
|
id: "agents-call".into(),
|
||||||
|
name: "run_agents".into(),
|
||||||
|
arguments: json!({
|
||||||
|
"summary": "Implement and review", "execution_mode": {"type": "local"},
|
||||||
|
"agent_run_configs": [{"name": "reviewer", "prompt": "Review changes", "model_id": "review-model"}]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
AIAgentActionResultType::RunAgents(RunAgentsResult::Launched {
|
||||||
|
model_id: "review-model".into(),
|
||||||
|
harness_type: String::new(),
|
||||||
|
execution_mode: RunAgentsLaunchedExecutionMode::Local,
|
||||||
|
agents: vec![RunAgentsAgentOutcome {
|
||||||
|
name: "reviewer".into(),
|
||||||
|
kind: RunAgentsAgentOutcomeKind::Launched {
|
||||||
|
agent_id: "child-conversation".into(),
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let mut task = api::Task {
|
||||||
|
id: "task".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
for (index, (call, result)) in cases.iter().enumerate() {
|
||||||
|
let request_id = format!("turn-{index}");
|
||||||
|
let action_result = AIAgentActionResult {
|
||||||
|
id: call.id.clone().into(),
|
||||||
|
task_id: crate::ai::agent::task::TaskId::new("task".to_owned()),
|
||||||
|
result: result.clone(),
|
||||||
|
};
|
||||||
|
for mut message in [
|
||||||
|
query(&request_id),
|
||||||
|
tool_message(
|
||||||
|
&context,
|
||||||
|
call,
|
||||||
|
&context.action_from_tool_call(call).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
result_message(&action_result).unwrap(),
|
||||||
|
] {
|
||||||
|
message.request_id = request_id.clone();
|
||||||
|
task.messages.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Exercise the same protobuf bytes stored in agent_tasks, followed by UI hydration.
|
||||||
|
let restored = api::Task::decode(task.encode_to_vec().as_slice()).unwrap();
|
||||||
|
let exchanges = (&restored).into_exchanges();
|
||||||
|
assert_eq!(exchanges.len(), cases.len());
|
||||||
|
for (exchange, (call, expected_result)) in exchanges.iter().zip(cases.iter()) {
|
||||||
|
let output = exchange.output_status.output().unwrap();
|
||||||
|
let output = output.get();
|
||||||
|
let actions = output.actions().collect::<Vec<_>>();
|
||||||
|
assert_eq!(actions.len(), 1);
|
||||||
|
assert_eq!(actions[0], &context.action_from_tool_call(call).unwrap());
|
||||||
|
let result = exchange
|
||||||
|
.input
|
||||||
|
.iter()
|
||||||
|
.find_map(|input| match input {
|
||||||
|
AIAgentInput::ActionResult { result, .. } => Some(result),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(&result.result, expected_result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_messages_without_request_ids_split_at_each_user_query() {
|
||||||
|
let task = api::Task {
|
||||||
|
id: "task".into(),
|
||||||
|
messages: vec![
|
||||||
|
query("first"),
|
||||||
|
api::Message {
|
||||||
|
id: "reply".into(),
|
||||||
|
task_id: "task".into(),
|
||||||
|
message: Some(api::message::Message::AgentOutput(
|
||||||
|
api::message::AgentOutput {
|
||||||
|
text: "First reply".into(),
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
query("second"),
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!((&task).into_exchanges().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restored_question_keeps_the_id_used_by_its_answer() {
|
||||||
|
let context = ProviderActionContext::new_for_test("task");
|
||||||
|
let call = ToolCall {
|
||||||
|
id: "question-call".into(),
|
||||||
|
name: "ask_user_question".into(),
|
||||||
|
arguments: json!({"question": "Which option?", "options": ["A", "B"]}),
|
||||||
|
};
|
||||||
|
let action = context.action_from_tool_call(&call).unwrap();
|
||||||
|
let message = tool_message(&context, &call, &action).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
super::restore_tool(&message.server_message_data)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap(),
|
||||||
|
action
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -691,9 +691,38 @@ impl Task {
|
|||||||
Ok(source)
|
Ok(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Append display-only records without replaying tools or changing the live exchange.
|
||||||
|
pub(super) fn save_display_message(
|
||||||
|
&mut self,
|
||||||
|
mut message: api::Message,
|
||||||
|
request_id: String,
|
||||||
|
) -> Result<(), UpdateTaskError> {
|
||||||
|
message.request_id = request_id;
|
||||||
|
if message.timestamp.is_none() {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
message.timestamp = Some(prost_types::Timestamp {
|
||||||
|
seconds: now.timestamp(),
|
||||||
|
nanos: now.timestamp_subsec_nanos() as i32,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let source = self.try_get_source_mut()?;
|
||||||
|
if let Some(existing) = source
|
||||||
|
.messages
|
||||||
|
.iter_mut()
|
||||||
|
.find(|existing| existing.id == message.id)
|
||||||
|
{
|
||||||
|
message.request_id.clone_from(&existing.request_id);
|
||||||
|
message.timestamp = existing.timestamp.or(message.timestamp);
|
||||||
|
*existing = message;
|
||||||
|
} else {
|
||||||
|
source.messages.push(message);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn add_messages(
|
pub(super) fn add_messages(
|
||||||
&mut self,
|
&mut self,
|
||||||
messages: Vec<api::Message>,
|
mut messages: Vec<api::Message>,
|
||||||
exchange_id: AIAgentExchangeId,
|
exchange_id: AIAgentExchangeId,
|
||||||
message_context: TaskMessageContext<'_>,
|
message_context: TaskMessageContext<'_>,
|
||||||
should_convert_input_messages: bool,
|
should_convert_input_messages: bool,
|
||||||
@@ -701,6 +730,20 @@ impl Task {
|
|||||||
if self.source().is_none() {
|
if self.source().is_none() {
|
||||||
return Err(UpdateTaskError::TaskNotInitialized);
|
return Err(UpdateTaskError::TaskNotInitialized);
|
||||||
}
|
}
|
||||||
|
// Locally projected provider messages have no server request identity.
|
||||||
|
// Persist their actual exchange identity so restart preserves turn boundaries.
|
||||||
|
for message in &mut messages {
|
||||||
|
if message.request_id.is_empty() {
|
||||||
|
message.request_id = exchange_id.to_string();
|
||||||
|
}
|
||||||
|
if message.timestamp.is_none() {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
message.timestamp = Some(prost_types::Timestamp {
|
||||||
|
seconds: now.timestamp(),
|
||||||
|
nanos: now.timestamp_subsec_nanos() as i32,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
self.update_exchange_from_messages(
|
self.update_exchange_from_messages(
|
||||||
messages.clone(),
|
messages.clone(),
|
||||||
exchange_id,
|
exchange_id,
|
||||||
|
|||||||
@@ -2455,6 +2455,16 @@ impl BlocklistAIActionModel {
|
|||||||
let permission_denied = self
|
let permission_denied = self
|
||||||
.denied_permissions
|
.denied_permissions
|
||||||
.remove(&(conversation_id, action_result.id.clone()));
|
.remove(&(conversation_id, action_result.id.clone()));
|
||||||
|
if execution_ref.is_some() {
|
||||||
|
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||||
|
if let Some(conversation) = history.conversation_mut(&conversation_id) {
|
||||||
|
if let Err(error) = conversation.save_provider_tool_result(&action_result, ctx)
|
||||||
|
{
|
||||||
|
log::error!("Failed to save provider tool result: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
let tool_result = domain_tool_result(&action_result, permission_denied);
|
let tool_result = domain_tool_result(&action_result, permission_denied);
|
||||||
if execution_ref.is_none() {
|
if execution_ref.is_none() {
|
||||||
self.finished_tool_results
|
self.finished_tool_results
|
||||||
|
|||||||
@@ -277,8 +277,16 @@ impl ShellCommandExecutor {
|
|||||||
move |result, ctx| {
|
move |result, ctx| {
|
||||||
// Remove the senders from the maps.
|
// Remove the senders from the maps.
|
||||||
if let Some(handle) = handle.upgrade(ctx) {
|
if let Some(handle) = handle.upgrade(ctx) {
|
||||||
handle.update(ctx, |me, _| {
|
handle.update(ctx, |me, ctx| {
|
||||||
me.prune_closed_senders(&block_selector);
|
me.prune_closed_senders(&block_selector);
|
||||||
|
if matches!(
|
||||||
|
result,
|
||||||
|
ActionResult::BlockNotFound | ActionResult::Cancelled
|
||||||
|
) {
|
||||||
|
ctx.emit(ShellCommandExecutorEvent::CancelExecution {
|
||||||
|
action_id: action_id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,11 +667,9 @@ impl ShellCommandExecutor {
|
|||||||
};
|
};
|
||||||
// Cancelling the wait future alone would report cancellation while the process keeps
|
// Cancelling the wait future alone would report cancellation while the process keeps
|
||||||
// running. Terminate the exact requested command before resolving the action as cancelled.
|
// running. Terminate the exact requested command before resolving the action as cancelled.
|
||||||
if requested_block_is_running {
|
|
||||||
ctx.emit(ShellCommandExecutorEvent::CancelExecution {
|
ctx.emit(ShellCommandExecutorEvent::CancelExecution {
|
||||||
action_id: id.clone(),
|
action_id: id.clone(),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
if !requested_block_is_running {
|
if !requested_block_is_running {
|
||||||
self.block_finished_senders.remove(&selector);
|
self.block_finished_senders.remove(&selector);
|
||||||
self.force_refresh_senders.remove(&selector);
|
self.force_refresh_senders.remove(&selector);
|
||||||
|
|||||||
@@ -295,6 +295,20 @@ impl CLISubagentController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ctx.subscribe_to_model(model_event_dispatcher, |me, _, event, ctx| {
|
ctx.subscribe_to_model(model_event_dispatcher, |me, _, event, ctx| {
|
||||||
|
if let ModelEvent::AfterBlockStarted { block_id, .. } = event {
|
||||||
|
let identity = {
|
||||||
|
let model = me.terminal_model.lock();
|
||||||
|
model.block_list().block_with_id(block_id).and_then(|block| {
|
||||||
|
Some((
|
||||||
|
block.ai_conversation_id()?,
|
||||||
|
block.requested_command_action_id()?.clone(),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
if let Some((conversation_id, action_id)) = identity {
|
||||||
|
me.track_requested_command(block_id, conversation_id, &action_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
if let ModelEvent::BlockCompleted(block_completed_event) = event {
|
if let ModelEvent::BlockCompleted(block_completed_event) = event {
|
||||||
let terminal_model = me.terminal_model.lock();
|
let terminal_model = me.terminal_model.lock();
|
||||||
let Some(block) = terminal_model
|
let Some(block) = terminal_model
|
||||||
@@ -654,11 +668,10 @@ impl CLISubagentController {
|
|||||||
.and_then(|state| state.last_snapshot_at)
|
.and_then(|state| state.last_snapshot_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Begins tracking an agent-requested command before its shell event is dispatched.
|
/// Tracks the block assigned when an agent-requested command actually starts in the shell.
|
||||||
///
|
/// This must not use the active block from when the command was queued: that block may still
|
||||||
/// The placeholder lets command completion and action-result events arrive in either order
|
/// belong to a previous command. The placeholder preserves completion before a monitor exists.
|
||||||
/// without losing the completion that a subsequently-created CLI monitor needs.
|
fn track_requested_command(
|
||||||
pub fn track_requested_command(
|
|
||||||
&mut self,
|
&mut self,
|
||||||
block_id: &BlockId,
|
block_id: &BlockId,
|
||||||
conversation_id: AIConversationId,
|
conversation_id: AIConversationId,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ use input_context::{input_context_for_request, parse_context_attachments};
|
|||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use parking_lot::FairMutex;
|
use parking_lot::FairMutex;
|
||||||
use pending_response_streams::PendingResponseStreams;
|
use pending_response_streams::PendingResponseStreams;
|
||||||
|
use persistence::model::ProviderConversationContext;
|
||||||
use session_sharing_protocol::common::ParticipantId;
|
use session_sharing_protocol::common::ParticipantId;
|
||||||
use settings::Setting;
|
use settings::Setting;
|
||||||
pub use slash_command::*;
|
pub use slash_command::*;
|
||||||
@@ -2557,7 +2558,23 @@ impl BlocklistAIController {
|
|||||||
terminal_surface_id,
|
terminal_surface_id,
|
||||||
conversation_ids,
|
conversation_ids,
|
||||||
} if *terminal_surface_id == me.terminal_surface_id => {
|
} if *terminal_surface_id == me.terminal_surface_id => {
|
||||||
me.schedule_restored_provider_runs(conversation_ids, ctx);
|
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||||
|
for conversation_id in conversation_ids {
|
||||||
|
if history
|
||||||
|
.conversation(conversation_id)
|
||||||
|
.is_some_and(|conversation| {
|
||||||
|
conversation.active_provider_run_json().is_some()
|
||||||
|
})
|
||||||
|
{
|
||||||
|
history.update_conversation_status(
|
||||||
|
me.terminal_surface_id,
|
||||||
|
*conversation_id,
|
||||||
|
ConversationStatus::Cancelled,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
BlocklistAIHistoryEvent::UpdatedConversationStatus { new_status, .. }
|
BlocklistAIHistoryEvent::UpdatedConversationStatus { new_status, .. }
|
||||||
if new_status.is_done() =>
|
if new_status.is_done() =>
|
||||||
@@ -4123,6 +4140,13 @@ impl BlocklistAIController {
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
// A saved checkpoint is dormant until the user explicitly resumes. In particular,
|
||||||
|
// child completion or late terminal events must not wake a restored conversation.
|
||||||
|
if conversation.active_provider_run_json().is_some()
|
||||||
|
&& !self.active_provider_runs.contains_key(&conversation_id)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// WaitingForEvents is treated as Success here: pending events
|
// WaitingForEvents is treated as Success here: pending events
|
||||||
// drain via the next outbound request and the server-side
|
// drain via the next outbound request and the server-side
|
||||||
// supersede emits the resume signal.
|
// supersede emits the resume signal.
|
||||||
@@ -4462,6 +4486,12 @@ impl BlocklistAIController {
|
|||||||
log::error!("Tried to resume non-existent conversation: {conversation_id:?}");
|
log::error!("Tried to resume non-existent conversation: {conversation_id:?}");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
if conversation.active_provider_run_json().is_some()
|
||||||
|
&& !self.active_provider_runs.contains_key(&conversation_id)
|
||||||
|
{
|
||||||
|
self.schedule_restored_provider_runs(&[conversation_id], ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let task_id = {
|
let task_id = {
|
||||||
let terminal_model = self.terminal_model.lock();
|
let terminal_model = self.terminal_model.lock();
|
||||||
let active_block = terminal_model.block_list().active_block();
|
let active_block = terminal_model.block_list().active_block();
|
||||||
@@ -5572,7 +5602,8 @@ impl BlocklistAIController {
|
|||||||
self.restoring_provider_runs
|
self.restoring_provider_runs
|
||||||
.extend(conversation_ids.iter().copied());
|
.extend(conversation_ids.iter().copied());
|
||||||
|
|
||||||
// RestoredConversations is emitted before terminal views finish rebuilding their blocks.
|
// Resume only after the caller releases its view/model borrows. Merely loading a
|
||||||
|
// conversation never reaches this path; the user must explicitly request Resume.
|
||||||
let _ = ctx.spawn(async {}, move |me, _, ctx| {
|
let _ = ctx.spawn(async {}, move |me, _, ctx| {
|
||||||
for conversation_id in conversation_ids {
|
for conversation_id in conversation_ids {
|
||||||
me.restore_active_provider_run(conversation_id, ctx);
|
me.restore_active_provider_run(conversation_id, ctx);
|
||||||
@@ -6519,9 +6550,16 @@ impl BlocklistAIController {
|
|||||||
Err(error) => return Err(error),
|
Err(error) => return Err(error),
|
||||||
};
|
};
|
||||||
snapshot.queued_follow_ups = queued_follow_ups;
|
snapshot.queued_follow_ups = queued_follow_ups;
|
||||||
|
let context = ProviderConversationContext {
|
||||||
|
messages: snapshot.run.transcript()[snapshot.persistence_offset..].to_vec(),
|
||||||
|
tool_result_archive: snapshot.run.tool_result_archive().to_vec(),
|
||||||
|
};
|
||||||
let json = serde_json::to_string(&snapshot)
|
let json = serde_json::to_string(&snapshot)
|
||||||
.map_err(|error| format!("failed to serialize active provider run: {error}"))?;
|
.map_err(|error| format!("failed to serialize active provider run: {error}"))?;
|
||||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
|
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
|
||||||
|
if let Some(conversation) = history_model.conversation_mut(&conversation_id) {
|
||||||
|
conversation.set_provider_context(context);
|
||||||
|
}
|
||||||
history_model
|
history_model
|
||||||
.persist_active_provider_run_json(conversation_id, Some(json), ctx)
|
.persist_active_provider_run_json(conversation_id, Some(json), ctx)
|
||||||
.map_err(|error| format!("failed to persist active provider run: {error:?}"))
|
.map_err(|error| format!("failed to persist active provider run: {error:?}"))
|
||||||
@@ -7656,6 +7694,7 @@ impl BlocklistAIController {
|
|||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let action_context = run.action_context.clone();
|
||||||
let tool_titles = batch
|
let tool_titles = batch
|
||||||
.calls
|
.calls
|
||||||
.iter()
|
.iter()
|
||||||
@@ -7815,6 +7854,32 @@ impl BlocklistAIController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(pending) = batch
|
||||||
|
.calls
|
||||||
|
.iter()
|
||||||
|
.find(|pending| pending.call.id == action.id.to_string())
|
||||||
|
{
|
||||||
|
let saved = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||||
|
let conversation = history
|
||||||
|
.conversation_mut(&conversation_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing provider conversation"))?;
|
||||||
|
conversation.save_provider_tool(
|
||||||
|
&stream_id,
|
||||||
|
&action_context,
|
||||||
|
&pending.call,
|
||||||
|
&action,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if let Err(error) = saved {
|
||||||
|
self.fail_active_provider_run(
|
||||||
|
conversation_id,
|
||||||
|
format!("failed to save provider tool history: {error}"),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
actions.push(action);
|
actions.push(action);
|
||||||
}
|
}
|
||||||
if let Some(slot) = self.active_provider_runs.get_mut(&conversation_id) {
|
if let Some(slot) = self.active_provider_runs.get_mut(&conversation_id) {
|
||||||
@@ -8290,6 +8355,20 @@ impl BlocklistAIController {
|
|||||||
let offset = run.persistence_offset.min(transcript.len());
|
let offset = run.persistence_offset.min(transcript.len());
|
||||||
*messages_sent = transcript[offset..].to_vec();
|
*messages_sent = transcript[offset..].to_vec();
|
||||||
}
|
}
|
||||||
|
// Retain the exact context before terminal projection clears the active checkpoint.
|
||||||
|
// UI task messages are a lossy representation of tool/reasoning messages.
|
||||||
|
let transcript = run.coordinator.run().transcript();
|
||||||
|
let offset = run.persistence_offset.min(transcript.len());
|
||||||
|
let context = ProviderConversationContext {
|
||||||
|
messages: transcript[offset..].to_vec(),
|
||||||
|
tool_result_archive: run.coordinator.run().tool_result_archive().to_vec(),
|
||||||
|
};
|
||||||
|
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||||
|
if let Some(conversation) = history.conversation_mut(&conversation_id) {
|
||||||
|
conversation.set_provider_context(context);
|
||||||
|
conversation.write_updated_conversation_state(ctx);
|
||||||
|
}
|
||||||
|
});
|
||||||
let aggregate_usage = run.coordinator.run().usage().clone();
|
let aggregate_usage = run.coordinator.run().usage().clone();
|
||||||
let events = match run.projector.finish(&outcome, &aggregate_usage) {
|
let events = match run.projector.finish(&outcome, &aggregate_usage) {
|
||||||
Ok(events) => events,
|
Ok(events) => events,
|
||||||
|
|||||||
@@ -3890,3 +3890,57 @@ fn failed_child_without_an_exchange_reaches_terminal_error() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restored_provider_checkpoint_waits_for_explicit_resume() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
initialize_app_for_terminal_view(&mut app);
|
||||||
|
let terminal = add_window_with_terminal(&mut app, None);
|
||||||
|
let conversation_id = terminal.update(&mut app, |terminal, ctx| {
|
||||||
|
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||||
|
let conversation_id =
|
||||||
|
history.start_new_conversation(terminal.id(), false, false, false, ctx);
|
||||||
|
history
|
||||||
|
.persist_active_provider_run_json(
|
||||||
|
conversation_id,
|
||||||
|
Some("unparsed saved checkpoint".to_owned()),
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
ctx.emit(
|
||||||
|
crate::ai::blocklist::BlocklistAIHistoryEvent::RestoredConversations {
|
||||||
|
terminal_surface_id: terminal.id(),
|
||||||
|
conversation_ids: vec![conversation_id],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
conversation_id
|
||||||
|
})
|
||||||
|
});
|
||||||
|
terminal.update(&mut app, |terminal, ctx| {
|
||||||
|
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
|
||||||
|
.conversation(&conversation_id)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(conversation.status(), &ConversationStatus::Cancelled);
|
||||||
|
assert_eq!(
|
||||||
|
conversation.active_provider_run_json(),
|
||||||
|
Some("unparsed saved checkpoint")
|
||||||
|
);
|
||||||
|
terminal.ai_controller().update(ctx, |controller, ctx| {
|
||||||
|
assert!(!controller
|
||||||
|
.restoring_provider_runs
|
||||||
|
.contains(&conversation_id));
|
||||||
|
assert!(!controller
|
||||||
|
.active_provider_runs
|
||||||
|
.contains_key(&conversation_id));
|
||||||
|
assert!(!controller.conversation_ready_for_pending_events(conversation_id, ctx));
|
||||||
|
controller.resume_conversation(conversation_id, Vec::new(), ctx);
|
||||||
|
assert!(controller
|
||||||
|
.restoring_provider_runs
|
||||||
|
.contains(&conversation_id));
|
||||||
|
// A second click must not schedule another restoration.
|
||||||
|
controller.resume_conversation(conversation_id, Vec::new(), ctx);
|
||||||
|
assert_eq!(controller.restoring_provider_runs.len(), 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1703,6 +1703,7 @@ impl BlocklistAIHistoryModel {
|
|||||||
|
|
||||||
let conversation_data = AgentConversationData {
|
let conversation_data = AgentConversationData {
|
||||||
agent_backend: source_conversation.agent_backend().for_fork(),
|
agent_backend: source_conversation.agent_backend().for_fork(),
|
||||||
|
provider_context: None,
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
local_agent_groups: Vec::new(),
|
local_agent_groups: Vec::new(),
|
||||||
tool_titles: source_conversation.tool_titles().clone(),
|
tool_titles: source_conversation.tool_titles().clone(),
|
||||||
@@ -1870,6 +1871,7 @@ impl BlocklistAIHistoryModel {
|
|||||||
// be recomputed based on the retained exchanges in a follow-up.
|
// be recomputed based on the retained exchanges in a follow-up.
|
||||||
let conversation_data = AgentConversationData {
|
let conversation_data = AgentConversationData {
|
||||||
agent_backend: conversation.agent_backend().for_fork(),
|
agent_backend: conversation.agent_backend().for_fork(),
|
||||||
|
provider_context: None,
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
local_agent_groups: Vec::new(),
|
local_agent_groups: Vec::new(),
|
||||||
tool_titles: conversation.tool_titles().clone(),
|
tool_titles: conversation.tool_titles().clone(),
|
||||||
@@ -2898,6 +2900,7 @@ fn merged_remote_child_placeholder_conversation_data(
|
|||||||
cloud_conversation: &AIConversation,
|
cloud_conversation: &AIConversation,
|
||||||
) -> AgentConversationData {
|
) -> AgentConversationData {
|
||||||
AgentConversationData {
|
AgentConversationData {
|
||||||
|
provider_context: None,
|
||||||
// Placeholder authoritative.
|
// Placeholder authoritative.
|
||||||
agent_backend: placeholder.agent_backend().clone(),
|
agent_backend: placeholder.agent_backend().clone(),
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,14 @@ pub fn convert_persisted_conversation_to_ai_conversation_with_metadata(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let conversation_data = serde_json::from_str::<AgentConversationData>(&conversation_data).ok();
|
let conversation_data = match serde_json::from_str::<AgentConversationData>(&conversation_data)
|
||||||
|
{
|
||||||
|
Ok(data) => Some(data),
|
||||||
|
Err(error) => {
|
||||||
|
log::error!("Failed to restore conversation {conversation_id} metadata: {error}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Local-DB restore: an empty `agent_tasks` row is the normal shape of a
|
// Local-DB restore: an empty `agent_tasks` row is the normal shape of a
|
||||||
// child conversation persisted before its first server response, so
|
// child conversation persisted before its first server response, so
|
||||||
|
|||||||
@@ -83,6 +83,33 @@ impl ProviderActionContext {
|
|||||||
.filter(|title| !title.is_empty())
|
.filter(|title| !title.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn history_compatibility_call(&self, call: &ToolCall) -> ToolCall {
|
||||||
|
let mut compatible = call.clone();
|
||||||
|
if let Some(target) = self.mcp_tool_aliases.get(&call.name) {
|
||||||
|
let server = target
|
||||||
|
.server_id
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
compatible.name = format!("mcp__{server}__{}", target.name);
|
||||||
|
if let (Some(arguments), Some(parameter)) = (
|
||||||
|
compatible.arguments.as_object_mut(),
|
||||||
|
target.title_parameter.as_ref(),
|
||||||
|
) {
|
||||||
|
arguments.remove(parameter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compatible
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn restored_action_from_tool_call(
|
||||||
|
&self,
|
||||||
|
call: &ToolCall,
|
||||||
|
) -> Result<AIAgentAction, String> {
|
||||||
|
let mut context = self.clone();
|
||||||
|
context.skill_path_origin = ai::skills::SkillPathOrigin::RestoredDisplayOnly;
|
||||||
|
context.action_from_tool_call(call)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn action_from_tool_call(&self, call: &ToolCall) -> Result<AIAgentAction, String> {
|
pub(crate) fn action_from_tool_call(&self, call: &ToolCall) -> Result<AIAgentAction, String> {
|
||||||
action_from_tool_call(
|
action_from_tool_call(
|
||||||
&self.task_id,
|
&self.task_id,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use crate::workspace::tab_group::TabGroupId;
|
|||||||
use crate::workspace::view::left_panel::ToolPanelView;
|
use crate::workspace::view::left_panel::ToolPanelView;
|
||||||
use crate::workspace::WorkspaceRegistry;
|
use crate::workspace::WorkspaceRegistry;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub windows: Vec<WindowSnapshot>,
|
pub windows: Vec<WindowSnapshot>,
|
||||||
pub active_window_index: Option<usize>,
|
pub active_window_index: Option<usize>,
|
||||||
|
|||||||
@@ -2829,6 +2829,24 @@ fn launch(ctx: &mut galaxyui::AppContext, app_state: Option<AppState>, launch_mo
|
|||||||
timer.mark_interval_end("WINDOWS_CREATED");
|
timer.mark_interval_end("WINDOWS_CREATED");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
#[cfg(feature = "local_fs")]
|
||||||
|
if matches!(launch_mode, LaunchMode::App { .. })
|
||||||
|
&& PersistenceWriter::as_ref(ctx).sender().is_none()
|
||||||
|
{
|
||||||
|
let windows: Vec<_> = ctx.window_ids().collect();
|
||||||
|
for window_id in windows {
|
||||||
|
ToastStack::handle(ctx).update(ctx, |toasts, ctx| {
|
||||||
|
toasts.add_persistent_toast(
|
||||||
|
DismissibleToast::error(
|
||||||
|
"Galaxy could not restore all local data. Saving is disabled to protect existing data. Check the logs and restart after resolving the problem.".to_string(),
|
||||||
|
),
|
||||||
|
window_id,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO(ben): We should skip this for LaunchMode::Test.
|
// TODO(ben): We should skip this for LaunchMode::Test.
|
||||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ fn persisted_remote_child_conversation(
|
|||||||
conversation_id: conversation_id.to_string(),
|
conversation_id: conversation_id.to_string(),
|
||||||
conversation_data: serde_json::to_string(&AgentConversationData {
|
conversation_data: serde_json::to_string(&AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
|
provider_context: None,
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
local_agent_groups: Vec::new(),
|
local_agent_groups: Vec::new(),
|
||||||
tool_titles: Default::default(),
|
tool_titles: Default::default(),
|
||||||
@@ -3546,3 +3547,35 @@ fn test_auto_hiding_completed_child_preserves_parent_focus_and_reopen() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_transcript_snapshot_retains_conversation_links() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
initialize_app(&mut app);
|
||||||
|
let pane_group = mock_pane_group(&mut app, Default::default());
|
||||||
|
pane_group.update(&mut app, |panes, ctx| {
|
||||||
|
let pane_id = get_newly_created_pane_id(panes, &[]);
|
||||||
|
let terminal = panes.terminal_view_from_pane_id(pane_id, ctx).unwrap();
|
||||||
|
let conversation_id =
|
||||||
|
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||||
|
history.start_new_conversation(terminal.id(), false, false, false, ctx)
|
||||||
|
});
|
||||||
|
terminal
|
||||||
|
.as_ref(ctx)
|
||||||
|
.model
|
||||||
|
.lock()
|
||||||
|
.set_conversation_transcript_viewer_status(Some(
|
||||||
|
ConversationTranscriptViewerStatus::ViewingLocalConversation,
|
||||||
|
));
|
||||||
|
let PaneNodeSnapshot::Leaf(leaf) = panes.snapshot(ctx) else {
|
||||||
|
panic!("expected a terminal leaf");
|
||||||
|
};
|
||||||
|
let LeafContents::Terminal(snapshot) = leaf.contents else {
|
||||||
|
panic!("expected a terminal snapshot");
|
||||||
|
};
|
||||||
|
assert!(snapshot
|
||||||
|
.conversation_ids_to_restore
|
||||||
|
.contains(&conversation_id));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ use crate::server::server_api::ai::{SpawnAgentRequest, UserQueryMode};
|
|||||||
use crate::server::server_api::ServerApiProvider;
|
use crate::server::server_api::ServerApiProvider;
|
||||||
use crate::session_management::SessionNavigationData;
|
use crate::session_management::SessionNavigationData;
|
||||||
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
|
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
|
||||||
use crate::terminal::general_settings::GeneralSettings;
|
|
||||||
use crate::terminal::shared_session::manager::{Manager, ManagerEvent};
|
use crate::terminal::shared_session::manager::{Manager, ManagerEvent};
|
||||||
use crate::terminal::shared_session::role_change_modal::RoleChangeOpenSource;
|
use crate::terminal::shared_session::role_change_modal::RoleChangeOpenSource;
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
@@ -493,6 +492,13 @@ impl PaneContent for TerminalPane {
|
|||||||
|
|
||||||
// Capture the current input_config from the AI input model
|
// Capture the current input_config from the AI input model
|
||||||
let current_input_config = view.input_config(app.as_ref());
|
let current_input_config = view.input_config(app.as_ref());
|
||||||
|
let transcript_task_id = {
|
||||||
|
let model = view.model.lock();
|
||||||
|
model
|
||||||
|
.is_conversation_transcript_viewer()
|
||||||
|
.then(|| model.ambient_agent_task_id())
|
||||||
|
.flatten()
|
||||||
|
};
|
||||||
|
|
||||||
if view.model.lock().shared_session_status().is_viewer() {
|
if view.model.lock().shared_session_status().is_viewer() {
|
||||||
// We save and restore ambient agent sessions
|
// We save and restore ambient agent sessions
|
||||||
@@ -530,33 +536,13 @@ impl PaneContent for TerminalPane {
|
|||||||
uuid: self.uuid.clone(),
|
uuid: self.uuid.clone(),
|
||||||
task_id: Some(task_id),
|
task_id: Some(task_id),
|
||||||
})
|
})
|
||||||
} else if view.model.lock().is_conversation_transcript_viewer() {
|
} else if let Some(task_id) = transcript_task_id {
|
||||||
// Conversation transcript viewers (opened from the conversation list)
|
|
||||||
// can be restored via the ambient agent task if one exists.
|
|
||||||
let task_id = view.model.lock().ambient_agent_task_id();
|
|
||||||
if task_id.is_some() {
|
|
||||||
log::trace!("[session-snapshot] pane=transcript/ambient task_id={task_id:?}");
|
|
||||||
LeafContents::AmbientAgent(AmbientAgentPaneSnapshot {
|
LeafContents::AmbientAgent(AmbientAgentPaneSnapshot {
|
||||||
uuid: self.uuid.clone(),
|
uuid: self.uuid.clone(),
|
||||||
task_id,
|
task_id: Some(task_id),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
let cwd = view.pwd_if_local(app);
|
// Local transcript viewers retain their conversation links just like live panes.
|
||||||
log::trace!("[session-snapshot] pane=transcript cwd={cwd:?} is_active={is_active}");
|
|
||||||
LeafContents::Terminal(TerminalPaneSnapshot {
|
|
||||||
uuid: self.uuid.clone(),
|
|
||||||
cwd,
|
|
||||||
is_active,
|
|
||||||
is_read_only: false,
|
|
||||||
shell_launch_data: None,
|
|
||||||
input_config: None,
|
|
||||||
llm_model_override: None,
|
|
||||||
active_profile_id: None,
|
|
||||||
conversation_ids_to_restore: vec![],
|
|
||||||
active_conversation_id: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let llm_model_override =
|
let llm_model_override =
|
||||||
LLMPreferences::as_ref(app).get_base_llm_override(self.terminal_view(app).id());
|
LLMPreferences::as_ref(app).get_base_llm_override(self.terminal_view(app).id());
|
||||||
|
|
||||||
@@ -1038,9 +1024,7 @@ fn handle_terminal_view_event(
|
|||||||
Event::BlockCompleted { block, is_local } => {
|
Event::BlockCompleted { block, is_local } => {
|
||||||
match group.terminal_session_by_id(pane_id) {
|
match group.terminal_session_by_id(pane_id) {
|
||||||
Some(pane) => {
|
Some(pane) => {
|
||||||
if *GeneralSettings::as_ref(ctx).restore_session
|
if AppExecutionMode::as_ref(ctx).can_save_session() {
|
||||||
&& AppExecutionMode::as_ref(ctx).can_save_session()
|
|
||||||
{
|
|
||||||
if let Some(sender) = &group.model_event_sender {
|
if let Some(sender) = &group.model_event_sender {
|
||||||
let block_completed_event = ModelEvent::SaveBlock(BlockCompleted {
|
let block_completed_event = ModelEvent::SaveBlock(BlockCompleted {
|
||||||
pane_id: pane.session_uuid(),
|
pane_id: pane.session_uuid(),
|
||||||
@@ -2249,10 +2233,8 @@ fn handle_ai_history_event(
|
|||||||
is_hidden,
|
is_hidden,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
// Check if session restoration is enabled.
|
// Save query history independently of automatic session reopening.
|
||||||
if !*GeneralSettings::as_ref(ctx).restore_session
|
if !AppExecutionMode::as_ref(ctx).can_save_session() {
|
||||||
|| !AppExecutionMode::as_ref(ctx).can_save_session()
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,22 @@
|
|||||||
|
# Saving and restoring local data
|
||||||
|
|
||||||
|
On macOS, Galaxy stores its local database at `~/.galaxy/galaxy.sqlite`.
|
||||||
|
`general.restore_session` controls automatic reopening of the previous session;
|
||||||
|
conversation history and session snapshots continue to be saved when it is off.
|
||||||
|
Terminal scrollback retention does not limit persisted provider conversation context.
|
||||||
|
|
||||||
|
New conversation records store the provider message history and tool-result archive
|
||||||
|
alongside the progressive summary. Older records fall back to reconstructing the
|
||||||
|
available task messages; provider details omitted by those older saves cannot be
|
||||||
|
recovered exactly.
|
||||||
|
|
||||||
|
Startup reads use a consistent SQLite snapshot and recover independent data domains.
|
||||||
|
If a domain or saved tab/conversation cannot be read, Galaxy logs the failure, shows a persistent startup warning, and
|
||||||
|
leaves database writing disabled for that launch so partial in-memory state cannot
|
||||||
|
overwrite the original records. Repair the underlying problem and restart to resume
|
||||||
|
saving. Snapshot deduplication happens only after a successful database commit;
|
||||||
|
transactional snapshot and conversation saves retry transient database contention.
|
||||||
|
|
||||||
# How to perform migrations
|
# How to perform migrations
|
||||||
|
|
||||||
## How do migrations work?
|
## How do migrations work?
|
||||||
@@ -27,20 +46,20 @@ This will create a new folder with an up.sql and down.sql.
|
|||||||
## Step 3: Run the migration + generate the schema
|
## Step 3: Run the migration + generate the schema
|
||||||
```
|
```
|
||||||
cd <repo root>
|
cd <repo root>
|
||||||
diesel migration run --database-url="/Users/$USER/.galaxy-local/galaxy.sqlite"
|
diesel migration run --database-url="/Users/$USER/.galaxy/galaxy.sqlite"
|
||||||
```
|
```
|
||||||
This will run the migration on the same warp that runs when you run the app locally. This automatically generates or updates the `crates/persistence/src/schema.rs`. We do not make manual edits to `schema.rs`.
|
This will run the migration on the same warp that runs when you run the app locally. This automatically generates or updates the `crates/persistence/src/schema.rs`. We do not make manual edits to `schema.rs`.
|
||||||
|
|
||||||
You can also print the schema from a database that already has the migration with:
|
You can also print the schema from a database that already has the migration with:
|
||||||
```
|
```
|
||||||
diesel print-schema --database-url="/Users/$USER/.galaxy-local/galaxy.sqlite"
|
diesel print-schema --database-url="/Users/$USER/.galaxy/galaxy.sqlite"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Reverting/redo-ing migrations
|
## Reverting/redo-ing migrations
|
||||||
As you are writing features and changing branches, you'll want to undo migrations to fix your database and make it compatible with older code. Redo-ing can also be helpful as you are iterating on your schema.
|
As you are writing features and changing branches, you'll want to undo migrations to fix your database and make it compatible with older code. Redo-ing can also be helpful as you are iterating on your schema.
|
||||||
```
|
```
|
||||||
diesel migration revert --database-url="/Users/$USER/.galaxy-local/galaxy.sqlite"
|
diesel migration revert --database-url="/Users/$USER/.galaxy/galaxy.sqlite"
|
||||||
diesel migration redo --database-url="/Users/$USER/.galaxy-local/galaxy.sqlite"
|
diesel migration redo --database-url="/Users/$USER/.galaxy/galaxy.sqlite"
|
||||||
```
|
```
|
||||||
|
|
||||||
# Schema style
|
# Schema style
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ pub(super) fn select_conversations_to_evict(
|
|||||||
|
|
||||||
pub(super) fn read_agent_conversations(
|
pub(super) fn read_agent_conversations(
|
||||||
conn: &mut SqliteConnection,
|
conn: &mut SqliteConnection,
|
||||||
|
errors: &mut Vec<String>,
|
||||||
) -> Result<Vec<AgentConversation>, diesel::result::Error> {
|
) -> Result<Vec<AgentConversation>, diesel::result::Error> {
|
||||||
let mut conversations_by_id = HashMap::<String, AgentConversation>::from_iter(
|
let mut conversations_by_id = HashMap::<String, AgentConversation>::from_iter(
|
||||||
agent_conversations::table
|
agent_conversations::table
|
||||||
@@ -242,7 +243,27 @@ pub(super) fn read_agent_conversations(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
conversations_by_id.retain(|c_id, _| !invalid_conversation_ids.contains(c_id));
|
for conversation_id in &invalid_conversation_ids {
|
||||||
|
errors.push(format!(
|
||||||
|
"Failed to restore conversation {conversation_id}: invalid task data"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
conversations_by_id.retain(|id, _| !invalid_conversation_ids.contains(id));
|
||||||
|
// Keep healthy conversations available, but mark the restore incomplete so
|
||||||
|
// invalid metadata can never be replaced with empty defaults on disk.
|
||||||
|
conversations_by_id.retain(|id, conversation| {
|
||||||
|
match serde_json::from_str::<AgentConversationData>(
|
||||||
|
&conversation.conversation.conversation_data,
|
||||||
|
) {
|
||||||
|
Ok(_) => true,
|
||||||
|
Err(error) => {
|
||||||
|
let message = format!("Failed to restore conversation {id} metadata: {error}");
|
||||||
|
log::error!("{message}");
|
||||||
|
errors.push(message);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Ok(conversations_by_id.into_values().collect())
|
Ok(conversations_by_id.into_values().collect())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ impl SingletonEntity for PersistenceWriter {}
|
|||||||
///
|
///
|
||||||
/// This data belongs to the local installation rather than an inherited Warp
|
/// This data belongs to the local installation rather than an inherited Warp
|
||||||
/// account, so logging out of a legacy account must not clear it.
|
/// account, so logging out of a legacy account must not clear it.
|
||||||
|
#[derive(Default)]
|
||||||
pub struct PersistedData {
|
pub struct PersistedData {
|
||||||
/// Session restoration data
|
/// Session restoration data
|
||||||
pub app_state: AppState,
|
pub app_state: AppState,
|
||||||
|
|||||||
+280
-97
@@ -136,7 +136,10 @@ pub fn initialize(
|
|||||||
let database_path = database_file_path_for_scope(&scope);
|
let database_path = database_file_path_for_scope(&scope);
|
||||||
match init_db(&scope) {
|
match init_db(&scope) {
|
||||||
Ok(mut conn) => {
|
Ok(mut conn) => {
|
||||||
let persisted_data = read_persisted_data(&mut conn, ctx);
|
let (persisted_data, restore_complete) = read_persisted_data(&mut conn, ctx);
|
||||||
|
if !restore_complete {
|
||||||
|
return (Some(persisted_data), None);
|
||||||
|
}
|
||||||
|
|
||||||
let writer_handles = match start_writer(conn, database_path.clone()) {
|
let writer_handles = match start_writer(conn, database_path.clone()) {
|
||||||
Ok(writer_handles) => Some(writer_handles),
|
Ok(writer_handles) => Some(writer_handles),
|
||||||
@@ -149,7 +152,7 @@ pub fn initialize(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
(persisted_data, writer_handles)
|
(Some(persisted_data), writer_handles)
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
send_telemetry_from_app_ctx!(
|
send_telemetry_from_app_ctx!(
|
||||||
@@ -165,16 +168,14 @@ pub fn initialize(
|
|||||||
fn read_persisted_data(
|
fn read_persisted_data(
|
||||||
conn: &mut SqliteConnection,
|
conn: &mut SqliteConnection,
|
||||||
ctx: &mut AppContext,
|
ctx: &mut AppContext,
|
||||||
) -> Option<Box<PersistedData>> {
|
) -> (Box<PersistedData>, bool) {
|
||||||
let user_uid = AuthStateProvider::as_ref(ctx).get().user_id();
|
let user_uid = AuthStateProvider::as_ref(ctx).get().user_id();
|
||||||
match read_sqlite_data(conn, user_uid) {
|
let (data, errors) = read_sqlite_data_with_recovery(conn, user_uid);
|
||||||
Ok(app_state) => Some(Box::new(app_state)),
|
if !errors.is_empty() {
|
||||||
Err(err) => {
|
send_telemetry_from_app_ctx!(TelemetryEvent::DatabaseReadError(errors.join("; ")), ctx);
|
||||||
send_telemetry_from_app_ctx!(TelemetryEvent::DatabaseReadError(err.to_string()), ctx);
|
report_error!(anyhow!("Incomplete SQLite restore; database writes are disabled for this launch to preserve existing data: {}", errors.join("; ")));
|
||||||
report_error!(anyhow::Error::new(err).context("Failed to read persisted data"));
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
(Box::new(data), errors.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a read-only connection to the sqlite database.
|
/// Returns a read-only connection to the sqlite database.
|
||||||
@@ -466,6 +467,7 @@ fn start_writer(conn: SqliteConnection, database_path: PathBuf) -> Result<Writer
|
|||||||
let handle = thread::Builder::new()
|
let handle = thread::Builder::new()
|
||||||
.name("SQLite Writer".into())
|
.name("SQLite Writer".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
|
let mut last_saved_snapshot = None;
|
||||||
loop {
|
loop {
|
||||||
let events = match rx.recv() {
|
let events = match rx.recv() {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
@@ -490,6 +492,15 @@ fn start_writer(conn: SqliteConnection, database_path: PathBuf) -> Result<Writer
|
|||||||
log::info!("Shutting down SQLite writer thread");
|
log::info!("Shutting down SQLite writer thread");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
ModelEvent::Snapshot(app_state) => {
|
||||||
|
if let Err(err) = save_snapshot_if_changed(
|
||||||
|
&mut current_conn,
|
||||||
|
app_state,
|
||||||
|
&mut last_saved_snapshot,
|
||||||
|
) {
|
||||||
|
report_db_error("session snapshot", err, &database_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
event => {
|
event => {
|
||||||
if let Err(err) = handle_model_event(event, &mut current_conn) {
|
if let Err(err) = handle_model_event(event, &mut current_conn) {
|
||||||
report_db_error("Model", err, &database_path);
|
report_db_error("Model", err, &database_path);
|
||||||
@@ -502,6 +513,43 @@ fn start_writer(conn: SqliteConnection, database_path: PathBuf) -> Result<Writer
|
|||||||
Ok(WriterHandles { handle, sender: tx })
|
Ok(WriterHandles { handle, sender: tx })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Only committed snapshots may suppress a later save, including the shutdown save.
|
||||||
|
fn save_snapshot_if_changed(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
snapshot: AppState,
|
||||||
|
last_saved: &mut Option<AppState>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if last_saved.as_ref() == Some(&snapshot) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
retry_sqlite_write(|| save_app_state(conn, &snapshot))?;
|
||||||
|
*last_saved = Some(snapshot);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retry transactional, idempotent saves on transient SQLite contention. Permanent
|
||||||
|
/// failures remain errors, and must never be recorded as committed state.
|
||||||
|
fn retry_sqlite_write<T>(mut write: impl FnMut() -> Result<T>) -> Result<T> {
|
||||||
|
for attempt in 0..3 {
|
||||||
|
match write() {
|
||||||
|
Ok(value) => return Ok(value),
|
||||||
|
Err(error) => {
|
||||||
|
let busy = error.chain().any(|cause| {
|
||||||
|
cause.downcast_ref::<Error>().is_some_and(|error| {
|
||||||
|
matches!(error, Error::DatabaseError(_, info)
|
||||||
|
if info.message().contains("locked") || info.message().contains("busy"))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if !busy || attempt == 2 {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
thread::sleep(std::time::Duration::from_millis(100 * (attempt + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unreachable!("the last write attempt always returns")
|
||||||
|
}
|
||||||
|
|
||||||
/// Handles a single [`ModelEvent`] by dispatching to an event-specific function.
|
/// Handles a single [`ModelEvent`] by dispatching to an event-specific function.
|
||||||
/// Events which affect the SQLite writer event loop _must_ instead be handled by the event loop itself:
|
/// Events which affect the SQLite writer event loop _must_ instead be handled by the event loop itself:
|
||||||
/// * [`ModelEvent::Terminate`]
|
/// * [`ModelEvent::Terminate`]
|
||||||
@@ -636,13 +684,15 @@ fn handle_model_event(event: ModelEvent, connection: &mut SqliteConnection) -> a
|
|||||||
conversation_id,
|
conversation_id,
|
||||||
updated_tasks,
|
updated_tasks,
|
||||||
conversation_data,
|
conversation_data,
|
||||||
} => upsert_agent_conversation(
|
} => retry_sqlite_write(|| {
|
||||||
|
upsert_agent_conversation(
|
||||||
connection,
|
connection,
|
||||||
&conversation_id,
|
&conversation_id,
|
||||||
&updated_tasks,
|
&updated_tasks,
|
||||||
conversation_data,
|
conversation_data.clone(),
|
||||||
)
|
)
|
||||||
.map_err(anyhow::Error::from),
|
.map_err(anyhow::Error::from)
|
||||||
|
}),
|
||||||
ModelEvent::DeleteMultiAgentConversations { conversation_ids } => {
|
ModelEvent::DeleteMultiAgentConversations { conversation_ids } => {
|
||||||
delete_agent_conversations(connection, conversation_ids)
|
delete_agent_conversations(connection, conversation_ids)
|
||||||
.map_err(anyhow::Error::from)
|
.map_err(anyhow::Error::from)
|
||||||
@@ -2355,19 +2405,200 @@ fn box_persisted_generic_string_object(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This is not in a transaction. The interface for a transaction is a bit awkward,
|
/// Use a consistent SQLite snapshot even when another Galaxy process is writing.
|
||||||
/// and makes it invalid to write the logic recursively. It's ok it's not in a
|
/// A failed domain is reported independently; incomplete restoration disables writes.
|
||||||
/// transaction because we should be the only connection using the database.
|
fn read_sqlite_data_with_recovery(
|
||||||
///
|
conn: &mut SqliteConnection,
|
||||||
/// One notable exception is the case where there may be two warp apps running
|
current_user_id: Option<UserUid>,
|
||||||
/// in the same bundle. In this case, we may read some garbage, but all that will
|
) -> (PersistedData, Vec<String>) {
|
||||||
/// happen is the user won't have session restoration.
|
match conn.transaction::<_, Error, _>(|conn| Ok(read_sqlite_domains(conn, current_user_id))) {
|
||||||
///
|
Ok(result) => result,
|
||||||
/// In the future, the awkwardness of the transaction interface is resolved in diesel 2.0.0.
|
Err(error) => (
|
||||||
|
PersistedData::default(),
|
||||||
|
vec![format!("Failed to read SQLite snapshot: {error}")],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_sqlite_domains(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
current_user_id: Option<UserUid>,
|
||||||
|
) -> (PersistedData, Vec<String>) {
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
let layout = read_session_layout(conn, &mut errors);
|
||||||
|
let (windows, active_window_index) = recover_read("session layout", layout, &mut errors);
|
||||||
|
let cloud_objects = recover_read(
|
||||||
|
"local objects",
|
||||||
|
read_cloud_objects(conn, current_user_id),
|
||||||
|
&mut errors,
|
||||||
|
);
|
||||||
|
let (workspaces, current_workspace_uid) =
|
||||||
|
recover_read("workspaces", read_workspaces(conn), &mut errors);
|
||||||
|
let commands = recover_read(
|
||||||
|
"commands",
|
||||||
|
(|| -> Result<_, Error> {
|
||||||
|
let commands = schema::commands::dsl::commands
|
||||||
|
// Ensure the commands come into memory sorted chronologically.
|
||||||
|
.order(schema::commands::columns::id.desc())
|
||||||
|
.load_iter::<model::Command, DefaultLoadingMode>(conn)?
|
||||||
|
.filter_map(|command| command.ok())
|
||||||
|
.map(PersistedCommand::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(commands)
|
||||||
|
})(),
|
||||||
|
&mut errors,
|
||||||
|
);
|
||||||
|
let user_profiles = recover_read(
|
||||||
|
"user_profiles",
|
||||||
|
(|| -> Result<_, Error> {
|
||||||
|
let user_profiles = schema::user_profiles::dsl::user_profiles
|
||||||
|
.load_iter::<model::UserProfile, DefaultLoadingMode>(conn)?
|
||||||
|
.filter_map(|user_profile| user_profile.ok())
|
||||||
|
.map(user_profile_from_persistence)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(user_profiles)
|
||||||
|
})(),
|
||||||
|
&mut errors,
|
||||||
|
);
|
||||||
|
let object_actions = recover_read(
|
||||||
|
"object_actions",
|
||||||
|
(|| -> Result<_, Error> {
|
||||||
|
let object_actions: Vec<ObjectAction> = schema::object_actions::dsl::object_actions
|
||||||
|
.load_iter::<model::PersistedObjectAction, DefaultLoadingMode>(conn)?
|
||||||
|
.filter_map(|object_action| object_action.ok()) // parse into PersistedObjectAction
|
||||||
|
.filter_map(|action| object_action_from_persisted(action).ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(object_actions)
|
||||||
|
})(),
|
||||||
|
&mut errors,
|
||||||
|
);
|
||||||
|
let server_experiments = recover_read(
|
||||||
|
"server_experiments",
|
||||||
|
(|| -> Result<_, Error> {
|
||||||
|
let server_experiments = schema::server_experiments::dsl::server_experiments
|
||||||
|
.load_iter::<model::ServerExperiment, DefaultLoadingMode>(conn)?
|
||||||
|
.filter_map(|server_experiment| server_experiment.ok())
|
||||||
|
.filter_map(|server_experiment| {
|
||||||
|
ServerExperiment::from_string(server_experiment.experiment).ok()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(server_experiments)
|
||||||
|
})(),
|
||||||
|
&mut errors,
|
||||||
|
);
|
||||||
|
let restored_blocks = recover_read(
|
||||||
|
"restored_blocks",
|
||||||
|
get_all_restored_blocks(conn),
|
||||||
|
&mut errors,
|
||||||
|
);
|
||||||
|
let running_mcp_servers = recover_read(
|
||||||
|
"running_mcp_servers",
|
||||||
|
load_active_mcp_servers(conn),
|
||||||
|
&mut errors,
|
||||||
|
);
|
||||||
|
let time_of_next_force_object_refresh = recover_read(
|
||||||
|
"time_of_next_force_object_refresh",
|
||||||
|
read_time_of_next_force_object_refresh(conn),
|
||||||
|
&mut errors,
|
||||||
|
);
|
||||||
|
let ai_queries = recover_read("ai_queries", read_ai_queries(conn), &mut errors);
|
||||||
|
let codebase_indices = recover_read(
|
||||||
|
"codebase_indices",
|
||||||
|
get_all_codebase_index_metadata(conn),
|
||||||
|
&mut errors,
|
||||||
|
);
|
||||||
|
let workspace_language_servers = recover_read(
|
||||||
|
"workspace_language_servers",
|
||||||
|
get_all_workspace_language_servers_by_workspace(conn),
|
||||||
|
&mut errors,
|
||||||
|
);
|
||||||
|
let conversations = read_agent_conversations(conn, &mut errors);
|
||||||
|
let multi_agent_conversations =
|
||||||
|
recover_read("multi_agent_conversations", conversations, &mut errors);
|
||||||
|
let projects = recover_read("projects", get_all_projects(conn), &mut errors);
|
||||||
|
let project_rules = recover_read("project_rules", get_all_project_rules(conn), &mut errors);
|
||||||
|
let ignored_suggestions = recover_read(
|
||||||
|
"ignored_suggestions",
|
||||||
|
get_all_ignored_suggestions(conn),
|
||||||
|
&mut errors,
|
||||||
|
);
|
||||||
|
let mcp_server_installations = recover_read(
|
||||||
|
"mcp_server_installations",
|
||||||
|
get_all_mcp_server_installations(conn),
|
||||||
|
&mut errors,
|
||||||
|
);
|
||||||
|
let mcp_servers_to_restore = recover_read(
|
||||||
|
"mcp_servers_to_restore",
|
||||||
|
get_mcp_servers_to_restore(conn),
|
||||||
|
&mut errors,
|
||||||
|
);
|
||||||
|
let app_state = AppState {
|
||||||
|
windows,
|
||||||
|
active_window_index,
|
||||||
|
block_lists: Arc::new(restored_blocks),
|
||||||
|
running_mcp_servers,
|
||||||
|
};
|
||||||
|
(
|
||||||
|
PersistedData {
|
||||||
|
app_state,
|
||||||
|
cloud_objects,
|
||||||
|
workspaces,
|
||||||
|
current_workspace_uid,
|
||||||
|
command_history: commands,
|
||||||
|
user_profiles,
|
||||||
|
time_of_next_force_object_refresh,
|
||||||
|
object_actions,
|
||||||
|
experiments: server_experiments,
|
||||||
|
ai_queries,
|
||||||
|
codebase_indices,
|
||||||
|
workspace_language_servers,
|
||||||
|
multi_agent_conversations,
|
||||||
|
projects,
|
||||||
|
project_rules,
|
||||||
|
ignored_suggestions,
|
||||||
|
mcp_server_installations,
|
||||||
|
mcp_servers_to_restore,
|
||||||
|
},
|
||||||
|
errors,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recover_read<T: Default, E: std::fmt::Display>(
|
||||||
|
domain: &str,
|
||||||
|
result: Result<T, E>,
|
||||||
|
errors: &mut Vec<String>,
|
||||||
|
) -> T {
|
||||||
|
match result {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
let message = format!("Failed to restore {domain}: {error}");
|
||||||
|
log::error!("{message}");
|
||||||
|
errors.push(message);
|
||||||
|
T::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
fn read_sqlite_data(
|
fn read_sqlite_data(
|
||||||
conn: &mut SqliteConnection,
|
conn: &mut SqliteConnection,
|
||||||
current_user_id: Option<UserUid>,
|
current_user_id: Option<UserUid>,
|
||||||
) -> Result<PersistedData, Error> {
|
) -> Result<PersistedData> {
|
||||||
|
let (data, errors) = read_sqlite_data_with_recovery(conn, current_user_id);
|
||||||
|
if !errors.is_empty() {
|
||||||
|
bail!("{}", errors.join("; "));
|
||||||
|
}
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_session_layout(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
errors: &mut Vec<String>,
|
||||||
|
) -> Result<(Vec<WindowSnapshot>, Option<usize>), Error> {
|
||||||
use schema::windows::dsl::*;
|
use schema::windows::dsl::*;
|
||||||
|
|
||||||
let active_window_id = schema::app::dsl::app
|
let active_window_id = schema::app::dsl::app
|
||||||
@@ -2427,7 +2658,18 @@ fn read_sqlite_data(
|
|||||||
let saved_tabs: Vec<_> = tabs_for_window
|
let saved_tabs: Vec<_> = tabs_for_window
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|tab| {
|
.filter_map(|tab| {
|
||||||
let root = read_root_node(conn, tab.id).ok()?;
|
let root = match read_root_node(conn, tab.id) {
|
||||||
|
Ok(root) => root,
|
||||||
|
Err(error) => {
|
||||||
|
let message = format!(
|
||||||
|
"Failed to restore session layout tab {}: {error}",
|
||||||
|
tab.id
|
||||||
|
);
|
||||||
|
log::error!("{message}");
|
||||||
|
errors.push(message);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
let panel = db_panels.get(&tab.id);
|
let panel = db_panels.get(&tab.id);
|
||||||
|
|
||||||
let left_panel = panel
|
let left_panel = panel
|
||||||
@@ -2560,6 +2802,13 @@ fn read_sqlite_data(
|
|||||||
)
|
)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
Ok((saved_windows, active_window_index))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_cloud_objects(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
current_user_id: Option<UserUid>,
|
||||||
|
) -> Result<Vec<Box<dyn CloudObject>>, Error> {
|
||||||
let read_context = load_cloud_object_read_context(conn, current_user_id)?;
|
let read_context = load_cloud_object_read_context(conn, current_user_id)?;
|
||||||
let mut cloud_objects: Vec<Box<dyn CloudObject>> = Vec::new();
|
let mut cloud_objects: Vec<Box<dyn CloudObject>> = Vec::new();
|
||||||
cloud_objects.extend(
|
cloud_objects.extend(
|
||||||
@@ -2583,6 +2832,12 @@ fn read_sqlite_data(
|
|||||||
.map(box_persisted_generic_string_object),
|
.map(box_persisted_generic_string_object),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Ok(cloud_objects)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_workspaces(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
) -> Result<(Vec<WorkspaceMetadata>, Option<WorkspaceUid>), Error> {
|
||||||
let db_teams: Vec<model::Team> = schema::teams::dsl::teams.load(conn)?;
|
let db_teams: Vec<model::Team> = schema::teams::dsl::teams.load(conn)?;
|
||||||
|
|
||||||
let team_member_rows: Vec<model::TeamMemberRow> =
|
let team_member_rows: Vec<model::TeamMemberRow> =
|
||||||
@@ -2671,79 +2926,7 @@ fn read_sqlite_data(
|
|||||||
.optional()?
|
.optional()?
|
||||||
.map(|uid| uid.into());
|
.map(|uid| uid.into());
|
||||||
|
|
||||||
let commands = schema::commands::dsl::commands
|
Ok((workspaces, current_workspace_uid))
|
||||||
// Ensure the commands come into memory sorted chronologically.
|
|
||||||
.order(schema::commands::columns::id.desc())
|
|
||||||
.load_iter::<model::Command, DefaultLoadingMode>(conn)?
|
|
||||||
.filter_map(|command| command.ok())
|
|
||||||
.map(PersistedCommand::from)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let user_profiles = schema::user_profiles::dsl::user_profiles
|
|
||||||
.load_iter::<model::UserProfile, DefaultLoadingMode>(conn)?
|
|
||||||
.filter_map(|user_profile| user_profile.ok())
|
|
||||||
.map(user_profile_from_persistence)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let object_actions: Vec<ObjectAction> = schema::object_actions::dsl::object_actions
|
|
||||||
.load_iter::<model::PersistedObjectAction, DefaultLoadingMode>(conn)?
|
|
||||||
.filter_map(|object_action| object_action.ok()) // parse into PersistedObjectAction
|
|
||||||
.filter_map(|action| object_action_from_persisted(action).ok())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let server_experiments = schema::server_experiments::dsl::server_experiments
|
|
||||||
.load_iter::<model::ServerExperiment, DefaultLoadingMode>(conn)?
|
|
||||||
.filter_map(|server_experiment| server_experiment.ok())
|
|
||||||
.filter_map(|server_experiment| {
|
|
||||||
ServerExperiment::from_string(server_experiment.experiment).ok()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let restored_blocks = get_all_restored_blocks(conn)?;
|
|
||||||
|
|
||||||
// Load active MCP servers from database
|
|
||||||
let running_mcp_servers = load_active_mcp_servers(conn)?;
|
|
||||||
|
|
||||||
let app_state = AppState {
|
|
||||||
windows: saved_windows,
|
|
||||||
active_window_index,
|
|
||||||
block_lists: Arc::new(restored_blocks),
|
|
||||||
running_mcp_servers,
|
|
||||||
};
|
|
||||||
|
|
||||||
let time_of_next_force_object_refresh = read_time_of_next_force_object_refresh(conn)?;
|
|
||||||
|
|
||||||
let ai_queries = read_ai_queries(conn)?;
|
|
||||||
|
|
||||||
let codebase_indices = get_all_codebase_index_metadata(conn)?;
|
|
||||||
let workspace_language_servers = get_all_workspace_language_servers_by_workspace(conn)?;
|
|
||||||
let multi_agent_conversations = read_agent_conversations(conn)?;
|
|
||||||
let projects = get_all_projects(conn)?;
|
|
||||||
let project_rules = get_all_project_rules(conn)?;
|
|
||||||
let ignored_suggestions = get_all_ignored_suggestions(conn)?;
|
|
||||||
let mcp_server_installations = get_all_mcp_server_installations(conn)?;
|
|
||||||
let mcp_servers_to_restore = get_mcp_servers_to_restore(conn)?;
|
|
||||||
|
|
||||||
Ok(PersistedData {
|
|
||||||
app_state,
|
|
||||||
cloud_objects,
|
|
||||||
workspaces,
|
|
||||||
current_workspace_uid,
|
|
||||||
command_history: commands,
|
|
||||||
user_profiles,
|
|
||||||
time_of_next_force_object_refresh,
|
|
||||||
object_actions,
|
|
||||||
experiments: server_experiments,
|
|
||||||
ai_queries,
|
|
||||||
codebase_indices,
|
|
||||||
workspace_language_servers,
|
|
||||||
multi_agent_conversations,
|
|
||||||
projects,
|
|
||||||
project_rules,
|
|
||||||
ignored_suggestions,
|
|
||||||
mcp_server_installations,
|
|
||||||
mcp_servers_to_restore,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<StartedCommandMetadata> for model::NewCommand {
|
impl From<StartedCommandMetadata> for model::NewCommand {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use ai::workspace::WorkspaceMetadata;
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use cloud_object_persistence::to_cloud_object_permissions;
|
use cloud_object_persistence::to_cloud_object_permissions;
|
||||||
use diesel::connection::SimpleConnection;
|
use diesel::connection::SimpleConnection;
|
||||||
|
use galaxy_agent_core::{ConversationMessage, MessageContent, MessageRole};
|
||||||
use galaxy_core::features::FeatureFlag;
|
use galaxy_core::features::FeatureFlag;
|
||||||
use galaxy_graphql::scalars::time::ServerTimestamp;
|
use galaxy_graphql::scalars::time::ServerTimestamp;
|
||||||
use pathfinder_geometry::rect::RectF;
|
use pathfinder_geometry::rect::RectF;
|
||||||
@@ -15,6 +16,7 @@ use super::{
|
|||||||
encode_path, get_all_codebase_index_metadata, read_sqlite_data, save_app_state,
|
encode_path, get_all_codebase_index_metadata, read_sqlite_data, save_app_state,
|
||||||
save_codebase_index_metadata, setup_database, start_writer, GALAXY_SQLITE_FILE_NAME,
|
save_codebase_index_metadata, setup_database, start_writer, GALAXY_SQLITE_FILE_NAME,
|
||||||
};
|
};
|
||||||
|
use crate::ai::agent::conversation::{AIConversation, AIConversationId};
|
||||||
use crate::ai::facts::{AIFact, AIMemory, CloudAIFact};
|
use crate::ai::facts::{AIFact, AIMemory, CloudAIFact};
|
||||||
use crate::app_state::{
|
use crate::app_state::{
|
||||||
AppState, CodePaneSnapShot, CodePaneTabSnapshot, LeafContents, LeafSnapshot, PaneNodeSnapshot,
|
AppState, CodePaneSnapShot, CodePaneTabSnapshot, LeafContents, LeafSnapshot, PaneNodeSnapshot,
|
||||||
@@ -23,7 +25,10 @@ use crate::app_state::{
|
|||||||
use crate::cloud_object::{CloudObjectPermissions, Owner};
|
use crate::cloud_object::{CloudObjectPermissions, Owner};
|
||||||
use crate::code::editor_management::CodeSource;
|
use crate::code::editor_management::CodeSource;
|
||||||
use crate::notebooks::{CloudNotebook, CloudNotebookModel};
|
use crate::notebooks::{CloudNotebook, CloudNotebookModel};
|
||||||
use crate::persistence::model::ObjectPermissions;
|
use crate::persistence::agent::upsert_agent_conversation;
|
||||||
|
use crate::persistence::model::{
|
||||||
|
AgentConversationData, ObjectPermissions, ProviderConversationContext,
|
||||||
|
};
|
||||||
use crate::persistence::{BlockCompleted, ModelEvent, PersistenceScope};
|
use crate::persistence::{BlockCompleted, ModelEvent, PersistenceScope};
|
||||||
use crate::server::ids::{ClientId, SyncId};
|
use crate::server::ids::{ClientId, SyncId};
|
||||||
use crate::tab::SelectedTabColor;
|
use crate::tab::SelectedTabColor;
|
||||||
@@ -1125,3 +1130,144 @@ fn test_sqlite_drops_too_small_bounds_on_read() {
|
|||||||
"tiny persisted bounds must be discarded on read so users recover from a corrupt DB"
|
"tiny persisted bounds must be discarded on read so users recover from a corrupt DB"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_snapshot_is_not_cached_and_can_be_retried() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut conn = setup_database(&dir.path().join("galaxy.sqlite")).unwrap();
|
||||||
|
let snapshot = AppState {
|
||||||
|
windows: vec![test_terminal_window_snapshot(false)],
|
||||||
|
active_window_index: Some(0),
|
||||||
|
block_lists: Default::default(),
|
||||||
|
running_mcp_servers: Vec::new(),
|
||||||
|
};
|
||||||
|
conn.batch_execute("CREATE TRIGGER reject_window BEFORE INSERT ON windows BEGIN SELECT RAISE(ABORT, 'injected write failure'); END;").unwrap();
|
||||||
|
let mut last_saved = None;
|
||||||
|
assert!(super::save_snapshot_if_changed(&mut conn, snapshot.clone(), &mut last_saved).is_err());
|
||||||
|
assert!(last_saved.is_none());
|
||||||
|
conn.batch_execute("DROP TRIGGER reject_window;").unwrap();
|
||||||
|
super::save_snapshot_if_changed(&mut conn, snapshot.clone(), &mut last_saved).unwrap();
|
||||||
|
assert_eq!(last_saved, Some(snapshot));
|
||||||
|
assert_eq!(
|
||||||
|
read_sqlite_data(&mut conn, None)
|
||||||
|
.unwrap()
|
||||||
|
.app_state
|
||||||
|
.windows
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_busy_writes_retry_but_permanent_failures_do_not() {
|
||||||
|
let mut attempts = 0;
|
||||||
|
super::retry_sqlite_write(|| {
|
||||||
|
attempts += 1;
|
||||||
|
if attempts < 3 {
|
||||||
|
return Err(diesel::result::Error::DatabaseError(
|
||||||
|
diesel::result::DatabaseErrorKind::Unknown,
|
||||||
|
Box::new("database is locked".to_owned()),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(attempts, 3);
|
||||||
|
attempts = 0;
|
||||||
|
assert!(super::retry_sqlite_write(|| -> anyhow::Result<()> {
|
||||||
|
attempts += 1;
|
||||||
|
anyhow::bail!("permanent failure")
|
||||||
|
})
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(attempts, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incomplete_restore_keeps_independent_domains_and_reports_failure() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut conn = setup_database(&dir.path().join("galaxy.sqlite")).unwrap();
|
||||||
|
let snapshot = AppState {
|
||||||
|
windows: vec![test_terminal_window_snapshot(false)],
|
||||||
|
active_window_index: Some(0),
|
||||||
|
block_lists: Default::default(),
|
||||||
|
running_mcp_servers: Vec::new(),
|
||||||
|
};
|
||||||
|
save_app_state(&mut conn, &snapshot).unwrap();
|
||||||
|
conn.batch_execute("DROP TABLE ignored_suggestions;")
|
||||||
|
.unwrap();
|
||||||
|
let (restored, errors) = super::read_sqlite_data_with_recovery(&mut conn, None);
|
||||||
|
assert_eq!(restored.app_state.windows.len(), 1);
|
||||||
|
assert_eq!(errors.len(), 1);
|
||||||
|
assert!(errors[0].contains("ignored_suggestions"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_tab_is_reported_instead_of_silently_replacing_saved_layout() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut conn = setup_database(&dir.path().join("galaxy.sqlite")).unwrap();
|
||||||
|
let snapshot = AppState {
|
||||||
|
windows: vec![test_terminal_window_snapshot(false)],
|
||||||
|
active_window_index: Some(0),
|
||||||
|
block_lists: Default::default(),
|
||||||
|
running_mcp_servers: Vec::new(),
|
||||||
|
};
|
||||||
|
save_app_state(&mut conn, &snapshot).unwrap();
|
||||||
|
conn.batch_execute("DELETE FROM terminal_panes;").unwrap();
|
||||||
|
let (_, errors) = super::read_sqlite_data_with_recovery(&mut conn, None);
|
||||||
|
assert!(!errors.is_empty());
|
||||||
|
assert!(errors.iter().any(|error| error.contains("session layout")));
|
||||||
|
// Reading alone must leave the recoverable tab record in place.
|
||||||
|
conn.batch_execute("CREATE TRIGGER require_saved_tab BEFORE DELETE ON tabs BEGIN SELECT RAISE(ABORT, 'must retain tab'); END;").unwrap();
|
||||||
|
assert!(save_app_state(&mut conn, &snapshot).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_context_round_trips_through_sqlite_without_terminal_blocks() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut conn = setup_database(&dir.path().join("galaxy.sqlite")).unwrap();
|
||||||
|
let id = AIConversationId::new();
|
||||||
|
let context = ProviderConversationContext {
|
||||||
|
messages: vec![ConversationMessage {
|
||||||
|
role: MessageRole::Assistant,
|
||||||
|
content: MessageContent::Text("Most recent response".into()),
|
||||||
|
}],
|
||||||
|
tool_result_archive: vec![ConversationMessage {
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: MessageContent::ToolResult {
|
||||||
|
tool_use_id: "old-call".into(),
|
||||||
|
content: "archived output".into(),
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
upsert_agent_conversation(
|
||||||
|
&mut conn,
|
||||||
|
&id.to_string(),
|
||||||
|
&[],
|
||||||
|
AgentConversationData {
|
||||||
|
provider_context: Some(context.clone()),
|
||||||
|
progressive_summary: Some("Earlier conversation".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.batch_execute("INSERT INTO agent_conversations (conversation_id, conversation_data) VALUES ('corrupt', 'not-json');").unwrap();
|
||||||
|
let (healthy, errors) = super::read_sqlite_data_with_recovery(&mut conn, None);
|
||||||
|
assert_eq!(healthy.multi_agent_conversations.len(), 1);
|
||||||
|
assert_eq!(errors.len(), 1);
|
||||||
|
assert!(errors[0].contains("corrupt"));
|
||||||
|
conn.batch_execute("DELETE FROM agent_conversations WHERE conversation_id = 'corrupt';")
|
||||||
|
.unwrap();
|
||||||
|
let mut data = read_sqlite_data(&mut conn, None).unwrap();
|
||||||
|
assert!(data.app_state.block_lists.is_empty());
|
||||||
|
let saved = data.multi_agent_conversations.pop().unwrap();
|
||||||
|
let restored = AIConversation::new_restored_synthesizing_on_empty(
|
||||||
|
id,
|
||||||
|
saved.tasks,
|
||||||
|
Some(serde_json::from_str(&saved.conversation.conversation_data).unwrap()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(restored.provider_context(), context);
|
||||||
|
assert_eq!(restored.progressive_summary(), Some("Earlier conversation"));
|
||||||
|
}
|
||||||
|
|||||||
@@ -2457,6 +2457,7 @@ fn test_histignorespace_support_in_zsh() {
|
|||||||
(terminal.model.clone(), terminal.sessions_model().clone())
|
(terminal.model.clone(), terminal.sessions_model().clone())
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let block_id = model.lock().active_block_id().clone();
|
||||||
app.update(|ctx| {
|
app.update(|ctx| {
|
||||||
update_command_history(
|
update_command_history(
|
||||||
&ExecuteCommandEvent {
|
&ExecuteCommandEvent {
|
||||||
@@ -2467,6 +2468,7 @@ fn test_histignorespace_support_in_zsh() {
|
|||||||
should_add_command_to_history: true,
|
should_add_command_to_history: true,
|
||||||
source: CommandExecutionSource::User,
|
source: CommandExecutionSource::User,
|
||||||
},
|
},
|
||||||
|
&block_id,
|
||||||
&model,
|
&model,
|
||||||
None,
|
None,
|
||||||
&sessions,
|
&sessions,
|
||||||
@@ -2482,6 +2484,7 @@ fn test_histignorespace_support_in_zsh() {
|
|||||||
should_add_command_to_history: true,
|
should_add_command_to_history: true,
|
||||||
source: CommandExecutionSource::User,
|
source: CommandExecutionSource::User,
|
||||||
},
|
},
|
||||||
|
&block_id,
|
||||||
&model,
|
&model,
|
||||||
None,
|
None,
|
||||||
&sessions,
|
&sessions,
|
||||||
|
|||||||
@@ -3022,9 +3022,12 @@ impl ansi::Handler for TerminalModel {
|
|||||||
self.is_receiving_in_band_command_output = IsReceivingInBandCommandOutput::No;
|
self.is_receiving_in_band_command_output = IsReceivingInBandCommandOutput::No;
|
||||||
}
|
}
|
||||||
IsReceivingInBandCommandOutput::No => {
|
IsReceivingInBandCommandOutput::No => {
|
||||||
|
// Command dispatch calls this defensively even when no in-band output is active.
|
||||||
|
if from_osc_sequence {
|
||||||
log::warn!("Received 'end_in_band_command_output' while not expecting to read in-band command output.");
|
log::warn!("Received 'end_in_band_command_output' while not expecting to read in-band command output.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
if from_osc_sequence {
|
if from_osc_sequence {
|
||||||
|
|||||||
+14
-11
@@ -1763,6 +1763,7 @@ pub enum Event {
|
|||||||
size_update: SizeUpdate,
|
size_update: SizeUpdate,
|
||||||
},
|
},
|
||||||
ExecuteCommand(ExecuteCommandEvent),
|
ExecuteCommand(ExecuteCommandEvent),
|
||||||
|
CancelQueuedAgentCommand(AIAgentActionId),
|
||||||
BlockStarted {
|
BlockStarted {
|
||||||
is_for_in_band_command: bool,
|
is_for_in_band_command: bool,
|
||||||
},
|
},
|
||||||
@@ -7695,17 +7696,8 @@ impl TerminalView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let block_id = model.active_block_id().clone();
|
|
||||||
drop(model);
|
drop(model);
|
||||||
|
|
||||||
self.cli_subagent_controller.update(ctx, |controller, _| {
|
|
||||||
controller.track_requested_command(
|
|
||||||
&block_id,
|
|
||||||
parent_conversation_id,
|
|
||||||
action_id,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.emit(Event::ExecuteCommand(ExecuteCommandEvent {
|
ctx.emit(Event::ExecuteCommand(ExecuteCommandEvent {
|
||||||
command,
|
command,
|
||||||
session_id,
|
session_id,
|
||||||
@@ -7721,8 +7713,6 @@ impl TerminalView {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
self.schedule_command_monitor_start(block_id, ctx);
|
|
||||||
|
|
||||||
if let Some(metadata) = workflow_telem_metadata {
|
if let Some(metadata) = workflow_telem_metadata {
|
||||||
send_telemetry_from_ctx!(TelemetryEvent::WorkflowExecuted(metadata), ctx);
|
send_telemetry_from_ctx!(TelemetryEvent::WorkflowExecuted(metadata), ctx);
|
||||||
}
|
}
|
||||||
@@ -7732,6 +7722,7 @@ impl TerminalView {
|
|||||||
self.write_agent_bytes_to_pty(input.to_vec(), mode, ctx);
|
self.write_agent_bytes_to_pty(input.to_vec(), mode, ctx);
|
||||||
}
|
}
|
||||||
ShellCommandExecutorEvent::CancelExecution { action_id } => {
|
ShellCommandExecutorEvent::CancelExecution { action_id } => {
|
||||||
|
ctx.emit(Event::CancelQueuedAgentCommand(action_id.clone()));
|
||||||
// We need to manually invoke ctrl-c to terminate the running command because the
|
// We need to manually invoke ctrl-c to terminate the running command because the
|
||||||
// user's ctrl-c was directed to the AIBlock instead of the command's shell block.
|
// user's ctrl-c was directed to the AIBlock instead of the command's shell block.
|
||||||
let is_exact_active_command = self
|
let is_exact_active_command = self
|
||||||
@@ -12185,6 +12176,15 @@ impl TerminalView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.did_notify_long_running = false;
|
self.did_notify_long_running = false;
|
||||||
|
let is_agent_command = self
|
||||||
|
.model
|
||||||
|
.lock()
|
||||||
|
.block_list()
|
||||||
|
.block_with_id(block_id)
|
||||||
|
.is_some_and(|block| block.requested_command_action_id().is_some());
|
||||||
|
if is_agent_command {
|
||||||
|
self.schedule_command_monitor_start(block_id.clone(), ctx);
|
||||||
|
}
|
||||||
|
|
||||||
// Snapshot the prompt state as of when the command began executing.
|
// Snapshot the prompt state as of when the command began executing.
|
||||||
// Commands may themselves affect the prompt (if running `git checkout`), for
|
// Commands may themselves affect the prompt (if running `git checkout`), for
|
||||||
@@ -26257,6 +26257,9 @@ impl PtyIntentEvent for Event {
|
|||||||
}),
|
}),
|
||||||
Event::Resize { size_update } => Some(PtyIntent::Resize(*size_update)),
|
Event::Resize { size_update } => Some(PtyIntent::Resize(*size_update)),
|
||||||
Event::ExecuteCommand(event) => Some(PtyIntent::ExecuteCommand(event.clone())),
|
Event::ExecuteCommand(event) => Some(PtyIntent::ExecuteCommand(event.clone())),
|
||||||
|
Event::CancelQueuedAgentCommand(action_id) => {
|
||||||
|
Some(PtyIntent::CancelQueuedAgentCommand(action_id.clone()))
|
||||||
|
}
|
||||||
Event::RunNativeShellCompletions {
|
Event::RunNativeShellCompletions {
|
||||||
buffer_text,
|
buffer_text,
|
||||||
results_tx,
|
results_tx,
|
||||||
|
|||||||
@@ -943,6 +943,7 @@ impl TerminalView {
|
|||||||
|
|
||||||
let conversation_data = AgentConversationData {
|
let conversation_data = AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
|
provider_context: None,
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
local_agent_groups: Vec::new(),
|
local_agent_groups: Vec::new(),
|
||||||
tool_titles: Default::default(),
|
tool_titles: Default::default(),
|
||||||
|
|||||||
@@ -5,19 +5,23 @@ use galaxyui::{AppContext, ModelHandle, SingletonEntity};
|
|||||||
use parking_lot::FairMutex;
|
use parking_lot::FairMutex;
|
||||||
|
|
||||||
use crate::persistence::{ModelEvent, StartedCommandMetadata};
|
use crate::persistence::{ModelEvent, StartedCommandMetadata};
|
||||||
|
use crate::terminal::model::block::BlockId;
|
||||||
use crate::terminal::model::session::Sessions;
|
use crate::terminal::model::session::Sessions;
|
||||||
use crate::terminal::view::ExecuteCommandEvent;
|
use crate::terminal::view::ExecuteCommandEvent;
|
||||||
use crate::terminal::{History, HistoryEntry, TerminalModel};
|
use crate::terminal::{History, HistoryEntry, TerminalModel};
|
||||||
|
|
||||||
pub fn update_command_history(
|
pub fn update_command_history(
|
||||||
event: &ExecuteCommandEvent,
|
event: &ExecuteCommandEvent,
|
||||||
|
block_id: &BlockId,
|
||||||
model: &Arc<FairMutex<TerminalModel>>,
|
model: &Arc<FairMutex<TerminalModel>>,
|
||||||
model_event_sender: Option<&SyncSender<ModelEvent>>,
|
model_event_sender: Option<&SyncSender<ModelEvent>>,
|
||||||
sessions: &ModelHandle<Sessions>,
|
sessions: &ModelHandle<Sessions>,
|
||||||
ctx: &mut AppContext,
|
ctx: &mut AppContext,
|
||||||
) {
|
) {
|
||||||
let model = model.lock();
|
let model = model.lock();
|
||||||
let active_block = model.block_list().active_block();
|
let Some(active_block) = model.block_list().block_with_id(block_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let session_id = event.session_id;
|
let session_id = event.session_id;
|
||||||
let Some(session) = sessions.as_ref(ctx).get(session_id) else {
|
let Some(session) = sessions.as_ref(ctx).get(session_id) else {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ use parking_lot::FairMutex;
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use super::Message;
|
use super::Message;
|
||||||
use crate::ai::agent::AIAgentPtyWriteMode;
|
use crate::ai::agent::{AIAgentActionId, AIAgentPtyWriteMode};
|
||||||
use crate::terminal::input::CommandExecutionSource;
|
use crate::terminal::input::CommandExecutionSource;
|
||||||
use crate::terminal::line_editor_status::{LineEditorStatus, LineEditorStatusEvent};
|
use crate::terminal::line_editor_status::{LineEditorStatus, LineEditorStatusEvent};
|
||||||
use crate::terminal::model::ansi::Handler;
|
use crate::terminal::model::ansi::Handler;
|
||||||
|
use crate::terminal::model::block::BlockId;
|
||||||
use crate::terminal::model::completions::ShellCompletion;
|
use crate::terminal::model::completions::ShellCompletion;
|
||||||
use crate::terminal::model::escape_sequences;
|
use crate::terminal::model::escape_sequences;
|
||||||
use crate::terminal::model::session::{
|
use crate::terminal::model::session::{
|
||||||
@@ -21,7 +22,7 @@ use crate::terminal::model::session::{
|
|||||||
};
|
};
|
||||||
use crate::terminal::model_events::{AnsiHandlerEvent, ModelEvent, ModelEventDispatcher};
|
use crate::terminal::model_events::{AnsiHandlerEvent, ModelEvent, ModelEventDispatcher};
|
||||||
use crate::terminal::shell::ShellType;
|
use crate::terminal::shell::ShellType;
|
||||||
use crate::terminal::view::LINEFEED_REGEX;
|
use crate::terminal::view::{ExecuteCommandEvent, LINEFEED_REGEX};
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
use crate::terminal::writeable_pty::bootstrap_file::{permanent_bootstrap_file, TempBootstrapFile};
|
use crate::terminal::writeable_pty::bootstrap_file::{permanent_bootstrap_file, TempBootstrapFile};
|
||||||
use crate::terminal::{bootstrap, SizeUpdate, TerminalModel};
|
use crate::terminal::{bootstrap, SizeUpdate, TerminalModel};
|
||||||
@@ -46,6 +47,7 @@ enum PtyWrite {
|
|||||||
in_band_command_id: Option<String>,
|
in_band_command_id: Option<String>,
|
||||||
/// If 'some', the given callback is called right before the bytes are written to the PTY.
|
/// If 'some', the given callback is called right before the bytes are written to the PTY.
|
||||||
before_write_fn: Option<Box<dyn Fn() + Send + 'static>>,
|
before_write_fn: Option<Box<dyn Fn() + Send + 'static>>,
|
||||||
|
execution: Option<Box<ExecuteCommandEvent>>,
|
||||||
},
|
},
|
||||||
Bytes {
|
Bytes {
|
||||||
/// The bytes to be written.
|
/// The bytes to be written.
|
||||||
@@ -298,7 +300,17 @@ impl<T: EventLoopSender> PtyController<T> {
|
|||||||
cancel_tx: Sender<InBandCommandCancelledEvent>,
|
cancel_tx: Sender<InBandCommandCancelledEvent>,
|
||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) {
|
) {
|
||||||
if self.is_user_command_executing {
|
if self.is_user_command_executing
|
||||||
|
|| self.pending_writes.iter().any(|write| {
|
||||||
|
matches!(
|
||||||
|
write,
|
||||||
|
PtyWrite::Command {
|
||||||
|
execution: Some(_),
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
{
|
||||||
// Send blocking should be okay b/c this is an unbound channel
|
// Send blocking should be okay b/c this is an unbound channel
|
||||||
if let Err(err) = block_on(cancel_tx.send(InBandCommandCancelledEvent { command_id })) {
|
if let Err(err) = block_on(cancel_tx.send(InBandCommandCancelledEvent { command_id })) {
|
||||||
log::warn!("Pty Controller failed to cancel in band command: {err:?}");
|
log::warn!("Pty Controller failed to cancel in band command: {err:?}");
|
||||||
@@ -311,6 +323,7 @@ impl<T: EventLoopSender> PtyController<T> {
|
|||||||
command: command.to_owned(),
|
command: command.to_owned(),
|
||||||
shell_type,
|
shell_type,
|
||||||
in_band_command_id: Some(command_id),
|
in_band_command_id: Some(command_id),
|
||||||
|
execution: None,
|
||||||
before_write_fn: Some(Box::new(move || {
|
before_write_fn: Some(Box::new(move || {
|
||||||
let mut terminal_model = terminal_model.lock();
|
let mut terminal_model = terminal_model.lock();
|
||||||
terminal_model
|
terminal_model
|
||||||
@@ -510,47 +523,19 @@ impl<T: EventLoopSender> PtyController<T> {
|
|||||||
/// sequences.
|
/// sequences.
|
||||||
pub fn write_command(
|
pub fn write_command(
|
||||||
&mut self,
|
&mut self,
|
||||||
command: &str,
|
event: &ExecuteCommandEvent,
|
||||||
shell_type: ShellType,
|
shell_type: ShellType,
|
||||||
source: CommandExecutionSource,
|
|
||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) {
|
) {
|
||||||
{
|
|
||||||
let mut model = self.terminal_model.lock();
|
|
||||||
|
|
||||||
// Explicitly start the block now that the command is executed.
|
|
||||||
match source {
|
|
||||||
CommandExecutionSource::AI { metadata } => {
|
|
||||||
model.start_command_execution_with_ai_metadata(metadata)
|
|
||||||
}
|
|
||||||
CommandExecutionSource::SharedSession {
|
|
||||||
participant_id,
|
|
||||||
ai_metadata,
|
|
||||||
..
|
|
||||||
} => model.start_command_execution_for_shared_session(participant_id, ai_metadata),
|
|
||||||
CommandExecutionSource::User | CommandExecutionSource::QueuedCommand => {
|
|
||||||
model.start_command_execution()
|
|
||||||
}
|
|
||||||
CommandExecutionSource::EnvVarCollection { metadata } => {
|
|
||||||
model.start_command_execution_from_env_var_collection(metadata)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure that the `TerminalModel` doesn't interpret any of the PTY output from the
|
|
||||||
// following commands as in-band command output. If the in-band command output is not
|
|
||||||
// currently being received by the `TerminalModel`, this is a no-op.
|
|
||||||
model.end_in_band_command_output(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.pending_writes.clear();
|
self.pending_writes.clear();
|
||||||
self.is_user_command_executing = true;
|
|
||||||
|
|
||||||
// Send the write to the PTY event loop.
|
// Send the write to the PTY event loop.
|
||||||
let write = PtyWrite::Command {
|
let write = PtyWrite::Command {
|
||||||
command: command.to_owned(),
|
command: event.command.clone(),
|
||||||
shell_type,
|
shell_type,
|
||||||
in_band_command_id: None,
|
in_band_command_id: None,
|
||||||
before_write_fn: None,
|
before_write_fn: None,
|
||||||
|
execution: Some(Box::new(event.clone())),
|
||||||
};
|
};
|
||||||
if self.can_write_to_pty(ctx) {
|
if self.can_write_to_pty(ctx) {
|
||||||
// Cancel the async writer task and clear the async write queue.
|
// Cancel the async writer task and clear the async write queue.
|
||||||
@@ -561,6 +546,28 @@ impl<T: EventLoopSender> PtyController<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cancel only a command that has not yet been dispatched to the shell.
|
||||||
|
pub fn cancel_queued_agent_command(&mut self, action_id: &AIAgentActionId) {
|
||||||
|
self.pending_writes.retain(|write| {
|
||||||
|
let PtyWrite::Command {
|
||||||
|
execution: Some(event),
|
||||||
|
..
|
||||||
|
} = write
|
||||||
|
else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let metadata = match &event.source {
|
||||||
|
CommandExecutionSource::AI { metadata } => Some(metadata),
|
||||||
|
CommandExecutionSource::SharedSession { ai_metadata, .. } => ai_metadata.as_ref(),
|
||||||
|
CommandExecutionSource::User
|
||||||
|
| CommandExecutionSource::QueuedCommand
|
||||||
|
| CommandExecutionSource::EnvVarCollection { .. } => None,
|
||||||
|
};
|
||||||
|
!metadata
|
||||||
|
.is_some_and(|metadata| metadata.requested_command_action_id() == Some(action_id))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Synchronously writes the EOT (End-of-Transmission) char to the PTY.
|
/// Synchronously writes the EOT (End-of-Transmission) char to the PTY.
|
||||||
pub fn write_end_of_transmission_char(&mut self, ctx: &mut ModelContext<Self>) {
|
pub fn write_end_of_transmission_char(&mut self, ctx: &mut ModelContext<Self>) {
|
||||||
self.write_bytes(&[escape_sequences::C0::EOT][..], ctx);
|
self.write_bytes(&[escape_sequences::C0::EOT][..], ctx);
|
||||||
@@ -630,11 +637,12 @@ impl<T: EventLoopSender> PtyController<T> {
|
|||||||
/// If the write corresponds to a command, this also calls
|
/// If the write corresponds to a command, this also calls
|
||||||
/// [`LineEditorStatus::did_execute_command()`].
|
/// [`LineEditorStatus::did_execute_command()`].
|
||||||
fn send_write_to_event_loop(&mut self, write: PtyWrite, ctx: &mut ModelContext<Self>) {
|
fn send_write_to_event_loop(&mut self, write: PtyWrite, ctx: &mut ModelContext<Self>) {
|
||||||
let (bytes_to_write, is_for_command, on_write_fn) = match write {
|
let (bytes_to_write, is_for_command, on_write_fn, execution) = match write {
|
||||||
PtyWrite::Command {
|
PtyWrite::Command {
|
||||||
command,
|
command,
|
||||||
shell_type,
|
shell_type,
|
||||||
before_write_fn: on_write_fn,
|
before_write_fn: on_write_fn,
|
||||||
|
execution,
|
||||||
..
|
..
|
||||||
} => (
|
} => (
|
||||||
Cow::Owned(bytes_to_execute_command(
|
Cow::Owned(bytes_to_execute_command(
|
||||||
@@ -644,13 +652,14 @@ impl<T: EventLoopSender> PtyController<T> {
|
|||||||
)),
|
)),
|
||||||
true,
|
true,
|
||||||
on_write_fn,
|
on_write_fn,
|
||||||
|
execution,
|
||||||
),
|
),
|
||||||
PtyWrite::AgentInput { bytes, mode } => {
|
PtyWrite::AgentInput { bytes, mode } => {
|
||||||
let decorated_bytes =
|
let decorated_bytes =
|
||||||
mode.decorate_bytes(bytes.into_owned(), self.is_bracketed_paste_enabled);
|
mode.decorate_bytes(bytes.into_owned(), self.is_bracketed_paste_enabled);
|
||||||
(decorated_bytes.into(), false, None)
|
(decorated_bytes.into(), false, None, None)
|
||||||
}
|
}
|
||||||
PtyWrite::Bytes { bytes } => (bytes, false, None),
|
PtyWrite::Bytes { bytes } => (bytes, false, None, None),
|
||||||
PtyWrite::RunNativeShellCompletions(state) => {
|
PtyWrite::RunNativeShellCompletions(state) => {
|
||||||
self.in_flight_native_completions_state = Some(state);
|
self.in_flight_native_completions_state = Some(state);
|
||||||
|
|
||||||
@@ -658,7 +667,7 @@ impl<T: EventLoopSender> PtyController<T> {
|
|||||||
// then wait for an OSC-based signal from the shell before we
|
// then wait for an OSC-based signal from the shell before we
|
||||||
// send the text that needs to be completed.
|
// send the text that needs to be completed.
|
||||||
let bytes = vec![0x19_u8];
|
let bytes = vec![0x19_u8];
|
||||||
(bytes.into(), false, None)
|
(bytes.into(), false, None, None)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -674,6 +683,41 @@ impl<T: EventLoopSender> PtyController<T> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A queued command must not claim the block still owned by the previous command
|
||||||
|
// or an in-band request. Attach its identity only once the shell accepts this write.
|
||||||
|
if let Some(event) = execution {
|
||||||
|
let block_id = {
|
||||||
|
let mut model = self.terminal_model.lock();
|
||||||
|
match &event.source {
|
||||||
|
CommandExecutionSource::AI { metadata } => {
|
||||||
|
model.start_command_execution_with_ai_metadata(metadata.clone());
|
||||||
|
}
|
||||||
|
CommandExecutionSource::SharedSession {
|
||||||
|
participant_id,
|
||||||
|
ai_metadata,
|
||||||
|
..
|
||||||
|
} => model.start_command_execution_for_shared_session(
|
||||||
|
participant_id.clone(),
|
||||||
|
ai_metadata.clone(),
|
||||||
|
),
|
||||||
|
CommandExecutionSource::User | CommandExecutionSource::QueuedCommand => {
|
||||||
|
model.start_command_execution();
|
||||||
|
}
|
||||||
|
CommandExecutionSource::EnvVarCollection { metadata } => {
|
||||||
|
model.start_command_execution_from_env_var_collection(metadata.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
model
|
||||||
|
.block_list_mut()
|
||||||
|
.active_block_mut()
|
||||||
|
.set_cloud_workflow_state(event.workflow_id);
|
||||||
|
model.end_in_band_command_output(false);
|
||||||
|
model.active_block_id().clone()
|
||||||
|
};
|
||||||
|
self.is_user_command_executing = true;
|
||||||
|
ctx.emit(PtyControllerEvent::CommandStarted { event, block_id });
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(on_write_fn) = on_write_fn {
|
if let Some(on_write_fn) = on_write_fn {
|
||||||
on_write_fn();
|
on_write_fn();
|
||||||
}
|
}
|
||||||
@@ -718,6 +762,11 @@ impl<T: EventLoopSender> PtyController<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub enum PtyControllerEvent {
|
pub enum PtyControllerEvent {
|
||||||
|
/// The command has acquired its terminal block and is being written to the shell.
|
||||||
|
CommandStarted {
|
||||||
|
event: Box<ExecuteCommandEvent>,
|
||||||
|
block_id: BlockId,
|
||||||
|
},
|
||||||
/// Emitted when the event loop thread has exited.
|
/// Emitted when the event loop thread has exited.
|
||||||
PtyDisconnected,
|
PtyDisconnected,
|
||||||
}
|
}
|
||||||
@@ -790,6 +839,10 @@ fn wrap_bytes_in_bracketed_paste(bytes: impl IntoIterator<Item = u8>) -> impl It
|
|||||||
.chain(escape_sequences::BRACKETED_PASTE_END.iter().copied())
|
.chain(escape_sequences::BRACKETED_PASTE_END.iter().copied())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "pty_controller_queue_tests.rs"]
|
||||||
|
mod queue_tests;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "pty_controller_command_bytes_tests.rs"]
|
#[path = "pty_controller_command_bytes_tests.rs"]
|
||||||
mod command_bytes_tests;
|
mod command_bytes_tests;
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
use galaxyui::App;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::ai::agent::conversation::AIConversationId;
|
||||||
|
use crate::ai::agent::AIAgentActionId;
|
||||||
|
use crate::terminal::model::block::AgentInteractionMetadata;
|
||||||
|
|
||||||
|
struct RecordingEventLoopSender(Arc<FairMutex<Vec<Message>>>);
|
||||||
|
|
||||||
|
impl EventLoopSender for RecordingEventLoopSender {
|
||||||
|
fn send(&self, message: Message) -> Result<(), EventLoopSendError> {
|
||||||
|
self.0.lock().push(message);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queued_agent_command_claims_only_the_block_at_dispatch() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
let sessions = app.add_model(|_| Sessions::new_for_test());
|
||||||
|
let (_events_tx, events_rx) = async_channel::unbounded();
|
||||||
|
let dispatcher =
|
||||||
|
app.add_model(|ctx| ModelEventDispatcher::new(events_rx, sessions.clone(), ctx));
|
||||||
|
let line_editor =
|
||||||
|
app.add_model(|ctx| LineEditorStatus::new(dispatcher.clone(), sessions.clone(), ctx));
|
||||||
|
let (_commands_tx, commands_rx) = async_channel::unbounded();
|
||||||
|
let model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
|
||||||
|
let writes = Arc::new(FairMutex::new(Vec::new()));
|
||||||
|
let controller = app.add_model(|ctx| {
|
||||||
|
PtyController::new(
|
||||||
|
RecordingEventLoopSender(writes.clone()),
|
||||||
|
dispatcher,
|
||||||
|
line_editor,
|
||||||
|
sessions,
|
||||||
|
commands_rx,
|
||||||
|
model.clone(),
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let conversation_id = AIConversationId::new();
|
||||||
|
let previous_action = AIAgentActionId::from("previous-command".to_string());
|
||||||
|
let next_action = AIAgentActionId::from("follow-up-command".to_string());
|
||||||
|
let previous_block = {
|
||||||
|
let mut model = model.lock();
|
||||||
|
model.simulate_long_running_block("echo previous", "previous output");
|
||||||
|
model
|
||||||
|
.block_list_mut()
|
||||||
|
.active_block_mut()
|
||||||
|
.set_agent_interaction_mode_for_requested_command(
|
||||||
|
previous_action.clone(),
|
||||||
|
None,
|
||||||
|
conversation_id,
|
||||||
|
);
|
||||||
|
model.active_block_id().clone()
|
||||||
|
};
|
||||||
|
controller.update(&mut app, |controller, ctx| {
|
||||||
|
controller.write_command(
|
||||||
|
&ExecuteCommandEvent {
|
||||||
|
command: "echo follow-up".into(),
|
||||||
|
session_id: 1.into(),
|
||||||
|
workflow_id: None,
|
||||||
|
workflow_command: None,
|
||||||
|
should_add_command_to_history: false,
|
||||||
|
source: CommandExecutionSource::AI {
|
||||||
|
metadata: AgentInteractionMetadata::new_hidden(
|
||||||
|
next_action.clone(),
|
||||||
|
conversation_id,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ShellType::Zsh,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
assert_eq!(controller.pending_writes.len(), 1);
|
||||||
|
controller.cancel_queued_agent_command(&previous_action);
|
||||||
|
assert_eq!(controller.pending_writes.len(), 1);
|
||||||
|
let write = controller.pending_writes.pop_front().unwrap();
|
||||||
|
let PtyWrite::Command {
|
||||||
|
execution: Some(event),
|
||||||
|
..
|
||||||
|
} = &write
|
||||||
|
else {
|
||||||
|
panic!("expected queued agent command");
|
||||||
|
};
|
||||||
|
let event = event.clone();
|
||||||
|
controller.pending_writes.push_back(write);
|
||||||
|
controller.cancel_queued_agent_command(&next_action);
|
||||||
|
assert!(controller.pending_writes.is_empty());
|
||||||
|
assert!(!controller.is_user_command_executing);
|
||||||
|
controller.write_command(&event, ShellType::Zsh, ctx);
|
||||||
|
});
|
||||||
|
assert!(writes.lock().is_empty());
|
||||||
|
{
|
||||||
|
let mut model = model.lock();
|
||||||
|
assert_eq!(
|
||||||
|
model
|
||||||
|
.block_list()
|
||||||
|
.active_block()
|
||||||
|
.requested_command_action_id(),
|
||||||
|
Some(&previous_action)
|
||||||
|
);
|
||||||
|
assert!(model
|
||||||
|
.block_list()
|
||||||
|
.block_for_ai_action_id(&next_action)
|
||||||
|
.is_none());
|
||||||
|
model.finish_block();
|
||||||
|
}
|
||||||
|
// The line editor becomes ready only after the preceding block has finished.
|
||||||
|
// Dispatch the queued write at that boundary, as the Active event normally does.
|
||||||
|
controller.update(&mut app, |controller, ctx| {
|
||||||
|
let write = controller.pending_writes.pop_front().unwrap();
|
||||||
|
controller.send_write_to_event_loop(write, ctx);
|
||||||
|
});
|
||||||
|
let model = model.lock();
|
||||||
|
let new_block = model
|
||||||
|
.block_list()
|
||||||
|
.block_for_ai_action_id(&next_action)
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(new_block.id(), &previous_block);
|
||||||
|
assert_eq!(new_block.ai_conversation_id(), Some(conversation_id));
|
||||||
|
assert_eq!(
|
||||||
|
model
|
||||||
|
.block_list()
|
||||||
|
.block_with_id(&previous_block)
|
||||||
|
.unwrap()
|
||||||
|
.requested_command_action_id(),
|
||||||
|
Some(&previous_action)
|
||||||
|
);
|
||||||
|
assert_eq!(writes.lock().len(), 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -37,8 +37,8 @@ pub fn wire_up_pty_controller_with_surface<T: EventLoopSender, S: TerminalSurfac
|
|||||||
{
|
{
|
||||||
let controller_weak_handle = pty_controller.downgrade();
|
let controller_weak_handle = pty_controller.downgrade();
|
||||||
let surface_weak_handle = surface.downgrade();
|
let surface_weak_handle = surface.downgrade();
|
||||||
let model_clone = model.clone();
|
|
||||||
|
|
||||||
|
let command_sessions = sessions.clone();
|
||||||
ctx.subscribe_to_view(surface, move |_surface, event, ctx| {
|
ctx.subscribe_to_view(surface, move |_surface, event, ctx| {
|
||||||
// NOTE: we cannot simply use the strong reference (the model handle argument to this wire_up fn)
|
// NOTE: we cannot simply use the strong reference (the model handle argument to this wire_up fn)
|
||||||
// because it'll create a reference cycle with the `subscribe_to_model` usage below. Instead,
|
// because it'll create a reference cycle with the `subscribe_to_model` usage below. Instead,
|
||||||
@@ -79,7 +79,7 @@ pub fn wire_up_pty_controller_with_surface<T: EventLoopSender, S: TerminalSurfac
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
PtyIntent::ExecuteCommand(event) => {
|
PtyIntent::ExecuteCommand(event) => {
|
||||||
let Some(shell_type) = sessions
|
let Some(shell_type) = command_sessions
|
||||||
.as_ref(ctx)
|
.as_ref(ctx)
|
||||||
.get(event.session_id)
|
.get(event.session_id)
|
||||||
.map(|s| s.shell().shell_type())
|
.map(|s| s.shell().shell_type())
|
||||||
@@ -88,20 +88,14 @@ pub fn wire_up_pty_controller_with_surface<T: EventLoopSender, S: TerminalSurfac
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
model_clone.lock().block_list_mut().active_block_mut().set_cloud_workflow_state(event.workflow_id);
|
|
||||||
controller.update(ctx, |controller, ctx| {
|
controller.update(ctx, |controller, ctx| {
|
||||||
controller.write_command(&event.command, shell_type, event.source.clone(), ctx)
|
controller.write_command(&event, shell_type, ctx)
|
||||||
});
|
});
|
||||||
|
|
||||||
if event.should_add_command_to_history {
|
|
||||||
update_command_history(
|
|
||||||
&event,
|
|
||||||
&model_clone,
|
|
||||||
model_event_sender.as_ref(),
|
|
||||||
&sessions,
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
PtyIntent::CancelQueuedAgentCommand(action_id) => {
|
||||||
|
controller.update(ctx, |controller, _| {
|
||||||
|
controller.cancel_queued_agent_command(&action_id);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
PtyIntent::RunNativeShellCompletions {
|
PtyIntent::RunNativeShellCompletions {
|
||||||
buffer_text,
|
buffer_text,
|
||||||
@@ -120,6 +114,18 @@ pub fn wire_up_pty_controller_with_surface<T: EventLoopSender, S: TerminalSurfac
|
|||||||
};
|
};
|
||||||
|
|
||||||
match event {
|
match event {
|
||||||
|
PtyControllerEvent::CommandStarted { event, block_id } => {
|
||||||
|
if event.should_add_command_to_history {
|
||||||
|
update_command_history(
|
||||||
|
event,
|
||||||
|
block_id,
|
||||||
|
&model,
|
||||||
|
model_event_sender.as_ref(),
|
||||||
|
&sessions,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
PtyControllerEvent::PtyDisconnected => surface.update(ctx, |_, _| {
|
PtyControllerEvent::PtyDisconnected => surface.update(ctx, |_, _| {
|
||||||
model.lock().exit(ExitReason::PtyDisconnected);
|
model.lock().exit(ExitReason::PtyDisconnected);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use async_channel::Sender;
|
|||||||
use warpui::AppContext;
|
use warpui::AppContext;
|
||||||
use warpui::{Entity, ViewContext};
|
use warpui::{Entity, ViewContext};
|
||||||
|
|
||||||
use crate::ai::agent::AIAgentPtyWriteMode;
|
use crate::ai::agent::{AIAgentActionId, AIAgentPtyWriteMode};
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use crate::terminal::event::AfterBlockCompletedEvent;
|
use crate::terminal::event::AfterBlockCompletedEvent;
|
||||||
use crate::terminal::model::completions::ShellCompletion;
|
use crate::terminal::model::completions::ShellCompletion;
|
||||||
@@ -30,6 +30,7 @@ pub enum PtyIntent {
|
|||||||
},
|
},
|
||||||
Resize(SizeUpdate),
|
Resize(SizeUpdate),
|
||||||
ExecuteCommand(ExecuteCommandEvent),
|
ExecuteCommand(ExecuteCommandEvent),
|
||||||
|
CancelQueuedAgentCommand(AIAgentActionId),
|
||||||
RunNativeShellCompletions {
|
RunNativeShellCompletions {
|
||||||
buffer_text: String,
|
buffer_text: String,
|
||||||
results_tx: Sender<Vec<ShellCompletion>>,
|
results_tx: Sender<Vec<ShellCompletion>>,
|
||||||
|
|||||||
@@ -10,13 +10,12 @@ use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity, TypedActionVie
|
|||||||
|
|
||||||
use crate::ai::agent::conversation::AIConversationId;
|
use crate::ai::agent::conversation::AIConversationId;
|
||||||
use crate::ai::agent::AIAgentExchangeId;
|
use crate::ai::agent::AIAgentExchangeId;
|
||||||
use crate::app_state::{get_app_state, AppState};
|
use crate::app_state::get_app_state;
|
||||||
use crate::network::NetworkStatus;
|
use crate::network::NetworkStatus;
|
||||||
use crate::persistence::ModelEvent;
|
use crate::persistence::ModelEvent;
|
||||||
use crate::root_view::OpenPath;
|
use crate::root_view::OpenPath;
|
||||||
use crate::server::server_api::ServerApiProvider;
|
use crate::server::server_api::ServerApiProvider;
|
||||||
use crate::terminal::alt_screen_reporting::AltScreenReporting;
|
use crate::terminal::alt_screen_reporting::AltScreenReporting;
|
||||||
use crate::terminal::general_settings::GeneralSettings;
|
|
||||||
use crate::undo_close::UndoCloseStack;
|
use crate::undo_close::UndoCloseStack;
|
||||||
use crate::workspace::cross_window_tab_drag::CrossWindowTabDrag;
|
use crate::workspace::cross_window_tab_drag::CrossWindowTabDrag;
|
||||||
use crate::workspace::{Workspace, WorkspaceAction};
|
use crate::workspace::{Workspace, WorkspaceAction};
|
||||||
@@ -25,7 +24,6 @@ use crate::{auth, GlobalResourceHandlesProvider};
|
|||||||
const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_millis(250);
|
const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
pub(crate) struct SessionSaveCoordinator {
|
pub(crate) struct SessionSaveCoordinator {
|
||||||
last_enqueued_state: Option<AppState>,
|
|
||||||
pending_save: bool,
|
pending_save: bool,
|
||||||
timer_generation: u64,
|
timer_generation: u64,
|
||||||
timer: Option<SpawnedFutureHandle>,
|
timer: Option<SpawnedFutureHandle>,
|
||||||
@@ -34,7 +32,6 @@ pub(crate) struct SessionSaveCoordinator {
|
|||||||
impl SessionSaveCoordinator {
|
impl SessionSaveCoordinator {
|
||||||
pub(crate) fn new() -> Self {
|
pub(crate) fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
last_enqueued_state: None,
|
|
||||||
pending_save: false,
|
pending_save: false,
|
||||||
timer_generation: 0,
|
timer_generation: 0,
|
||||||
timer: None,
|
timer: None,
|
||||||
@@ -47,7 +44,7 @@ impl SessionSaveCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if self.timer.is_none() {
|
if self.timer.is_none() {
|
||||||
self.save_if_changed(ctx);
|
self.enqueue_snapshot(ctx);
|
||||||
} else {
|
} else {
|
||||||
self.pending_save = true;
|
self.pending_save = true;
|
||||||
}
|
}
|
||||||
@@ -62,7 +59,7 @@ impl SessionSaveCoordinator {
|
|||||||
self.pending_save = false;
|
self.pending_save = false;
|
||||||
|
|
||||||
if session_save_is_enabled(ctx) {
|
if session_save_is_enabled(ctx) {
|
||||||
self.save_if_changed(ctx);
|
self.enqueue_snapshot(ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,13 +77,13 @@ impl SessionSaveCoordinator {
|
|||||||
}
|
}
|
||||||
coordinator.timer = None;
|
coordinator.timer = None;
|
||||||
if std::mem::take(&mut coordinator.pending_save) {
|
if std::mem::take(&mut coordinator.pending_save) {
|
||||||
coordinator.save_if_changed(ctx);
|
coordinator.enqueue_snapshot(ctx);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save_if_changed(&mut self, ctx: &mut ModelContext<Self>) {
|
fn enqueue_snapshot(&mut self, ctx: &mut ModelContext<Self>) {
|
||||||
let Some(model_event_sender) = GlobalResourceHandlesProvider::as_ref(ctx)
|
let Some(model_event_sender) = GlobalResourceHandlesProvider::as_ref(ctx)
|
||||||
.get()
|
.get()
|
||||||
.model_event_sender
|
.model_event_sender
|
||||||
@@ -95,14 +92,9 @@ impl SessionSaveCoordinator {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let app_state = get_app_state(ctx);
|
let app_state = get_app_state(ctx);
|
||||||
if self.last_enqueued_state.as_ref() == Some(&app_state) {
|
if let Err(err) = model_event_sender.send(ModelEvent::Snapshot(app_state)) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Err(err) = model_event_sender.send(ModelEvent::Snapshot(app_state.clone())) {
|
|
||||||
log::error!("Error trying to send model event {err:?}");
|
log::error!("Error trying to send model event {err:?}");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
self.last_enqueued_state = Some(app_state);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,9 +105,7 @@ impl Entity for SessionSaveCoordinator {
|
|||||||
impl SingletonEntity for SessionSaveCoordinator {}
|
impl SingletonEntity for SessionSaveCoordinator {}
|
||||||
|
|
||||||
fn session_save_is_enabled(ctx: &AppContext) -> bool {
|
fn session_save_is_enabled(ctx: &AppContext) -> bool {
|
||||||
AppExecutionMode::as_ref(ctx).can_save_session()
|
AppExecutionMode::as_ref(ctx).can_save_session() && !CrossWindowTabDrag::as_ref(ctx).is_active()
|
||||||
&& *GeneralSettings::as_ref(ctx).restore_session
|
|
||||||
&& !CrossWindowTabDrag::as_ref(ctx).is_active()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Specifies where a forked conversation should be opened.
|
/// Specifies where a forked conversation should be opened.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ license.workspace = true
|
|||||||
local_fs = []
|
local_fs = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
galaxy_agent_core.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
diesel = { workspace = true, features = [
|
diesel = { workspace = true, features = [
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -1086,8 +1086,19 @@ pub struct LocalAgentGroup {
|
|||||||
pub notified: bool,
|
pub notified: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Exact provider history, independent of terminal scrollback and UI task projections.
|
||||||
|
/// `None` on AgentConversationData denotes a legacy row requiring reconstruction;
|
||||||
|
/// an explicitly empty history must stay empty after restoration.
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||||
|
pub struct ProviderConversationContext {
|
||||||
|
pub messages: Vec<galaxy_agent_core::ConversationMessage>,
|
||||||
|
pub tool_result_archive: Vec<galaxy_agent_core::ConversationMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||||
pub struct AgentConversationData {
|
pub struct AgentConversationData {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub provider_context: Option<ProviderConversationContext>,
|
||||||
/// Display-only tool intent, scoped to this conversation and keyed by call ID.
|
/// Display-only tool intent, scoped to this conversation and keyed by call ID.
|
||||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
pub tool_titles: HashMap<String, String>,
|
pub tool_titles: HashMap<String, String>,
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ fn is_restorable_accepts_empty_and_single_task_conversations() {
|
|||||||
fn agent_conversation_data_roundtrips_last_event_sequence() {
|
fn agent_conversation_data_roundtrips_last_event_sequence() {
|
||||||
let data = AgentConversationData {
|
let data = AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
|
provider_context: None,
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
local_agent_groups: Vec::new(),
|
local_agent_groups: Vec::new(),
|
||||||
tool_titles: Default::default(),
|
tool_titles: Default::default(),
|
||||||
@@ -242,6 +243,7 @@ fn agent_conversation_data_omits_default_provider_backend() {
|
|||||||
fn agent_conversation_data_roundtrips_remote_child_marker() {
|
fn agent_conversation_data_roundtrips_remote_child_marker() {
|
||||||
let data = AgentConversationData {
|
let data = AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
|
provider_context: None,
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
local_agent_groups: Vec::new(),
|
local_agent_groups: Vec::new(),
|
||||||
tool_titles: Default::default(),
|
tool_titles: Default::default(),
|
||||||
@@ -272,6 +274,7 @@ fn agent_conversation_data_roundtrips_remote_child_marker() {
|
|||||||
fn agent_conversation_data_roundtrips_optimistic_root_marker() {
|
fn agent_conversation_data_roundtrips_optimistic_root_marker() {
|
||||||
let data = AgentConversationData {
|
let data = AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
|
provider_context: None,
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
local_agent_groups: Vec::new(),
|
local_agent_groups: Vec::new(),
|
||||||
tool_titles: Default::default(),
|
tool_titles: Default::default(),
|
||||||
@@ -314,6 +317,7 @@ fn agent_conversation_data_deserializes_legacy_payload_without_last_event_sequen
|
|||||||
fn agent_conversation_data_skips_serializing_none_last_event_sequence() {
|
fn agent_conversation_data_skips_serializing_none_last_event_sequence() {
|
||||||
let data = AgentConversationData {
|
let data = AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
|
provider_context: None,
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
local_agent_groups: Vec::new(),
|
local_agent_groups: Vec::new(),
|
||||||
tool_titles: Default::default(),
|
tool_titles: Default::default(),
|
||||||
@@ -346,6 +350,7 @@ fn agent_conversation_data_skips_serializing_none_last_event_sequence() {
|
|||||||
fn agent_conversation_data_roundtrips_pinned() {
|
fn agent_conversation_data_roundtrips_pinned() {
|
||||||
let data = AgentConversationData {
|
let data = AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
|
provider_context: None,
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
local_agent_groups: Vec::new(),
|
local_agent_groups: Vec::new(),
|
||||||
tool_titles: Default::default(),
|
tool_titles: Default::default(),
|
||||||
@@ -376,6 +381,7 @@ fn agent_conversation_data_roundtrips_pinned() {
|
|||||||
fn agent_conversation_data_skips_serializing_unpinned() {
|
fn agent_conversation_data_skips_serializing_unpinned() {
|
||||||
let data = AgentConversationData {
|
let data = AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
|
provider_context: None,
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
local_agent_groups: Vec::new(),
|
local_agent_groups: Vec::new(),
|
||||||
tool_titles: Default::default(),
|
tool_titles: Default::default(),
|
||||||
|
|||||||
Reference in New Issue
Block a user