Compare commits

..
2 Commits
Author SHA1 Message Date
rkw6086 35ff43de7f Restore agent tool panes and require explicit session resume
Persist direct-provider tool calls, results, display details, and turn identities so saved conversations rebuild individual panes. Keep restored runs dormant until explicitly resumed and bind queued shell commands to their actual execution blocks.
2026-09-20 17:11:41 -05:00
rkw6086 d7fbc09bcf Fix SQLite recovery and preserve provider conversation context
Persist exact provider history and archived tool results independently of
terminal scrollback, and retain them when active run checkpoints are cleared.
Keep conversation and session saving independent of automatic reopening.

Deduplicate only committed session snapshots, retry transient SQLite write
contention, and enqueue conversation updates before writer shutdown. Recover
healthy startup data independently and protect unread records by disabling
writes with a persistent warning after an incomplete restore. Retain local
transcript pane conversation links across restarts.

Add regression coverage for SQLite failures, context round trips, legacy
fallbacks, and transcript snapshots.
2026-09-20 16:40:44 -05:00
39 changed files with 1725 additions and 273 deletions
Generated
+1
View File
@@ -11612,6 +11612,7 @@ dependencies = [
"chrono",
"diesel",
"diesel_migrations",
"galaxy_agent_core",
"serde",
"serde_json",
"warp_multi_agent_api",
+26 -8
View File
@@ -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();
+12
View File
@@ -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.
+99 -24
View File
@@ -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) {
+63 -1
View File
@@ -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());
}
+1
View File
@@ -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;
+239
View File
@@ -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;
+186
View File
@@ -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
);
}
+44 -1
View File
@@ -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,
+10
View File
@@ -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);
+18 -5
View File
@@ -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,
+81 -2
View File
@@ -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,
+54
View File
@@ -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);
});
});
});
}
+3
View File
@@ -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
+27
View File
@@ -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,
+1 -1
View File
@@ -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>,
+18
View File
@@ -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"))]
{
+33
View File
@@ -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));
});
});
}
+16 -34
View File
@@ -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;
}
+23 -4
View File
@@ -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
+22 -1
View File
@@ -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())
}
+1
View File
@@ -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
View File
@@ -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 {
+147 -1
View File
@@ -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"));
}
+3
View File
@@ -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,
+4 -1
View File
@@ -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
View File
@@ -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>>,
+7 -17
View File
@@ -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.
+1
View File
@@ -9,6 +9,7 @@ license.workspace = true
local_fs = []
[dependencies]
galaxy_agent_core.workspace = true
chrono.workspace = true
diesel = { workspace = true, features = [
"chrono",
+11
View File
@@ -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>,
+6
View File
@@ -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(),