Preserve completed command assessments

This commit is contained in:
2026-08-13 16:41:19 -05:00
parent 934cddc063
commit 5737f8342c
29 changed files with 864 additions and 94 deletions
+15
View File
@@ -37,6 +37,21 @@ use crate::settings::AISettings;
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
use crate::workspaces::user_workspaces::UserWorkspaces;
const INTERNAL_COMMAND_COMPLETION_ASSESSMENT_MESSAGE_DATA: &str =
"galaxy:internal-command-completion-assessment:v1";
pub(crate) fn mark_internal_command_completion_assessment(
message: &mut warp_multi_agent_api::Message,
) {
message.server_message_data = INTERNAL_COMMAND_COMPLETION_ASSESSMENT_MESSAGE_DATA.to_string();
}
pub(crate) fn is_internal_command_completion_assessment(
message: &warp_multi_agent_api::Message,
) -> bool {
message.server_message_data == INTERNAL_COMMAND_COMPLETION_ASSESSMENT_MESSAGE_DATA
}
/// Unique, server-generated conversation-scoped token to be roundtripped to the API when sending
/// requests that follow-up within a given conversation.
#[derive(Serialize, Debug, Clone, PartialEq, Eq, Hash)]
+10 -5
View File
@@ -23,6 +23,7 @@ use crate::ai::agent::api::convert_from::{
convert_user_query_mode, ConversionParams, ConvertAPIMessageToClientOutputMessage,
MaybeAIAgentOutputMessage,
};
use crate::ai::agent::api::is_internal_command_completion_assessment;
use crate::ai::agent::conversation::{
update_todo_list_from_todo_op, AIConversation, AIConversationId, ServerAIConversationMetadata,
};
@@ -389,17 +390,21 @@ impl ConvertToExchanges for &api::Task {
let added_message_as_exchange_input = match message {
api::message::Message::UserQuery(user_query) => {
// Add user query as input
current_inputs.push(AIAgentInput::UserQuery {
if is_internal_command_completion_assessment(api_message) {
false
} else {
// Add user query as input
current_inputs.push(AIAgentInput::UserQuery {
query: user_query.query.clone(),
context: convert_input_context(user_query.context.as_ref()),
static_query_type: None,
referenced_attachments: HashMap::new(),
user_query_mode: convert_user_query_mode(user_query.mode.as_ref()),
running_command: None,
intended_agent: Some(user_query.intended_agent()),
});
true
intended_agent: Some(user_query.intended_agent()),
});
true
}
}
api::message::Message::SystemQuery(query) => {
let Some(query_type) = &query.r#type else {
@@ -4,7 +4,7 @@ use chrono::Utc;
use warp_multi_agent_api as api;
use crate::ai::agent::api::convert_conversation::*;
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::api::{mark_internal_command_completion_assessment, ServerConversationToken};
use crate::ai::agent::conversation::{
AIAgentHarness, AIConversationId, ServerAIConversationMetadata,
};
@@ -2129,6 +2129,61 @@ fn test_create_then_edit_then_create_version_tracking() {
);
}
#[test]
fn test_internal_command_completion_assessment_restores_output_without_visible_input() {
let assessment_text =
"[Completed command: cargo test]\n[Final terminal output:\ntest result: ok\n]";
let mut hidden_assessment = api::Message {
id: "msg_assessment".to_string(),
task_id: "task1".to_string(),
request_id: "req1".to_string(),
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
query: assessment_text.to_string(),
..Default::default()
})),
..Default::default()
};
mark_internal_command_completion_assessment(&mut hidden_assessment);
let provider_history =
crate::ai::bedrock::request_translator::convert_proto_message(&hidden_assessment)
.expect("hidden assessment should remain in provider history");
assert_eq!(
provider_history.role,
crate::ai::provider::types::MessageRole::User
);
assert!(matches!(
provider_history.content,
crate::ai::provider::types::MessageContent::Text(text) if text == assessment_text
));
let task = api::Task {
id: "task1".to_string(),
messages: vec![
hidden_assessment,
api::Message {
id: "msg_output".to_string(),
task_id: "task1".to_string(),
request_id: "req1".to_string(),
message: Some(api::message::Message::AgentOutput(
api::message::AgentOutput {
text: "The command completed successfully.".to_string(),
},
)),
..Default::default()
},
],
..Default::default()
};
let exchanges = task.into_exchanges();
assert_eq!(exchanges.len(), 1);
assert!(exchanges[0].input.is_empty());
assert_eq!(
exchanges[0].format_output_for_copy(None),
"The command completed successfully."
);
}
/// Verify that a `SystemQuery::HandoffRehydration` message does not produce
/// a displayed input when restoring a conversation. It must be treated as
/// hidden, so the exchange should have zero user-visible inputs.
+4
View File
@@ -16,6 +16,7 @@ use warp_multi_agent_api as api;
use crate::ai::agent::api::convert_conversation::{
convert_input_context, convert_tool_call_result_to_input,
};
use crate::ai::agent::api::is_internal_command_completion_assessment;
use crate::ai::agent::comment::CodeReview;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::todos::AIAgentTodoList;
@@ -957,6 +958,9 @@ pub fn user_inputs_from_messages(messages: &[api::Message]) -> Vec<AIAgentInput>
let Some(inner) = &m.message else { continue };
match inner {
api::message::Message::UserQuery(uq) => {
if is_internal_command_completion_assessment(m) {
continue;
}
let context = convert_input_context(uq.context.as_ref());
let referenced_attachments = uq
.referenced_attachments
+35 -4
View File
@@ -7,13 +7,14 @@ use warp_multi_agent_api as api;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use super::{
convert_api_question, ConversionParams, ConvertAPIMessageToClientOutputMessage,
MaybeAIAgentOutputMessage,
convert_api_question, user_inputs_from_messages, ConversionParams,
ConvertAPIMessageToClientOutputMessage, MaybeAIAgentOutputMessage,
};
use crate::ai::agent::api::mark_internal_command_completion_assessment;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
runtime_activity, AIAgentActionType, AIAgentOutputMessageType, LifecycleEventType,
StartAgentExecutionMode,
runtime_activity, AIAgentActionType, AIAgentInput, AIAgentOutputMessageType,
LifecycleEventType, StartAgentExecutionMode,
};
fn start_agent_tool_call_message(
@@ -617,6 +618,36 @@ fn converts_local_start_agent_v2_with_harness_type() {
assert_eq!(lifecycle_subscription, None);
}
#[test]
fn internal_command_completion_assessment_is_not_restored_as_shared_user_input() {
let mut hidden_assessment = api::Message {
id: "hidden-assessment".to_string(),
task_id: "task".to_string(),
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
query: "[Completed command: cargo test]".to_string(),
..Default::default()
})),
..Default::default()
};
mark_internal_command_completion_assessment(&mut hidden_assessment);
let visible_query = api::Message {
id: "visible-query".to_string(),
task_id: "task".to_string(),
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
query: "What changed?".to_string(),
..Default::default()
})),
..Default::default()
};
let inputs = user_inputs_from_messages(&[hidden_assessment, visible_query]);
assert_eq!(inputs.len(), 1);
assert!(matches!(
&inputs[0],
AIAgentInput::UserQuery { query, .. } if query == "What changed?"
));
}
#[test]
fn transfer_control_tool_call_converts_to_action_message() {
let task_id = TaskId::new("task".to_string());
+37
View File
@@ -348,6 +348,43 @@ fn convert_input_to_user_input(
}
))
}
AIAgentInput::CommandCompletionAssessment {
prompt,
completed_command:
RunningCommand {
command,
block_id,
grid_contents: output,
cursor,
requested_command_id,
is_alt_screen_active,
},
..
} => Ok(
api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(
api::request::input::CliAgentUserQuery {
user_query: Some(api::request::input::UserQuery {
query: prompt,
referenced_attachments: Default::default(),
mode: Some(UserQueryMode::Normal.into()),
intended_agent: api::AgentType::Primary.into(),
}),
running_command: Some(api::RunningShellCommand {
command,
snapshot: Some(api::LongRunningShellCommandSnapshot {
output,
cursor,
command_id: block_id.as_str().to_owned(),
is_alt_screen_active,
is_preempted: false,
}),
}),
run_shell_command_tool_call_id: requested_command_id
.map(|id| id.to_string())
.unwrap_or_default(),
},
),
),
AIAgentInput::ActionResult { result, .. } => result.try_into(),
AIAgentInput::MessagesReceivedFromAgents { messages } => Ok(
api::request::input::user_inputs::user_input::Input::MessagesReceivedFromAgents(
+82 -2
View File
@@ -1,11 +1,13 @@
use std::sync::Arc;
use chrono::{DateTime, Utc};
use galaxy_core::command::ExitCode;
use warp_multi_agent_api as api;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentActionResult, AIAgentActionResultType, AIAgentContext, ImageContext,
TransferShellCommandControlToUserResult,
AIAgentActionResult, AIAgentActionResultType, AIAgentContext, AIAgentInput, ImageContext,
RunningCommand, TransferShellCommandControlToUserResult, UserQueryMode,
};
use crate::terminal::model::block::BlockId;
@@ -132,6 +134,84 @@ fn git_context_deserializes_legacy_string_pull_request_number() {
assert_eq!(pull_request.number, 42);
}
#[test]
fn command_completion_assessment_converts_to_primary_cli_query() {
let block_id = BlockId::from("completed-block".to_string());
let converted = super::convert_input(vec![AIAgentInput::CommandCompletionAssessment {
prompt: "Summarize whether the command succeeded.".to_string(),
context: Arc::from([AIAgentContext::SelectedText("root context".to_string())]),
completed_command: RunningCommand {
command: "cargo test -p galaxy".to_string(),
block_id: block_id.clone(),
grid_contents: "test result: ok".to_string(),
cursor: "cursor".to_string(),
requested_command_id: Some("run-call".to_string().into()),
is_alt_screen_active: true,
},
}])
.unwrap();
let Some(api::request::input::Type::UserInputs(inputs)) = converted.r#type else {
panic!("expected user inputs");
};
let Some(api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(query)) =
inputs.inputs[0].input.as_ref()
else {
panic!("expected CLI agent query");
};
let user_query = query
.user_query
.as_ref()
.expect("expected assessment prompt");
assert_eq!(user_query.query, "Summarize whether the command succeeded.");
assert_eq!(user_query.intended_agent(), api::AgentType::Primary);
let command = query
.running_command
.as_ref()
.expect("expected completed command");
assert_eq!(command.command, "cargo test -p galaxy");
let snapshot = command.snapshot.as_ref().expect("expected final snapshot");
assert_eq!(snapshot.command_id, block_id.as_str());
assert_eq!(snapshot.output, "test result: ok");
assert_eq!(snapshot.cursor, "cursor");
assert!(snapshot.is_alt_screen_active);
assert_eq!(query.run_shell_command_tool_call_id, "run-call");
let active = super::convert_input(vec![AIAgentInput::UserQuery {
query: "Keep monitoring.".to_string(),
context: Arc::from([]),
static_query_type: None,
referenced_attachments: Default::default(),
user_query_mode: UserQueryMode::Normal,
running_command: Some(RunningCommand {
command: "cargo test -p galaxy".to_string(),
block_id,
grid_contents: "still running".to_string(),
cursor: String::new(),
requested_command_id: None,
is_alt_screen_active: false,
}),
intended_agent: None,
}])
.unwrap();
let Some(api::request::input::Type::UserInputs(inputs)) = active.r#type else {
panic!("expected active user inputs");
};
let Some(api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(query)) =
inputs.inputs[0].input.as_ref()
else {
panic!("expected active CLI agent query");
};
assert_eq!(
query
.user_query
.as_ref()
.expect("expected active monitor prompt")
.intended_agent(),
api::AgentType::Cli
);
}
#[test]
fn transfer_control_snapshot_result_converts_to_tool_call_result_input() {
let block_id = BlockId::default();
+7 -1
View File
@@ -7,7 +7,7 @@ use warp_multi_agent_api as api;
use super::convert_to::convert_input;
use super::{ConvertToAPITypeError, RequestParams, ResponseStream, StreamEvent};
use crate::ai::agent::redaction;
use crate::ai::agent::{redaction, AIAgentInput};
use crate::ai::openai::translator as openai_translator;
use crate::ai::provider::ProviderConfig;
use crate::server::server_api::AIApiError;
@@ -97,6 +97,10 @@ pub async fn generate_multi_agent_output(
);
}
let emit_user_query_message = !params
.input
.iter()
.any(|input| matches!(input, AIAgentInput::CommandCompletionAssessment { .. }));
let mut request = api::Request {
task_context: Some(api::request::TaskContext {
tasks: params.tasks,
@@ -189,6 +193,7 @@ pub async fn generate_multi_agent_output(
progressive_summary: params.progressive_summary.clone(),
messages_sent: params.messages_sent.clone(),
global_rules: params.global_rules.clone(),
emit_user_query_message,
};
match openai_translator::execute(translator_request, &mut request).await {
@@ -223,6 +228,7 @@ pub async fn generate_multi_agent_output(
bedrock_progressive_summary: params.progressive_summary.clone(),
bedrock_messages_sent: params.messages_sent.clone(),
global_rules: params.global_rules.clone(),
emit_user_query_message,
};
match crate::ai::bedrock::translator::execute(translator_request, &mut request).await {
+15 -2
View File
@@ -2705,6 +2705,13 @@ pub enum AIAgentInput {
intended_agent: Option<AgentType>,
},
/// A hidden system turn that asks for the final assessment of a completed command.
CommandCompletionAssessment {
prompt: String,
context: Arc<[AIAgentContext]>,
completed_command: RunningCommand,
},
AutoCodeDiffQuery {
query: String,
context: Arc<[AIAgentContext]>,
@@ -2869,6 +2876,9 @@ impl Display for AIAgentInput {
Self::UserQuery { .. } => {
write!(f, "UserQuery: {}", self.display_query().unwrap_or_default())
}
Self::CommandCompletionAssessment { .. } => {
write!(f, "CommandCompletionAssessment")
}
Self::AutoCodeDiffQuery { query, .. } => {
write!(f, "AutoCodeDiffQuery: {query}")
}
@@ -2957,7 +2967,8 @@ impl AIAgentInput {
suggestion: PassiveSuggestionResultType::Prompt { prompt },
..
} => Some(prompt.clone()),
Self::AutoCodeDiffQuery { .. }
Self::CommandCompletionAssessment { .. }
| Self::AutoCodeDiffQuery { .. }
| Self::ActionResult { .. }
| Self::TriggerPassiveSuggestion { .. }
| Self::ResumeConversation { .. }
@@ -3048,6 +3059,7 @@ impl AIAgentInput {
pub fn context(&self) -> Option<&[AIAgentContext]> {
match self {
Self::UserQuery { context, .. }
| Self::CommandCompletionAssessment { context, .. }
| Self::ActionResult { context, .. }
| Self::AutoCodeDiffQuery { context, .. }
| Self::ResumeConversation { context, .. }
@@ -3081,7 +3093,8 @@ impl AIAgentInput {
Some(res)
}
Self::TriggerPassiveSuggestion { attachments, .. } => Some(attachments.clone()),
Self::ActionResult { .. }
Self::CommandCompletionAssessment { .. }
| Self::ActionResult { .. }
| Self::AutoCodeDiffQuery { .. }
| Self::ResumeConversation { .. }
| Self::InitProjectRules { .. }
+52 -2
View File
@@ -1,17 +1,22 @@
use std::collections::HashSet;
use std::ops::Range;
use std::sync::Arc;
use anyhow::anyhow;
use chrono::Local;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use warp_multi_agent_api::{FileContent, FileContentLineRange};
use crate::ai::agent::{
AIAgentContext, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentText,
AIAgentContext, AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutput,
AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentOutputStatus, AIAgentText,
AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram,
AnyFileContent, FileContext, FormattedTextWrapper, MessageId, ProgrammingLanguage,
RenderableAIError, TransientNetworkErrorKind,
RenderableAIError, RunningCommand, TransientNetworkErrorKind,
};
use crate::ai::llms::LLMId;
use crate::server::server_api::AIApiError;
use crate::terminal::model::block::BlockId;
use crate::terminal::shell::ShellType;
fn to_range(range: Range<u32>) -> Option<FileContentLineRange> {
@@ -21,6 +26,51 @@ fn to_range(range: Range<u32>) -> Option<FileContentLineRange> {
})
}
#[test]
fn command_completion_assessment_stays_hidden_from_user_transcript() {
let context: Arc<[AIAgentContext]> =
Arc::from([AIAgentContext::SelectedText("relevant context".to_string())]);
let input = AIAgentInput::CommandCompletionAssessment {
prompt: "Report the final result.".to_string(),
context: context.clone(),
completed_command: RunningCommand {
command: "cargo test -p galaxy".to_string(),
block_id: BlockId::from("completed-command".to_string()),
grid_contents: "test result: ok".to_string(),
cursor: String::new(),
requested_command_id: None,
is_alt_screen_active: false,
},
};
assert_eq!(input.display_query(), None);
assert!(!input.is_user_query());
assert!(!input.is_passive_request());
assert_eq!(input.context(), Some(context.as_ref()));
assert_eq!(input.attachments(), None);
let now = Local::now();
let exchange = AIAgentExchange {
id: AIAgentExchangeId::new(),
input: vec![input],
output_status: AIAgentOutputStatus::Streaming { output: None },
added_message_ids: HashSet::new(),
start_time: now,
finish_time: None,
time_to_first_token_ms: None,
working_directory: None,
model_id: LLMId::from("test-model"),
request_cost: None,
coding_model_id: LLMId::from("test-model"),
cli_agent_model_id: LLMId::from("test-model"),
computer_use_model_id: LLMId::from("test-model"),
response_initiator: None,
};
assert_eq!(exchange.format_input_for_copy(), "");
assert_eq!(exchange.format_for_copy(None), "");
assert!(!exchange.has_user_query());
}
#[test]
fn formatted_text_wrapper_shares_arc_across_calls() {
let text = FormattedText::new([FormattedTextLine::Line(vec![
+11
View File
@@ -47,6 +47,17 @@ pub(crate) fn redact_inputs(inputs: &mut [AIAgentInput]) {
redact_secrets(&mut running_command.cursor);
}
}
AIAgentInput::CommandCompletionAssessment {
prompt,
context,
completed_command,
} => {
redact_secrets(prompt);
redact_context(Arc::make_mut(context));
redact_secrets(&mut completed_command.command);
redact_secrets(&mut completed_command.grid_contents);
redact_secrets(&mut completed_command.cursor);
}
AIAgentInput::AutoCodeDiffQuery { query, context, .. } => {
redact_secrets(query);
redact_context(Arc::make_mut(context));