Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35ff43de7f | ||
|
|
d7fbc09bcf |
Generated
+1
@@ -11612,6 +11612,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"diesel",
|
||||
"diesel_migrations",
|
||||
"galaxy_agent_core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"warp_multi_agent_api",
|
||||
|
||||
@@ -73,6 +73,7 @@ pub fn convert_conversation_data_to_ai_conversation(
|
||||
let agent_conversation_data = match restoration_mode {
|
||||
RestorationMode::Fork => AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
provider_context: None,
|
||||
active_provider_run_json: None,
|
||||
local_agent_groups: Vec::new(),
|
||||
tool_titles: Default::default(),
|
||||
@@ -99,6 +100,7 @@ pub fn convert_conversation_data_to_ai_conversation(
|
||||
},
|
||||
RestorationMode::Continue => AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
provider_context: None,
|
||||
active_provider_run_json: None,
|
||||
local_agent_groups: Vec::new(),
|
||||
tool_titles: Default::default(),
|
||||
@@ -357,7 +359,13 @@ impl ConvertToExchanges for &api::Task {
|
||||
let task_id = TaskId::new(api_message.task_id.clone());
|
||||
// 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() {
|
||||
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 {
|
||||
Some(api_message.request_id.clone())
|
||||
};
|
||||
@@ -464,12 +472,18 @@ impl ConvertToExchanges for &api::Task {
|
||||
}
|
||||
api::message::Message::ToolCallResult(tool_call_result) => {
|
||||
// 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,
|
||||
tool_call_result,
|
||||
&tool_call_map,
|
||||
&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
|
||||
current_inputs.push(input);
|
||||
}
|
||||
@@ -1865,15 +1879,19 @@ fn create_exchange_from_messages(
|
||||
_ => 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(|| {
|
||||
message_ids.iter().find_map(|message_id| {
|
||||
message_map.get(message_id.as_str()).and_then(|message| {
|
||||
message.timestamp.as_ref().map(|timestamp| {
|
||||
proto_timestamp_to_local_datetime(timestamp.seconds, timestamp.nanos)
|
||||
message_ids
|
||||
.iter()
|
||||
.filter_map(|message_id| {
|
||||
message_map.get(message_id.as_str()).and_then(|message| {
|
||||
message.timestamp.as_ref().map(|timestamp| {
|
||||
proto_timestamp_to_local_datetime(timestamp.seconds, timestamp.nanos)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
.min()
|
||||
})
|
||||
.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`].
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MessageToAIAgentOutputMessageError {
|
||||
#[error("Invalid saved provider tool: {0}")]
|
||||
SavedProviderTool(String),
|
||||
#[error("Missing expected message")]
|
||||
MissingMessage,
|
||||
#[error("Error converting tool to action: {0:?}")]
|
||||
@@ -262,6 +264,16 @@ impl ConvertAPIMessageToClientOutputMessage for api::Message {
|
||||
self,
|
||||
params: ConversionParams,
|
||||
) -> 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 {
|
||||
// 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.
|
||||
|
||||
@@ -17,7 +17,7 @@ use galaxy_core::ui::theme::GalaxyTheme;
|
||||
use galaxyui::color::ColorU;
|
||||
use galaxyui::{AppContext, EntityId, ModelContext, SingletonEntity};
|
||||
use itertools::Itertools as _;
|
||||
use persistence::model::LocalAgentGroup;
|
||||
use persistence::model::{LocalAgentGroup, ProviderConversationContext};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
use vec1::{Size0Error, Vec1};
|
||||
@@ -69,7 +69,6 @@ use crate::persistence::model::{
|
||||
};
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::server::ids::ServerId;
|
||||
use crate::terminal::general_settings::GeneralSettings;
|
||||
use crate::terminal::model::block::{
|
||||
AgentInteractionMetadata, AgentViewVisibility, BlockId, SerializedAIMetadata, SerializedBlock,
|
||||
};
|
||||
@@ -453,11 +452,27 @@ impl AIConversation {
|
||||
tasks: Vec<api::Task>,
|
||||
conversation_data: Option<AgentConversationData>,
|
||||
) -> Result<Self, RestoreConversationError> {
|
||||
let bedrock_message_history: Vec<crate::ai::provider::convert::ConversationMessage> = tasks
|
||||
.iter()
|
||||
.flat_map(|task| task.messages.iter())
|
||||
.filter_map(crate::ai::provider::request_translator::convert_proto_message)
|
||||
.collect();
|
||||
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()
|
||||
.flat_map(|task| task.messages.iter())
|
||||
.filter_map(crate::ai::provider::request_translator::convert_proto_message)
|
||||
.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() {
|
||||
// Bypass `derive_status_from_root_task`: it would return `Success`
|
||||
@@ -575,7 +590,6 @@ impl AIConversation {
|
||||
last_event_sequence,
|
||||
pinned,
|
||||
progressive_summary,
|
||||
messages_summarized_up_to,
|
||||
) = if let Some(data) = conversation_data {
|
||||
let server_conversation_token = data
|
||||
.server_conversation_token
|
||||
@@ -626,7 +640,6 @@ impl AIConversation {
|
||||
data.last_event_sequence,
|
||||
data.pinned,
|
||||
data.progressive_summary.clone(),
|
||||
data.messages_summarized_up_to,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
@@ -647,7 +660,6 @@ impl AIConversation {
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
0,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -715,9 +727,10 @@ impl AIConversation {
|
||||
orchestration_configs: HashMap::new(),
|
||||
pinned,
|
||||
bedrock_message_history,
|
||||
tool_result_archive: Vec::new(),
|
||||
tool_result_archive,
|
||||
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,
|
||||
latest_model_call_usage: ProviderUsage::default(),
|
||||
has_pending_progressive_summary: false,
|
||||
@@ -841,6 +854,19 @@ impl AIConversation {
|
||||
&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] {
|
||||
&self.tool_result_archive
|
||||
}
|
||||
@@ -2404,6 +2430,61 @@ impl AIConversation {
|
||||
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(
|
||||
&mut self,
|
||||
stream_id: &ResponseStreamId,
|
||||
@@ -4016,10 +4097,8 @@ impl AIConversation {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if session restoration is enabled before writing any state.
|
||||
if !*GeneralSettings::as_ref(ctx).restore_session
|
||||
|| !AppExecutionMode::as_ref(ctx).can_save_session()
|
||||
{
|
||||
// Conversation history is durable even when automatic session reopening is disabled.
|
||||
if !AppExecutionMode::as_ref(ctx).can_save_session() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4064,6 +4143,7 @@ impl AIConversation {
|
||||
.filter_map(|task| task.source_for_persistence())
|
||||
.collect(),
|
||||
conversation_data: AgentConversationData {
|
||||
provider_context: Some(self.provider_context()),
|
||||
agent_backend: self.agent_backend.clone(),
|
||||
active_provider_run_json: self.active_provider_run_json.clone(),
|
||||
local_agent_groups: self.local_agent_groups.clone(),
|
||||
@@ -4097,14 +4177,9 @@ impl AIConversation {
|
||||
messages_summarized_up_to: self.messages_summarized_up_to,
|
||||
},
|
||||
};
|
||||
ctx.spawn(
|
||||
async move {
|
||||
if let Err(e) = sqlite_sender.send(event) {
|
||||
log::warn!("Failed to send updated AI tasks to sqlite writer thread: {e:?}");
|
||||
}
|
||||
},
|
||||
|_, _, _| {},
|
||||
);
|
||||
if let Err(e) = sqlite_sender.send(event) {
|
||||
log::error!("Failed to send updated AI tasks to sqlite writer thread: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rollback_transaction(&mut self, response_stream_id: &ResponseStreamId) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use galaxy_agent_core::{ContentPart, ConversationMessage, MessageContent, MessageRole};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use warp_multi_agent_api as api;
|
||||
|
||||
@@ -8,7 +9,9 @@ use super::{
|
||||
ConversationStatus, RestoreConversationError,
|
||||
};
|
||||
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 {
|
||||
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 icons;
|
||||
pub(crate) mod linearization;
|
||||
pub(crate) mod provider_history;
|
||||
pub(crate) mod redaction;
|
||||
pub(crate) mod runtime_activity;
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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(
|
||||
&mut self,
|
||||
messages: Vec<api::Message>,
|
||||
mut messages: Vec<api::Message>,
|
||||
exchange_id: AIAgentExchangeId,
|
||||
message_context: TaskMessageContext<'_>,
|
||||
should_convert_input_messages: bool,
|
||||
@@ -701,6 +730,20 @@ impl Task {
|
||||
if self.source().is_none() {
|
||||
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(
|
||||
messages.clone(),
|
||||
exchange_id,
|
||||
|
||||
@@ -2455,6 +2455,16 @@ impl BlocklistAIActionModel {
|
||||
let permission_denied = self
|
||||
.denied_permissions
|
||||
.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);
|
||||
if execution_ref.is_none() {
|
||||
self.finished_tool_results
|
||||
|
||||
@@ -277,8 +277,16 @@ impl ShellCommandExecutor {
|
||||
move |result, ctx| {
|
||||
// Remove the senders from the maps.
|
||||
if let Some(handle) = handle.upgrade(ctx) {
|
||||
handle.update(ctx, |me, _| {
|
||||
handle.update(ctx, |me, ctx| {
|
||||
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
|
||||
// running. Terminate the exact requested command before resolving the action as cancelled.
|
||||
if requested_block_is_running {
|
||||
ctx.emit(ShellCommandExecutorEvent::CancelExecution {
|
||||
action_id: id.clone(),
|
||||
});
|
||||
}
|
||||
ctx.emit(ShellCommandExecutorEvent::CancelExecution {
|
||||
action_id: id.clone(),
|
||||
});
|
||||
if !requested_block_is_running {
|
||||
self.block_finished_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| {
|
||||
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 {
|
||||
let terminal_model = me.terminal_model.lock();
|
||||
let Some(block) = terminal_model
|
||||
@@ -654,11 +668,10 @@ impl CLISubagentController {
|
||||
.and_then(|state| state.last_snapshot_at)
|
||||
}
|
||||
|
||||
/// Begins tracking an agent-requested command before its shell event is dispatched.
|
||||
///
|
||||
/// The placeholder lets command completion and action-result events arrive in either order
|
||||
/// without losing the completion that a subsequently-created CLI monitor needs.
|
||||
pub fn track_requested_command(
|
||||
/// 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
|
||||
/// belong to a previous command. The placeholder preserves completion before a monitor exists.
|
||||
fn track_requested_command(
|
||||
&mut self,
|
||||
block_id: &BlockId,
|
||||
conversation_id: AIConversationId,
|
||||
|
||||
@@ -33,6 +33,7 @@ use input_context::{input_context_for_request, parse_context_attachments};
|
||||
use itertools::Itertools;
|
||||
use parking_lot::FairMutex;
|
||||
use pending_response_streams::PendingResponseStreams;
|
||||
use persistence::model::ProviderConversationContext;
|
||||
use session_sharing_protocol::common::ParticipantId;
|
||||
use settings::Setting;
|
||||
pub use slash_command::*;
|
||||
@@ -2557,7 +2558,23 @@ impl BlocklistAIController {
|
||||
terminal_surface_id,
|
||||
conversation_ids,
|
||||
} 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, .. }
|
||||
if new_status.is_done() =>
|
||||
@@ -4123,6 +4140,13 @@ impl BlocklistAIController {
|
||||
);
|
||||
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
|
||||
// drain via the next outbound request and the server-side
|
||||
// supersede emits the resume signal.
|
||||
@@ -4462,6 +4486,12 @@ impl BlocklistAIController {
|
||||
log::error!("Tried to resume non-existent conversation: {conversation_id:?}");
|
||||
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 terminal_model = self.terminal_model.lock();
|
||||
let active_block = terminal_model.block_list().active_block();
|
||||
@@ -5572,7 +5602,8 @@ impl BlocklistAIController {
|
||||
self.restoring_provider_runs
|
||||
.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| {
|
||||
for conversation_id in conversation_ids {
|
||||
me.restore_active_provider_run(conversation_id, ctx);
|
||||
@@ -6519,9 +6550,16 @@ impl BlocklistAIController {
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
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)
|
||||
.map_err(|error| format!("failed to serialize active provider run: {error}"))?;
|
||||
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
|
||||
.persist_active_provider_run_json(conversation_id, Some(json), ctx)
|
||||
.map_err(|error| format!("failed to persist active provider run: {error:?}"))
|
||||
@@ -7656,6 +7694,7 @@ impl BlocklistAIController {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let action_context = run.action_context.clone();
|
||||
let tool_titles = batch
|
||||
.calls
|
||||
.iter()
|
||||
@@ -7815,6 +7854,32 @@ impl BlocklistAIController {
|
||||
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);
|
||||
}
|
||||
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());
|
||||
*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 events = match run.projector.finish(&outcome, &aggregate_usage) {
|
||||
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 {
|
||||
agent_backend: source_conversation.agent_backend().for_fork(),
|
||||
provider_context: None,
|
||||
active_provider_run_json: None,
|
||||
local_agent_groups: Vec::new(),
|
||||
tool_titles: source_conversation.tool_titles().clone(),
|
||||
@@ -1870,6 +1871,7 @@ impl BlocklistAIHistoryModel {
|
||||
// be recomputed based on the retained exchanges in a follow-up.
|
||||
let conversation_data = AgentConversationData {
|
||||
agent_backend: conversation.agent_backend().for_fork(),
|
||||
provider_context: None,
|
||||
active_provider_run_json: None,
|
||||
local_agent_groups: Vec::new(),
|
||||
tool_titles: conversation.tool_titles().clone(),
|
||||
@@ -2898,6 +2900,7 @@ fn merged_remote_child_placeholder_conversation_data(
|
||||
cloud_conversation: &AIConversation,
|
||||
) -> AgentConversationData {
|
||||
AgentConversationData {
|
||||
provider_context: None,
|
||||
// Placeholder authoritative.
|
||||
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
|
||||
// child conversation persisted before its first server response, so
|
||||
|
||||
@@ -83,6 +83,33 @@ impl ProviderActionContext {
|
||||
.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> {
|
||||
action_from_tool_call(
|
||||
&self.task_id,
|
||||
|
||||
@@ -23,7 +23,7 @@ use crate::workspace::tab_group::TabGroupId;
|
||||
use crate::workspace::view::left_panel::ToolPanelView;
|
||||
use crate::workspace::WorkspaceRegistry;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct AppState {
|
||||
pub windows: Vec<WindowSnapshot>,
|
||||
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");
|
||||
});
|
||||
|
||||
#[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.
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
{
|
||||
|
||||
@@ -388,6 +388,7 @@ fn persisted_remote_child_conversation(
|
||||
conversation_id: conversation_id.to_string(),
|
||||
conversation_data: serde_json::to_string(&AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
provider_context: None,
|
||||
active_provider_run_json: None,
|
||||
local_agent_groups: Vec::new(),
|
||||
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::session_management::SessionNavigationData;
|
||||
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::role_change_modal::RoleChangeOpenSource;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
@@ -493,6 +492,13 @@ impl PaneContent for TerminalPane {
|
||||
|
||||
// Capture the current input_config from the AI input model
|
||||
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() {
|
||||
// We save and restore ambient agent sessions
|
||||
@@ -530,33 +536,13 @@ impl PaneContent for TerminalPane {
|
||||
uuid: self.uuid.clone(),
|
||||
task_id: Some(task_id),
|
||||
})
|
||||
} else if view.model.lock().is_conversation_transcript_viewer() {
|
||||
// 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 {
|
||||
uuid: self.uuid.clone(),
|
||||
task_id,
|
||||
})
|
||||
} else {
|
||||
let cwd = view.pwd_if_local(app);
|
||||
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 if let Some(task_id) = transcript_task_id {
|
||||
LeafContents::AmbientAgent(AmbientAgentPaneSnapshot {
|
||||
uuid: self.uuid.clone(),
|
||||
task_id: Some(task_id),
|
||||
})
|
||||
} else {
|
||||
// Local transcript viewers retain their conversation links just like live panes.
|
||||
let llm_model_override =
|
||||
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 } => {
|
||||
match group.terminal_session_by_id(pane_id) {
|
||||
Some(pane) => {
|
||||
if *GeneralSettings::as_ref(ctx).restore_session
|
||||
&& AppExecutionMode::as_ref(ctx).can_save_session()
|
||||
{
|
||||
if AppExecutionMode::as_ref(ctx).can_save_session() {
|
||||
if let Some(sender) = &group.model_event_sender {
|
||||
let block_completed_event = ModelEvent::SaveBlock(BlockCompleted {
|
||||
pane_id: pane.session_uuid(),
|
||||
@@ -2249,10 +2233,8 @@ fn handle_ai_history_event(
|
||||
is_hidden,
|
||||
..
|
||||
} => {
|
||||
// Check if session restoration is enabled.
|
||||
if !*GeneralSettings::as_ref(ctx).restore_session
|
||||
|| !AppExecutionMode::as_ref(ctx).can_save_session()
|
||||
{
|
||||
// Save query history independently of automatic session reopening.
|
||||
if !AppExecutionMode::as_ref(ctx).can_save_session() {
|
||||
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 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
|
||||
```
|
||||
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`.
|
||||
|
||||
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
|
||||
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 redo --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/galaxy.sqlite"
|
||||
```
|
||||
|
||||
# Schema style
|
||||
|
||||
@@ -204,6 +204,7 @@ pub(super) fn select_conversations_to_evict(
|
||||
|
||||
pub(super) fn read_agent_conversations(
|
||||
conn: &mut SqliteConnection,
|
||||
errors: &mut Vec<String>,
|
||||
) -> Result<Vec<AgentConversation>, diesel::result::Error> {
|
||||
let mut conversations_by_id = HashMap::<String, AgentConversation>::from_iter(
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ impl SingletonEntity for PersistenceWriter {}
|
||||
///
|
||||
/// This data belongs to the local installation rather than an inherited Warp
|
||||
/// account, so logging out of a legacy account must not clear it.
|
||||
#[derive(Default)]
|
||||
pub struct PersistedData {
|
||||
/// Session restoration data
|
||||
pub app_state: AppState,
|
||||
|
||||
+284
-101
@@ -136,7 +136,10 @@ pub fn initialize(
|
||||
let database_path = database_file_path_for_scope(&scope);
|
||||
match init_db(&scope) {
|
||||
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()) {
|
||||
Ok(writer_handles) => Some(writer_handles),
|
||||
@@ -149,7 +152,7 @@ pub fn initialize(
|
||||
None
|
||||
}
|
||||
};
|
||||
(persisted_data, writer_handles)
|
||||
(Some(persisted_data), writer_handles)
|
||||
}
|
||||
Err(err) => {
|
||||
send_telemetry_from_app_ctx!(
|
||||
@@ -165,16 +168,14 @@ pub fn initialize(
|
||||
fn read_persisted_data(
|
||||
conn: &mut SqliteConnection,
|
||||
ctx: &mut AppContext,
|
||||
) -> Option<Box<PersistedData>> {
|
||||
) -> (Box<PersistedData>, bool) {
|
||||
let user_uid = AuthStateProvider::as_ref(ctx).get().user_id();
|
||||
match read_sqlite_data(conn, user_uid) {
|
||||
Ok(app_state) => Some(Box::new(app_state)),
|
||||
Err(err) => {
|
||||
send_telemetry_from_app_ctx!(TelemetryEvent::DatabaseReadError(err.to_string()), ctx);
|
||||
report_error!(anyhow::Error::new(err).context("Failed to read persisted data"));
|
||||
None
|
||||
}
|
||||
let (data, errors) = read_sqlite_data_with_recovery(conn, user_uid);
|
||||
if !errors.is_empty() {
|
||||
send_telemetry_from_app_ctx!(TelemetryEvent::DatabaseReadError(errors.join("; ")), ctx);
|
||||
report_error!(anyhow!("Incomplete SQLite restore; database writes are disabled for this launch to preserve existing data: {}", errors.join("; ")));
|
||||
}
|
||||
(Box::new(data), errors.is_empty())
|
||||
}
|
||||
|
||||
/// 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()
|
||||
.name("SQLite Writer".into())
|
||||
.spawn(move || {
|
||||
let mut last_saved_snapshot = None;
|
||||
loop {
|
||||
let events = match rx.recv() {
|
||||
Ok(event) => {
|
||||
@@ -490,6 +492,15 @@ fn start_writer(conn: SqliteConnection, database_path: PathBuf) -> Result<Writer
|
||||
log::info!("Shutting down SQLite writer thread");
|
||||
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 => {
|
||||
if let Err(err) = handle_model_event(event, &mut current_conn) {
|
||||
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 })
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Events which affect the SQLite writer event loop _must_ instead be handled by the event loop itself:
|
||||
/// * [`ModelEvent::Terminate`]
|
||||
@@ -636,13 +684,15 @@ fn handle_model_event(event: ModelEvent, connection: &mut SqliteConnection) -> a
|
||||
conversation_id,
|
||||
updated_tasks,
|
||||
conversation_data,
|
||||
} => upsert_agent_conversation(
|
||||
connection,
|
||||
&conversation_id,
|
||||
&updated_tasks,
|
||||
conversation_data,
|
||||
)
|
||||
.map_err(anyhow::Error::from),
|
||||
} => retry_sqlite_write(|| {
|
||||
upsert_agent_conversation(
|
||||
connection,
|
||||
&conversation_id,
|
||||
&updated_tasks,
|
||||
conversation_data.clone(),
|
||||
)
|
||||
.map_err(anyhow::Error::from)
|
||||
}),
|
||||
ModelEvent::DeleteMultiAgentConversations { conversation_ids } => {
|
||||
delete_agent_conversations(connection, conversation_ids)
|
||||
.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,
|
||||
/// and makes it invalid to write the logic recursively. It's ok it's not in a
|
||||
/// transaction because we should be the only connection using the database.
|
||||
///
|
||||
/// One notable exception is the case where there may be two warp apps running
|
||||
/// in the same bundle. In this case, we may read some garbage, but all that will
|
||||
/// happen is the user won't have session restoration.
|
||||
///
|
||||
/// In the future, the awkwardness of the transaction interface is resolved in diesel 2.0.0.
|
||||
/// Use a consistent SQLite snapshot even when another Galaxy process is writing.
|
||||
/// A failed domain is reported independently; incomplete restoration disables writes.
|
||||
fn read_sqlite_data_with_recovery(
|
||||
conn: &mut SqliteConnection,
|
||||
current_user_id: Option<UserUid>,
|
||||
) -> (PersistedData, Vec<String>) {
|
||||
match conn.transaction::<_, Error, _>(|conn| Ok(read_sqlite_domains(conn, current_user_id))) {
|
||||
Ok(result) => result,
|
||||
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(
|
||||
conn: &mut SqliteConnection,
|
||||
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::*;
|
||||
|
||||
let active_window_id = schema::app::dsl::app
|
||||
@@ -2427,7 +2658,18 @@ fn read_sqlite_data(
|
||||
let saved_tabs: Vec<_> = tabs_for_window
|
||||
.into_iter()
|
||||
.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 left_panel = panel
|
||||
@@ -2560,6 +2802,13 @@ fn read_sqlite_data(
|
||||
)
|
||||
.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 mut cloud_objects: Vec<Box<dyn CloudObject>> = Vec::new();
|
||||
cloud_objects.extend(
|
||||
@@ -2583,6 +2832,12 @@ fn read_sqlite_data(
|
||||
.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 team_member_rows: Vec<model::TeamMemberRow> =
|
||||
@@ -2671,79 +2926,7 @@ fn read_sqlite_data(
|
||||
.optional()?
|
||||
.map(|uid| uid.into());
|
||||
|
||||
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();
|
||||
|
||||
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,
|
||||
})
|
||||
Ok((workspaces, current_workspace_uid))
|
||||
}
|
||||
|
||||
impl From<StartedCommandMetadata> for model::NewCommand {
|
||||
|
||||
@@ -5,6 +5,7 @@ use ai::workspace::WorkspaceMetadata;
|
||||
use chrono::Utc;
|
||||
use cloud_object_persistence::to_cloud_object_permissions;
|
||||
use diesel::connection::SimpleConnection;
|
||||
use galaxy_agent_core::{ConversationMessage, MessageContent, MessageRole};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_graphql::scalars::time::ServerTimestamp;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
@@ -15,6 +16,7 @@ use super::{
|
||||
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,
|
||||
};
|
||||
use crate::ai::agent::conversation::{AIConversation, AIConversationId};
|
||||
use crate::ai::facts::{AIFact, AIMemory, CloudAIFact};
|
||||
use crate::app_state::{
|
||||
AppState, CodePaneSnapShot, CodePaneTabSnapshot, LeafContents, LeafSnapshot, PaneNodeSnapshot,
|
||||
@@ -23,7 +25,10 @@ use crate::app_state::{
|
||||
use crate::cloud_object::{CloudObjectPermissions, Owner};
|
||||
use crate::code::editor_management::CodeSource;
|
||||
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::server::ids::{ClientId, SyncId};
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
#[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())
|
||||
});
|
||||
|
||||
let block_id = model.lock().active_block_id().clone();
|
||||
app.update(|ctx| {
|
||||
update_command_history(
|
||||
&ExecuteCommandEvent {
|
||||
@@ -2467,6 +2468,7 @@ fn test_histignorespace_support_in_zsh() {
|
||||
should_add_command_to_history: true,
|
||||
source: CommandExecutionSource::User,
|
||||
},
|
||||
&block_id,
|
||||
&model,
|
||||
None,
|
||||
&sessions,
|
||||
@@ -2482,6 +2484,7 @@ fn test_histignorespace_support_in_zsh() {
|
||||
should_add_command_to_history: true,
|
||||
source: CommandExecutionSource::User,
|
||||
},
|
||||
&block_id,
|
||||
&model,
|
||||
None,
|
||||
&sessions,
|
||||
|
||||
@@ -3022,7 +3022,10 @@ impl ansi::Handler for TerminalModel {
|
||||
self.is_receiving_in_band_command_output = IsReceivingInBandCommandOutput::No;
|
||||
}
|
||||
IsReceivingInBandCommandOutput::No => {
|
||||
log::warn!("Received 'end_in_band_command_output' while not expecting to read in-band command output.");
|
||||
// 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-11
@@ -1763,6 +1763,7 @@ pub enum Event {
|
||||
size_update: SizeUpdate,
|
||||
},
|
||||
ExecuteCommand(ExecuteCommandEvent),
|
||||
CancelQueuedAgentCommand(AIAgentActionId),
|
||||
BlockStarted {
|
||||
is_for_in_band_command: bool,
|
||||
},
|
||||
@@ -7695,17 +7696,8 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
}
|
||||
let block_id = model.active_block_id().clone();
|
||||
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 {
|
||||
command,
|
||||
session_id,
|
||||
@@ -7721,8 +7713,6 @@ impl TerminalView {
|
||||
});
|
||||
}
|
||||
|
||||
self.schedule_command_monitor_start(block_id, ctx);
|
||||
|
||||
if let Some(metadata) = workflow_telem_metadata {
|
||||
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);
|
||||
}
|
||||
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
|
||||
// user's ctrl-c was directed to the AIBlock instead of the command's shell block.
|
||||
let is_exact_active_command = self
|
||||
@@ -12185,6 +12176,15 @@ impl TerminalView {
|
||||
return;
|
||||
}
|
||||
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.
|
||||
// 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::ExecuteCommand(event) => Some(PtyIntent::ExecuteCommand(event.clone())),
|
||||
Event::CancelQueuedAgentCommand(action_id) => {
|
||||
Some(PtyIntent::CancelQueuedAgentCommand(action_id.clone()))
|
||||
}
|
||||
Event::RunNativeShellCompletions {
|
||||
buffer_text,
|
||||
results_tx,
|
||||
|
||||
@@ -943,6 +943,7 @@ impl TerminalView {
|
||||
|
||||
let conversation_data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
provider_context: None,
|
||||
active_provider_run_json: None,
|
||||
local_agent_groups: Vec::new(),
|
||||
tool_titles: Default::default(),
|
||||
|
||||
@@ -5,19 +5,23 @@ use galaxyui::{AppContext, ModelHandle, SingletonEntity};
|
||||
use parking_lot::FairMutex;
|
||||
|
||||
use crate::persistence::{ModelEvent, StartedCommandMetadata};
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::terminal::model::session::Sessions;
|
||||
use crate::terminal::view::ExecuteCommandEvent;
|
||||
use crate::terminal::{History, HistoryEntry, TerminalModel};
|
||||
|
||||
pub fn update_command_history(
|
||||
event: &ExecuteCommandEvent,
|
||||
block_id: &BlockId,
|
||||
model: &Arc<FairMutex<TerminalModel>>,
|
||||
model_event_sender: Option<&SyncSender<ModelEvent>>,
|
||||
sessions: &ModelHandle<Sessions>,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
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 Some(session) = sessions.as_ref(ctx).get(session_id) else {
|
||||
return;
|
||||
|
||||
@@ -10,10 +10,11 @@ use parking_lot::FairMutex;
|
||||
use thiserror::Error;
|
||||
|
||||
use super::Message;
|
||||
use crate::ai::agent::AIAgentPtyWriteMode;
|
||||
use crate::ai::agent::{AIAgentActionId, AIAgentPtyWriteMode};
|
||||
use crate::terminal::input::CommandExecutionSource;
|
||||
use crate::terminal::line_editor_status::{LineEditorStatus, LineEditorStatusEvent};
|
||||
use crate::terminal::model::ansi::Handler;
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::terminal::model::completions::ShellCompletion;
|
||||
use crate::terminal::model::escape_sequences;
|
||||
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::shell::ShellType;
|
||||
use crate::terminal::view::LINEFEED_REGEX;
|
||||
use crate::terminal::view::{ExecuteCommandEvent, LINEFEED_REGEX};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::terminal::writeable_pty::bootstrap_file::{permanent_bootstrap_file, TempBootstrapFile};
|
||||
use crate::terminal::{bootstrap, SizeUpdate, TerminalModel};
|
||||
@@ -46,6 +47,7 @@ enum PtyWrite {
|
||||
in_band_command_id: Option<String>,
|
||||
/// 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>>,
|
||||
execution: Option<Box<ExecuteCommandEvent>>,
|
||||
},
|
||||
Bytes {
|
||||
/// The bytes to be written.
|
||||
@@ -298,7 +300,17 @@ impl<T: EventLoopSender> PtyController<T> {
|
||||
cancel_tx: Sender<InBandCommandCancelledEvent>,
|
||||
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
|
||||
if let Err(err) = block_on(cancel_tx.send(InBandCommandCancelledEvent { command_id })) {
|
||||
log::warn!("Pty Controller failed to cancel in band command: {err:?}");
|
||||
@@ -311,6 +323,7 @@ impl<T: EventLoopSender> PtyController<T> {
|
||||
command: command.to_owned(),
|
||||
shell_type,
|
||||
in_band_command_id: Some(command_id),
|
||||
execution: None,
|
||||
before_write_fn: Some(Box::new(move || {
|
||||
let mut terminal_model = terminal_model.lock();
|
||||
terminal_model
|
||||
@@ -510,47 +523,19 @@ impl<T: EventLoopSender> PtyController<T> {
|
||||
/// sequences.
|
||||
pub fn write_command(
|
||||
&mut self,
|
||||
command: &str,
|
||||
event: &ExecuteCommandEvent,
|
||||
shell_type: ShellType,
|
||||
source: CommandExecutionSource,
|
||||
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.is_user_command_executing = true;
|
||||
|
||||
// Send the write to the PTY event loop.
|
||||
let write = PtyWrite::Command {
|
||||
command: command.to_owned(),
|
||||
command: event.command.clone(),
|
||||
shell_type,
|
||||
in_band_command_id: None,
|
||||
before_write_fn: None,
|
||||
execution: Some(Box::new(event.clone())),
|
||||
};
|
||||
if self.can_write_to_pty(ctx) {
|
||||
// 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.
|
||||
pub fn write_end_of_transmission_char(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
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
|
||||
/// [`LineEditorStatus::did_execute_command()`].
|
||||
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 {
|
||||
command,
|
||||
shell_type,
|
||||
before_write_fn: on_write_fn,
|
||||
execution,
|
||||
..
|
||||
} => (
|
||||
Cow::Owned(bytes_to_execute_command(
|
||||
@@ -644,13 +652,14 @@ impl<T: EventLoopSender> PtyController<T> {
|
||||
)),
|
||||
true,
|
||||
on_write_fn,
|
||||
execution,
|
||||
),
|
||||
PtyWrite::AgentInput { bytes, mode } => {
|
||||
let decorated_bytes =
|
||||
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) => {
|
||||
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
|
||||
// send the text that needs to be completed.
|
||||
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 {
|
||||
on_write_fn();
|
||||
}
|
||||
@@ -718,6 +762,11 @@ impl<T: EventLoopSender> PtyController<T> {
|
||||
}
|
||||
|
||||
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.
|
||||
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())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pty_controller_queue_tests.rs"]
|
||||
mod queue_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pty_controller_command_bytes_tests.rs"]
|
||||
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 surface_weak_handle = surface.downgrade();
|
||||
let model_clone = model.clone();
|
||||
|
||||
let command_sessions = sessions.clone();
|
||||
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)
|
||||
// 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) => {
|
||||
let Some(shell_type) = sessions
|
||||
let Some(shell_type) = command_sessions
|
||||
.as_ref(ctx)
|
||||
.get(event.session_id)
|
||||
.map(|s| s.shell().shell_type())
|
||||
@@ -88,20 +88,14 @@ pub fn wire_up_pty_controller_with_surface<T: EventLoopSender, S: TerminalSurfac
|
||||
return;
|
||||
};
|
||||
|
||||
model_clone.lock().block_list_mut().active_block_mut().set_cloud_workflow_state(event.workflow_id);
|
||||
controller.update(ctx, |controller, ctx| {
|
||||
controller.write_command(&event.command, shell_type, event.source.clone(), ctx)
|
||||
controller.write_command(&event, shell_type, ctx)
|
||||
});
|
||||
}
|
||||
PtyIntent::CancelQueuedAgentCommand(action_id) => {
|
||||
controller.update(ctx, |controller, _| {
|
||||
controller.cancel_queued_agent_command(&action_id);
|
||||
});
|
||||
|
||||
if event.should_add_command_to_history {
|
||||
update_command_history(
|
||||
&event,
|
||||
&model_clone,
|
||||
model_event_sender.as_ref(),
|
||||
&sessions,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
PtyIntent::RunNativeShellCompletions {
|
||||
buffer_text,
|
||||
@@ -120,6 +114,18 @@ pub fn wire_up_pty_controller_with_surface<T: EventLoopSender, S: TerminalSurfac
|
||||
};
|
||||
|
||||
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, |_, _| {
|
||||
model.lock().exit(ExitReason::PtyDisconnected);
|
||||
}),
|
||||
|
||||
@@ -5,7 +5,7 @@ use async_channel::Sender;
|
||||
use warpui::AppContext;
|
||||
use warpui::{Entity, ViewContext};
|
||||
|
||||
use crate::ai::agent::AIAgentPtyWriteMode;
|
||||
use crate::ai::agent::{AIAgentActionId, AIAgentPtyWriteMode};
|
||||
#[cfg(unix)]
|
||||
use crate::terminal::event::AfterBlockCompletedEvent;
|
||||
use crate::terminal::model::completions::ShellCompletion;
|
||||
@@ -30,6 +30,7 @@ pub enum PtyIntent {
|
||||
},
|
||||
Resize(SizeUpdate),
|
||||
ExecuteCommand(ExecuteCommandEvent),
|
||||
CancelQueuedAgentCommand(AIAgentActionId),
|
||||
RunNativeShellCompletions {
|
||||
buffer_text: String,
|
||||
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::AIAgentExchangeId;
|
||||
use crate::app_state::{get_app_state, AppState};
|
||||
use crate::app_state::get_app_state;
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::root_view::OpenPath;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::terminal::alt_screen_reporting::AltScreenReporting;
|
||||
use crate::terminal::general_settings::GeneralSettings;
|
||||
use crate::undo_close::UndoCloseStack;
|
||||
use crate::workspace::cross_window_tab_drag::CrossWindowTabDrag;
|
||||
use crate::workspace::{Workspace, WorkspaceAction};
|
||||
@@ -25,7 +24,6 @@ use crate::{auth, GlobalResourceHandlesProvider};
|
||||
const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_millis(250);
|
||||
|
||||
pub(crate) struct SessionSaveCoordinator {
|
||||
last_enqueued_state: Option<AppState>,
|
||||
pending_save: bool,
|
||||
timer_generation: u64,
|
||||
timer: Option<SpawnedFutureHandle>,
|
||||
@@ -34,7 +32,6 @@ pub(crate) struct SessionSaveCoordinator {
|
||||
impl SessionSaveCoordinator {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
last_enqueued_state: None,
|
||||
pending_save: false,
|
||||
timer_generation: 0,
|
||||
timer: None,
|
||||
@@ -47,7 +44,7 @@ impl SessionSaveCoordinator {
|
||||
}
|
||||
|
||||
if self.timer.is_none() {
|
||||
self.save_if_changed(ctx);
|
||||
self.enqueue_snapshot(ctx);
|
||||
} else {
|
||||
self.pending_save = true;
|
||||
}
|
||||
@@ -62,7 +59,7 @@ impl SessionSaveCoordinator {
|
||||
self.pending_save = false;
|
||||
|
||||
if session_save_is_enabled(ctx) {
|
||||
self.save_if_changed(ctx);
|
||||
self.enqueue_snapshot(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,13 +77,13 @@ impl SessionSaveCoordinator {
|
||||
}
|
||||
coordinator.timer = None;
|
||||
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)
|
||||
.get()
|
||||
.model_event_sender
|
||||
@@ -95,14 +92,9 @@ impl SessionSaveCoordinator {
|
||||
return;
|
||||
};
|
||||
let app_state = get_app_state(ctx);
|
||||
if self.last_enqueued_state.as_ref() == Some(&app_state) {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = model_event_sender.send(ModelEvent::Snapshot(app_state.clone())) {
|
||||
if let Err(err) = model_event_sender.send(ModelEvent::Snapshot(app_state)) {
|
||||
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 {}
|
||||
|
||||
fn session_save_is_enabled(ctx: &AppContext) -> bool {
|
||||
AppExecutionMode::as_ref(ctx).can_save_session()
|
||||
&& *GeneralSettings::as_ref(ctx).restore_session
|
||||
&& !CrossWindowTabDrag::as_ref(ctx).is_active()
|
||||
AppExecutionMode::as_ref(ctx).can_save_session() && !CrossWindowTabDrag::as_ref(ctx).is_active()
|
||||
}
|
||||
|
||||
/// Specifies where a forked conversation should be opened.
|
||||
|
||||
@@ -9,6 +9,7 @@ license.workspace = true
|
||||
local_fs = []
|
||||
|
||||
[dependencies]
|
||||
galaxy_agent_core.workspace = true
|
||||
chrono.workspace = true
|
||||
diesel = { workspace = true, features = [
|
||||
"chrono",
|
||||
|
||||
@@ -1086,8 +1086,19 @@ pub struct LocalAgentGroup {
|
||||
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)]
|
||||
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.
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
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() {
|
||||
let data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
provider_context: None,
|
||||
active_provider_run_json: None,
|
||||
local_agent_groups: Vec::new(),
|
||||
tool_titles: Default::default(),
|
||||
@@ -242,6 +243,7 @@ fn agent_conversation_data_omits_default_provider_backend() {
|
||||
fn agent_conversation_data_roundtrips_remote_child_marker() {
|
||||
let data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
provider_context: None,
|
||||
active_provider_run_json: None,
|
||||
local_agent_groups: Vec::new(),
|
||||
tool_titles: Default::default(),
|
||||
@@ -272,6 +274,7 @@ fn agent_conversation_data_roundtrips_remote_child_marker() {
|
||||
fn agent_conversation_data_roundtrips_optimistic_root_marker() {
|
||||
let data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
provider_context: None,
|
||||
active_provider_run_json: None,
|
||||
local_agent_groups: Vec::new(),
|
||||
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() {
|
||||
let data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
provider_context: None,
|
||||
active_provider_run_json: None,
|
||||
local_agent_groups: Vec::new(),
|
||||
tool_titles: Default::default(),
|
||||
@@ -346,6 +350,7 @@ fn agent_conversation_data_skips_serializing_none_last_event_sequence() {
|
||||
fn agent_conversation_data_roundtrips_pinned() {
|
||||
let data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
provider_context: None,
|
||||
active_provider_run_json: None,
|
||||
local_agent_groups: Vec::new(),
|
||||
tool_titles: Default::default(),
|
||||
@@ -376,6 +381,7 @@ fn agent_conversation_data_roundtrips_pinned() {
|
||||
fn agent_conversation_data_skips_serializing_unpinned() {
|
||||
let data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
provider_context: None,
|
||||
active_provider_run_json: None,
|
||||
local_agent_groups: Vec::new(),
|
||||
tool_titles: Default::default(),
|
||||
|
||||
Reference in New Issue
Block a user