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.
This commit is contained in:
2026-09-20 17:11:41 -05:00
parent d7fbc09bcf
commit 35ff43de7f
21 changed files with 1011 additions and 88 deletions
+24 -8
View File
@@ -359,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())
};
@@ -466,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);
}
@@ -1867,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.
+55
View File
@@ -2430,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,
+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,