Complete agent monitoring and Galaxy Control integration

- expose command-monitor conversations and preserve visible agent transcripts
- add bounded polling and a dedicated shell interrupt tool
- improve direct-provider images, skills, tool history, and usage handling
- package and brand Galaxy Control across releases, installers, persistence, and docs
This commit is contained in:
2026-07-29 15:04:58 -05:00
parent 100f1eff1c
commit dbfa8bcd48
172 changed files with 6357 additions and 3825 deletions
@@ -334,17 +334,17 @@ fn test_read_skill_executor_reads_enabled_bundled_skill() {
}
#[test]
fn test_read_skill_executor_rejects_warp_control_bundled_skills_when_disabled() {
fn test_read_skill_executor_rejects_galaxy_control_bundled_skills_when_disabled() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let _bundled_skills = FeatureFlag::BundledSkills.override_enabled(true);
let _warp_control_cli = FeatureFlag::WarpControlCli.override_enabled(false);
let skill_id = "warpctrl";
let _galaxy_control_cli = FeatureFlag::GalaxyControlCli.override_enabled(false);
let skill_id = "galaxyctrl";
SkillManager::handle(&app).update(&mut app, |manager, _ctx| {
manager.add_bundled_skill_for_testing(
skill_id,
bundled_skill(skill_id),
BundledSkillActivation::RequiresFeature(FeatureFlag::WarpControlCli),
BundledSkillActivation::RequiresFeature(FeatureFlag::GalaxyControlCli),
);
});
let executor_handle = add_test_read_skill_executor(&mut app);
@@ -38,9 +38,9 @@ use crate::{send_telemetry_from_ctx, TelemetryEvent};
pub struct ShellCommandExecutor {
active_session: ModelHandle<ActiveSession>,
block_finished_senders: HashMap<BlockSelector, oneshot::Sender<()>>,
/// Senders used by the `Check now` affordance to force a long-running shell command's
/// pending poll future to resolve immediately with a fresh snapshot, bypassing the
/// agent-set timeout.
/// Senders used by `Check now` and the automatic monitor watchdog to force a long-running
/// shell command's pending poll future to resolve immediately with a fresh snapshot,
/// bypassing the agent-set timeout.
force_refresh_senders: HashMap<BlockSelector, oneshot::Sender<()>>,
terminal_model: Arc<FairMutex<TerminalModel>>,
terminal_view_id: EntityId,
@@ -542,8 +542,8 @@ impl ShellCommandExecutor {
self.block_finished_senders
.insert(block_selector.clone(), block_metadata_received_tx);
// Create a channel so the `Check now` affordance can short-circuit the timeout
// and deliver the agent a fresh snapshot immediately.
// Create a channel so `Check now` or the automatic monitor watchdog can short-circuit
// the timeout and deliver the agent a fresh snapshot immediately.
let (force_refresh_tx, force_refresh_rx) = oneshot::channel();
self.force_refresh_senders
.insert(block_selector.clone(), force_refresh_tx);
@@ -555,9 +555,9 @@ impl ShellCommandExecutor {
enum WakeReason {
BlockFinished,
Timeout,
/// User clicked `Check now` in the warping indicator, short-circuiting
/// the agent-set poll timer. Treated as a preemption so the server does
/// not interpret the early snapshot as a completion.
/// The pending poll was explicitly refreshed before its agent-set timer elapsed.
/// Treated as a preemption so the provider does not interpret the early snapshot as
/// a completion.
ForceRefresh,
}
@@ -589,9 +589,8 @@ impl ShellCommandExecutor {
Err(_) => return ActionResult::Cancelled,
},
val = force_refresh_rx => match val {
// User asked the agent to check now; fall through to the snapshot
// code path below. Treated as a preemption (snapshot arrives before
// the agent's own timer would have fired).
// An explicit refresh was requested; fall through to the snapshot code path.
// Treat it as a preemption because it arrived before the agent's timer.
Ok(_) => WakeReason::ForceRefresh,
// Sender was dropped (e.g. because the executor is being torn down).
Err(_) => return ActionResult::Cancelled,
@@ -673,10 +672,10 @@ impl ShellCommandExecutor {
/// Force any in-flight poll for the given long-running command block to resolve
/// immediately with a fresh snapshot, bypassing the agent-set timeout.
///
/// Called by the `Check now` affordance in the warping indicator. No-ops if there
/// is no matching in-flight poll (e.g. because the block already finished or the
/// agent has transferred control to the user).
pub fn force_refresh_block(&mut self, block_id: &BlockId) {
/// Called by the `Check now` affordance and automatic monitor watchdog. No-ops if there is no
/// matching in-flight poll (e.g. because the block already finished or the agent transferred
/// control to the user). Returns whether a matching poll was successfully refreshed.
pub fn force_refresh_block(&mut self, block_id: &BlockId) -> bool {
let terminal_model = self.terminal_model.lock();
// Find a sender whose selector resolves to this block. In practice there is at
// most one: a given block can have at most one in-flight `action_result_future`
@@ -694,9 +693,10 @@ impl ShellCommandExecutor {
if let Some(selector) = matching_selector {
if let Some(sender) = self.force_refresh_senders.remove(&selector) {
let _ = sender.send(());
return sender.send(()).is_ok();
}
}
false
}
pub(super) fn preprocess_action(
@@ -5,7 +5,8 @@ use futures::channel::oneshot;
use parking_lot::FairMutex;
use warpui::{App, EntityId};
use super::{BlockSelector, ShellCommandExecutor};
use super::{ActionResult, BlockSelector, ShellCommandExecutor};
use crate::ai::agent::ShellCommandDelay;
use crate::terminal::event::{BlockMetadataReceivedEvent, BlockWorkingDirectoryUpdatedEvent};
use crate::terminal::model::block::{BlockId, BlockMetadata};
use crate::terminal::model::session::active_session::ActiveSession;
@@ -89,3 +90,87 @@ fn block_working_directory_updated_does_not_drain_finish_senders() {
);
});
}
#[test]
fn force_refresh_block_reports_and_resolves_matching_poll() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let sessions = app.add_model(|_| Sessions::new_for_test());
let (_model_events_tx, model_events_rx) = unbounded();
let model_event_dispatcher =
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
let active_session = app.add_model(|ctx| {
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
});
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
let block_id = terminal_model.lock().active_block_id().clone();
let executor = app.add_model(|ctx| {
ShellCommandExecutor::new(
active_session,
terminal_model,
&model_event_dispatcher,
terminal_view_id,
ctx,
)
});
let (tx, mut rx) = oneshot::channel();
executor.update(&mut app, |executor, _| {
executor
.force_refresh_senders
.insert(BlockSelector::Id(block_id.clone()), tx);
assert!(executor.force_refresh_block(&block_id));
assert!(!executor.force_refresh_block(&block_id));
});
assert!(matches!(rx.try_recv(), Ok(Some(()))));
});
}
#[test]
fn force_refresh_wakes_on_completion_poll_with_preempted_snapshot() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let sessions = app.add_model(|_| Sessions::new_for_test());
let (_model_events_tx, model_events_rx) = unbounded();
let model_event_dispatcher =
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
let active_session = app.add_model(|ctx| {
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
});
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
terminal_model
.lock()
.simulate_long_running_block("sleep 120", "still running");
let block_id = terminal_model.lock().active_block_id().clone();
let executor = app.add_model(|ctx| {
ShellCommandExecutor::new(
active_session,
terminal_model,
&model_event_dispatcher,
terminal_view_id,
ctx,
)
});
let result_future = executor.update(&mut app, |executor, _| {
executor.action_result_future(
BlockSelector::Id(block_id.clone()),
Some(ShellCommandDelay::OnCompletion),
)
});
assert!(executor.update(&mut app, |executor, _| {
executor.force_refresh_block(&block_id)
}));
let result = result_future.await;
assert!(matches!(
result,
ActionResult::LongRunningCommandSnapshot {
block_id: result_block_id,
is_preempted: true,
..
} if result_block_id == block_id
));
});
}
@@ -9,7 +9,7 @@ use shell_words::split as split_shell_words;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
use crate::ai::agent::{
AIAgentAction, AIAgentActionResultType, AIAgentActionType, LifecycleEventType,
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, LifecycleEventType,
StartAgentExecutionMode, StartAgentResult,
};
use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer;
@@ -115,6 +115,9 @@ pub struct StartAgentRequest {
}
struct PendingStartAgent {
/// Present for standalone StartAgent tool calls. RunAgents dispatches use
/// the same executor but do not have a one-to-one StartAgent action card.
action_id: Option<AIAgentActionId>,
parent_conversation_id: AIConversationId,
/// Set once the child conversation is synchronously created.
child_conversation_id: Option<AIConversationId>,
@@ -155,10 +158,35 @@ impl StartAgentExecutor {
child_conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
let Some(pending) = self.pending.get_mut(&request_id) else {
return;
let direct_provider_panel_link = {
let Some(pending) = self.pending.get_mut(&request_id) else {
return;
};
pending.child_conversation_id = Some(child_conversation_id);
if pending.wait_for_completion {
pending.action_id.clone().map(|action_id| {
(
action_id,
pending.parent_conversation_id,
child_conversation_id,
)
})
} else {
None
}
};
pending.child_conversation_id = Some(child_conversation_id);
if let Some((action_id, parent_conversation_id, child_conversation_id)) =
direct_provider_panel_link
{
ctx.emit(
StartAgentExecutorEvent::DirectProviderChildConversationCreated {
action_id,
parent_conversation_id,
child_conversation_id,
},
);
}
self.maybe_complete_pending_for_child_state(request_id, child_conversation_id, ctx);
}
@@ -374,6 +402,7 @@ impl StartAgentExecutor {
let prompt = prompt.clone();
let version = *version;
let action_id = input.action.id.clone();
let parent_conversation_id = input.conversation_id;
let (prompt, execution_mode) =
normalize_legacy_local_child_harness_command(prompt, execution_mode.clone());
@@ -511,6 +540,7 @@ impl StartAgentExecutor {
self.pending.insert(
request_id,
PendingStartAgent {
action_id: Some(action_id),
parent_conversation_id,
child_conversation_id: None,
sender,
@@ -574,6 +604,7 @@ impl StartAgentExecutor {
self.pending.insert(
request_id,
PendingStartAgent {
action_id: None,
parent_conversation_id,
child_conversation_id: None,
sender,
@@ -676,6 +707,14 @@ impl Entity for StartAgentExecutor {
pub enum StartAgentExecutorEvent {
CreateAgent(Box<StartAgentRequest>),
/// A direct-provider child conversation is available while its StartAgent
/// tool call remains open waiting for completion. This lets the parent
/// action render the live child panel before the tool result exists.
DirectProviderChildConversationCreated {
action_id: AIAgentActionId,
parent_conversation_id: AIConversationId,
child_conversation_id: AIConversationId,
},
/// A child agent failed at the launch stage (never started a server-side
/// run). The owning terminal view removes its hidden pane and conversation
/// so the orchestration pill bar does not retain a dead chip.
@@ -21,6 +21,37 @@ const FIRST_REQUEST_ID: StartAgentRequestId = StartAgentRequestId::from_raw_for_
/// exercise the direct-provider local child path instead.
const PARENT_RUN_ID: &str = "00000000-0000-0000-0000-000000000001";
#[derive(Default)]
struct CapturedDirectProviderChildLinks(Vec<(AIAgentActionId, AIConversationId, AIConversationId)>);
impl Entity for CapturedDirectProviderChildLinks {
type Event = ();
}
fn capture_direct_provider_child_links(
app: &mut App,
executor: &ModelHandle<StartAgentExecutor>,
) -> ModelHandle<CapturedDirectProviderChildLinks> {
let captured = app.add_model(|_| CapturedDirectProviderChildLinks::default());
captured.update(app, |captured, ctx| {
ctx.subscribe_to_model(executor, |captured, _, event, _ctx| {
if let StartAgentExecutorEvent::DirectProviderChildConversationCreated {
action_id,
parent_conversation_id,
child_conversation_id,
} = event
{
captured.0.push((
action_id.clone(),
*parent_conversation_id,
*child_conversation_id,
));
}
});
});
captured
}
fn build_start_agent_action(
version: StartAgentVersion,
execution_mode: StartAgentExecutionMode,
@@ -374,6 +405,144 @@ fn execute_resolves_success_when_request_linkage_happens_after_child_already_sta
});
}
#[test]
fn direct_provider_child_link_is_published_before_start_agent_completes() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let captured = capture_direct_provider_child_links(&mut app, &executor);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: parent_conversation_id,
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
let AnyActionExecution::Async {
execute_future,
on_complete,
} = execution
else {
panic!("expected async execution");
};
let child_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_child_conversation(
terminal_view_id,
"Agent 1".to_string(),
parent_conversation_id,
None,
ctx,
)
});
history_model.update(&mut app, |model, ctx| {
model.record_new_conversation_request_complete(
FIRST_REQUEST_ID,
child_conversation_id,
ctx,
);
});
captured.read(&app, |captured, _| {
assert_eq!(
captured.0,
vec![(
action.id.clone(),
parent_conversation_id,
child_conversation_id,
)]
);
});
executor.read(&app, |executor, _| {
assert!(
executor.pending.contains_key(&FIRST_REQUEST_ID),
"publishing the child link must not complete the StartAgent tool call"
);
});
drop(execute_future);
drop(on_complete);
});
}
#[test]
fn hosted_child_link_does_not_publish_direct_provider_panel_event() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let captured = capture_direct_provider_child_links(&mut app, &executor);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
history_model.update(&mut app, |model, ctx| {
model.assign_run_id_for_conversation(
parent_conversation_id,
PARENT_RUN_ID.to_string(),
None,
terminal_view_id,
ctx,
);
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: parent_conversation_id,
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
let AnyActionExecution::Async {
execute_future,
on_complete,
} = execution
else {
panic!("expected async execution");
};
let child_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_child_conversation(
terminal_view_id,
"Agent 1".to_string(),
parent_conversation_id,
None,
ctx,
)
});
history_model.update(&mut app, |model, ctx| {
model.record_new_conversation_request_complete(
FIRST_REQUEST_ID,
child_conversation_id,
ctx,
);
});
captured.read(&app, |captured, _| {
assert_eq!(captured.0, Vec::new());
});
drop(execute_future);
drop(on_complete);
});
}
#[test]
fn execute_waits_for_direct_provider_child_and_returns_its_output() {
App::test((), |mut app| async move {
@@ -345,7 +345,7 @@ impl AgentInputFooter {
let file_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("", AgentInputButtonTheme)
.with_icon(Icon::Plus)
.with_tooltip("Attach file")
.with_tooltip("Attach files or images")
.with_size(button_size)
.with_tooltip_alignment(TooltipAlignment::Left)
.on_click(|ctx| {
@@ -24,10 +24,19 @@ use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::workspace::{RestoreConversationLayout, WorkspaceAction, WorkspaceRegistry};
const DIRECT_PROVIDER_AGENT_OUTPUT_DELIMITER: &str = "\n\nAgent output:\n";
fn canonical_agent_id(agent_id: &str) -> &str {
agent_id
.split_once(DIRECT_PROVIDER_AGENT_OUTPUT_DELIMITER)
.map_or(agent_id, |(agent_id, _)| agent_id)
}
pub(crate) fn conversation_id_for_agent_id(
agent_id: &str,
app: &AppContext,
) -> Option<AIConversationId> {
let agent_id = canonical_agent_id(agent_id);
let history_model = BlocklistAIHistoryModel::as_ref(app);
history_model
.conversation_id_for_agent_id(agent_id)
@@ -36,6 +45,13 @@ pub(crate) fn conversation_id_for_agent_id(
agent_id.to_string(),
))
})
.or_else(|| {
let conversation_id = AIConversationId::try_from(agent_id.to_string()).ok()?;
history_model
.conversation(&conversation_id)
.is_some()
.then_some(conversation_id)
})
}
/// True if the conversation is open in some other visible pane. Hidden
@@ -303,3 +319,7 @@ pub(crate) fn conversation_navigation_card_with_icon(
hoverable.finish()
}
#[cfg(test)]
#[path = "orchestration_conversation_links_tests.rs"]
mod tests;
@@ -0,0 +1,33 @@
use warpui::App;
use super::{canonical_agent_id, conversation_id_for_agent_id};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::blocklist::BlocklistAIHistoryModel;
#[test]
fn canonical_agent_id_preserves_plain_ids() {
assert_eq!(canonical_agent_id("child-agent-id"), "child-agent-id");
}
#[test]
fn canonical_agent_id_strips_direct_provider_inline_output() {
assert_eq!(
canonical_agent_id(
"child-agent-id\n\nAgent output:\nFinished the task.\n\nAgent output:\nNested text"
),
"child-agent-id"
);
}
#[test]
fn conversation_id_fallback_rejects_uuid_absent_from_local_history() {
App::test((), |mut app| async move {
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let unknown_conversation_id = AIConversationId::new();
let resolved =
app.read(|ctx| conversation_id_for_agent_id(&unknown_conversation_id.to_string(), ctx));
assert_eq!(resolved, None);
});
}
@@ -1,20 +1,21 @@
#![allow(dead_code)]
//! Inline subagent panel rendered within the parent agent's chat flow.
//!
//! Shows a collapsible panel with the subagent's status, a mini-transcript of
//! recent messages, and controls to expand to full view or cancel.
//! recent messages, and controls to expand inline or open the full child view.
use galaxyui::elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Empty, Flex, Hoverable,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
};
use galaxyui::{AppContext, SingletonEntity};
use galaxyui::platform::Cursor;
use galaxyui::ui_components::components::UiComponent;
use galaxyui::{AppContext, EntityId, SingletonEntity};
use pathfinder_color::ColorU;
use warp_multi_agent_api as api;
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus, StatusColorStyle};
use crate::ai::agent::AIAgentActionId;
use crate::ai::blocklist::agent_view::orchestration_conversation_links::dispatch_focus_or_open_child_agent_pane;
use crate::ai::blocklist::block::AIBlockAction;
use crate::ai::blocklist::inline_action::inline_action_header::{
ICON_MARGIN, INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
@@ -23,9 +24,12 @@ use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::appearance::Appearance;
use crate::ui_components::blended_colors;
use crate::ui_components::buttons::icon_button;
use crate::ui_components::icons::Icon;
const MINI_TRANSCRIPT_MAX_LINES: usize = 8;
const MINI_TRANSCRIPT_MAX_CHARS: usize = 120;
const COMPLETION_SUMMARY_MAX_CHARS: usize = 300;
const PANEL_MAX_HEIGHT: f32 = 200.;
const PANEL_CORNER_RADIUS: f32 = 8.;
@@ -35,14 +39,18 @@ pub struct SubagentPanelState {
pub conversation_id: AIConversationId,
pub is_expanded: bool,
pub header_mouse_state: MouseStateHandle,
pub open_mouse_state: MouseStateHandle,
}
impl SubagentPanelState {
pub fn new(conversation_id: AIConversationId) -> Self {
Self {
conversation_id,
is_expanded: false,
// The panel exists to expose the child agent's live conversation.
// Start expanded so its responses are visible without another click.
is_expanded: true,
header_mouse_state: MouseStateHandle::default(),
open_mouse_state: MouseStateHandle::default(),
}
}
}
@@ -51,6 +59,7 @@ impl SubagentPanelState {
pub fn render_subagent_inline_panel(
state: &SubagentPanelState,
action_id: &AIAgentActionId,
self_terminal_view_id: EntityId,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
@@ -70,19 +79,52 @@ pub fn render_subagent_inline_panel(
// Header — always visible, click to toggle expand/collapse
let header_mouse_state = state.header_mouse_state.clone();
let open_mouse_state = state.open_mouse_state.clone();
let ui_builder = appearance.ui_builder().clone();
let toggle_action_id = action_id.clone();
let header_status = status.clone();
let header_expanded = state.is_expanded;
let toggle = Hoverable::new(header_mouse_state, move |_mouse_state| {
render_panel_header(&agent_name, &header_status, header_expanded, panel_bg, app)
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AIBlockAction::ToggleSubagentPanel {
action_id: toggle_action_id.clone(),
});
})
.finish();
let child_conversation_id = state.conversation_id;
let open = icon_button(appearance, Icon::LinkExternal, false, open_mouse_state)
.with_tooltip(move || {
ui_builder
.tool_tip("Open child conversation".to_string())
.build()
.finish()
})
.build()
.on_click(move |ctx, app, _| {
dispatch_focus_or_open_child_agent_pane(
child_conversation_id,
self_terminal_view_id,
ctx,
app,
);
})
.finish();
column.add_child(
Hoverable::new(header_mouse_state, move |_mouse_state| {
render_panel_header(&agent_name, &header_status, header_expanded, panel_bg, app)
})
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AIBlockAction::ToggleSubagentPanel {
action_id: toggle_action_id.clone(),
});
})
.finish(),
Flex::row()
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Shrinkable::new(1., toggle).finish())
.with_child(
Container::new(open)
.with_padding_left(4.)
.with_padding_right(INLINE_ACTION_HORIZONTAL_PADDING)
.finish(),
)
.finish(),
);
// Body (mini-transcript) — only when expanded
@@ -204,35 +246,44 @@ fn collect_mini_transcript(conversation_id: &AIConversationId, app: &AppContext)
return vec![];
};
let mut lines = Vec::new();
let messages = conversation.all_linearized_messages();
for msg in messages.iter().rev().take(MINI_TRANSCRIPT_MAX_LINES * 2) {
if let Some(text) = extract_message_text(msg) {
let truncated = if text.len() > 120 {
format!("{}...", &text[..117])
} else {
text
};
lines.push(truncated);
if lines.len() >= MINI_TRANSCRIPT_MAX_LINES {
break;
}
}
}
collect_visible_transcript(&messages, MINI_TRANSCRIPT_MAX_LINES)
}
fn collect_visible_transcript(messages: &[&api::Message], max_lines: usize) -> Vec<String> {
let mut lines = messages
.iter()
.rev()
// Filter first, then apply the visible-line limit. A tool-heavy turn can
// contain many internal messages between user/agent chat messages.
.filter_map(|message| extract_message_text(message))
.take(max_lines)
.map(|text| truncate_with_ellipsis(&text, MINI_TRANSCRIPT_MAX_CHARS))
.collect::<Vec<_>>();
lines.reverse();
lines
}
fn truncate_with_ellipsis(text: &str, max_chars: usize) -> String {
let mut chars = text.chars();
let prefix = chars.by_ref().take(max_chars).collect::<String>();
if chars.next().is_none() {
return prefix;
}
let visible_prefix_chars = max_chars.saturating_sub(3);
let mut truncated = prefix
.chars()
.take(visible_prefix_chars)
.collect::<String>();
truncated.push_str(&".".repeat(max_chars.min(3)));
truncated
}
fn extract_message_text(msg: &api::Message) -> Option<String> {
let message_content = msg.message.as_ref()?;
match message_content {
api::message::Message::AgentOutput(output) => {
if output.text.is_empty() {
None
} else {
Some(output.text.clone())
}
}
api::message::Message::AgentOutput(_) => extract_agent_output_text(msg),
api::message::Message::UserQuery(query) => {
if query.query.is_empty() {
None
@@ -244,6 +295,13 @@ fn extract_message_text(msg: &api::Message) -> Option<String> {
}
}
fn extract_agent_output_text(msg: &api::Message) -> Option<String> {
let api::message::Message::AgentOutput(output) = msg.message.as_ref()? else {
return None;
};
(!output.text.is_empty()).then(|| output.text.clone())
}
fn render_mini_transcript(
lines: &[String],
background: ColorU,
@@ -285,20 +343,14 @@ fn get_completion_summary(conversation_id: &AIConversationId, app: &AppContext)
return None;
}
let messages = conversation.all_linearized_messages();
for msg in messages.iter().rev() {
if let Some(text) = extract_message_text(msg) {
if !text.is_empty() {
let truncated = if text.len() > 300 {
format!("{}...", &text[..297])
} else {
text
};
return Some(truncated);
}
}
}
None
completion_summary_from_messages(&conversation.all_linearized_messages())
}
fn completion_summary_from_messages(messages: &[&api::Message]) -> Option<String> {
messages.iter().rev().find_map(|message| {
extract_agent_output_text(message)
.map(|text| truncate_with_ellipsis(&text, COMPLETION_SUMMARY_MAX_CHARS))
})
}
fn render_summary_footer(summary: &str, _background: ColorU, app: &AppContext) -> Box<dyn Element> {
@@ -333,3 +385,7 @@ fn render_summary_footer(summary: &str, _background: ColorU, app: &AppContext) -
.with_padding_bottom(6.)
.finish()
}
#[cfg(test)]
#[path = "subagent_inline_panel_tests.rs"]
mod tests;
@@ -0,0 +1,128 @@
use warp_multi_agent_api as api;
use super::{
collect_visible_transcript, completion_summary_from_messages, extract_message_text,
truncate_with_ellipsis, SubagentPanelState,
};
use crate::ai::agent::conversation::AIConversationId;
fn message(content: api::message::Message) -> api::Message {
api::Message {
message: Some(content),
..Default::default()
}
}
fn user_query(text: &str) -> api::Message {
message(api::message::Message::UserQuery(api::message::UserQuery {
query: text.to_string(),
..Default::default()
}))
}
fn system_query() -> api::Message {
message(api::message::Message::SystemQuery(
api::message::SystemQuery::default(),
))
}
fn agent_output(text: &str) -> api::Message {
message(api::message::Message::AgentOutput(
api::message::AgentOutput {
text: text.to_string(),
},
))
}
fn message_refs(messages: &[api::Message]) -> Vec<&api::Message> {
messages.iter().collect()
}
#[test]
fn truncate_with_ellipsis_preserves_short_text() {
assert_eq!(
truncate_with_ellipsis("Galaxy terminal", 20),
"Galaxy terminal"
);
}
#[test]
fn truncate_with_ellipsis_is_unicode_safe() {
let truncated = truncate_with_ellipsis("🚀🚀🚀🚀🚀 Galaxy", 8);
assert_eq!(truncated.chars().count(), 8);
assert!(truncated.ends_with("..."));
}
#[test]
fn truncate_with_ellipsis_handles_tiny_limits() {
assert_eq!(truncate_with_ellipsis("Galaxy", 2), "..");
}
#[test]
fn new_panel_starts_expanded_so_agent_chat_is_visible() {
let state = SubagentPanelState::new(AIConversationId::new());
assert!(state.is_expanded);
}
#[test]
fn transcript_shows_user_and_agent_messages_but_hides_system_queries() {
let system = system_query();
let user = user_query("Please check the build");
let agent = agent_output("The build is still running.");
assert_eq!(extract_message_text(&system), None);
assert_eq!(
extract_message_text(&user).as_deref(),
Some("Please check the build")
);
assert_eq!(
extract_message_text(&agent).as_deref(),
Some("The build is still running.")
);
assert_eq!(extract_message_text(&user_query("")), None);
assert_eq!(extract_message_text(&agent_output("")), None);
}
#[test]
fn hidden_system_messages_do_not_displace_agent_responses() {
let mut messages = vec![agent_output("Visible response to a system request")];
messages.extend((0..32).map(|_| system_query()));
let refs = message_refs(&messages);
assert_eq!(
collect_visible_transcript(&refs, 8),
vec!["Visible response to a system request"]
);
}
#[test]
fn transcript_limits_visible_messages_and_keeps_chronological_order() {
let messages = (0..10)
.map(|index| agent_output(&format!("response {index}")))
.collect::<Vec<_>>();
let refs = message_refs(&messages);
assert_eq!(
collect_visible_transcript(&refs, 8),
(2..10)
.map(|index| format!("response {index}"))
.collect::<Vec<_>>()
);
}
#[test]
fn completion_summary_uses_latest_agent_response() {
let messages = vec![
agent_output("Final assistant answer"),
user_query("A trailing user message"),
system_query(),
];
let refs = message_refs(&messages);
assert_eq!(
completion_summary_from_messages(&refs).as_deref(),
Some("Final assistant answer")
);
}
+152 -1
View File
@@ -72,7 +72,9 @@ use warpui::{
#[cfg(feature = "agent_mode_debug")]
use self::code_diff_view::FileDiff;
use self::model::{AIBlockModel, AIBlockModelHelper};
use super::action_model::{AIActionStatus, BlocklistAIActionEvent, RequestFileEditsFormatKind};
use super::action_model::{
AIActionStatus, BlocklistAIActionEvent, RequestFileEditsFormatKind, StartAgentExecutorEvent,
};
use super::code_block::CodeSnippetButtonHandles;
use super::controller::ClientIdentifiers;
use super::inline_action::code_diff_view::{
@@ -897,6 +899,108 @@ fn default_orchestration_collapsible_state(expanded: bool) -> CollapsibleElement
}
}
fn history_event_affects_conversation(
event: &BlocklistAIHistoryEvent,
conversation_id: AIConversationId,
) -> bool {
match event {
BlocklistAIHistoryEvent::StartedNewConversation {
new_conversation_id,
..
} => *new_conversation_id == conversation_id,
BlocklistAIHistoryEvent::CreatedSubtask {
conversation_id: event_conversation_id,
..
}
| BlocklistAIHistoryEvent::AppendedExchange {
conversation_id: event_conversation_id,
..
}
| BlocklistAIHistoryEvent::UpdatedStreamingExchange {
conversation_id: event_conversation_id,
..
}
| BlocklistAIHistoryEvent::UpdatedConversationStatus {
conversation_id: event_conversation_id,
..
}
| BlocklistAIHistoryEvent::SetActiveConversation {
conversation_id: event_conversation_id,
..
}
| BlocklistAIHistoryEvent::ClearedActiveConversation {
conversation_id: event_conversation_id,
..
}
| BlocklistAIHistoryEvent::RemoveConversation {
conversation_id: event_conversation_id,
..
}
| BlocklistAIHistoryEvent::DeletedConversation {
conversation_id: event_conversation_id,
..
}
| BlocklistAIHistoryEvent::UpdatedConversationMetadata {
conversation_id: event_conversation_id,
..
}
| BlocklistAIHistoryEvent::UpdatedConversationTitle {
conversation_id: event_conversation_id,
..
}
| BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
conversation_id: event_conversation_id,
..
}
| BlocklistAIHistoryEvent::ConversationServerTokenAssigned {
conversation_id: event_conversation_id,
..
}
| BlocklistAIHistoryEvent::ConversationTransferredBetweenTerminalSurfaces {
conversation_id: event_conversation_id,
..
}
| BlocklistAIHistoryEvent::NewConversationRequestComplete {
conversation_id: event_conversation_id,
..
}
| BlocklistAIHistoryEvent::OrchestrationConfigUpdated {
conversation_id: event_conversation_id,
..
}
| BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated {
conversation_id: event_conversation_id,
}
| BlocklistAIHistoryEvent::LocalSharedSessionEstablished {
conversation_id: event_conversation_id,
..
} => *event_conversation_id == conversation_id,
BlocklistAIHistoryEvent::ReassignedExchange {
new_conversation_id,
..
} => *new_conversation_id == conversation_id,
BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface {
active_conversation_id,
cleared_conversation_ids,
..
} => {
*active_conversation_id == Some(conversation_id)
|| cleared_conversation_ids.contains(&conversation_id)
}
BlocklistAIHistoryEvent::SplitConversation {
old_conversation_id,
new_conversation_id,
..
} => *old_conversation_id == conversation_id || *new_conversation_id == conversation_id,
BlocklistAIHistoryEvent::RestoredConversations {
conversation_ids, ..
} => conversation_ids.contains(&conversation_id),
BlocklistAIHistoryEvent::UpgradedTask { .. }
| BlocklistAIHistoryEvent::UpdatedTodoList { .. }
| BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. } => false,
}
}
pub struct AIBlock {
model: Rc<dyn AIBlockModel<View = AIBlock>>,
terminal_model: Arc<FairMutex<TerminalModel>>,
@@ -1222,6 +1326,7 @@ impl AIBlock {
);
Self::register_action_model_subscription(&action_model, ctx);
Self::register_start_agent_executor_subscription(&action_model, ctx);
ctx.subscribe_to_model(&active_session, |me, _, event, ctx| match event {
ActiveSessionEvent::UpdatedPwd => {
@@ -1275,6 +1380,18 @@ impl AIBlock {
ctx.subscribe_to_model(
&BlocklistAIHistoryModel::handle(ctx),
|me, _, event, ctx| {
if me
.state_handles
.subagent_panel_states
.values()
.any(|state| history_event_affects_conversation(event, state.conversation_id))
{
// Child conversations live on a different terminal
// surface, so they bypass the parent block's normal
// terminal-surface filter. Repaint the inline transcript
// and status whenever one of those children changes.
ctx.notify();
}
if event
.terminal_surface_id()
.is_none_or(|id| id == me.terminal_view_id)
@@ -4818,6 +4935,40 @@ impl AIBlock {
});
}
/// Registers the direct-provider child linkage needed to render a live
/// StartAgent panel while the action is still waiting for child output.
fn register_start_agent_executor_subscription(
action_model: &ModelHandle<BlocklistAIActionModel>,
ctx: &mut ViewContext<Self>,
) {
let start_agent_executor = action_model.as_ref(ctx).start_agent_executor(ctx);
ctx.subscribe_to_model(&start_agent_executor, |me, _, event, ctx| {
let StartAgentExecutorEvent::DirectProviderChildConversationCreated {
action_id,
parent_conversation_id,
child_conversation_id,
} = event
else {
return;
};
if me.client_ids.conversation_id != *parent_conversation_id
|| !me.requested_action_ids.contains(action_id)
{
return;
}
me.state_handles
.subagent_panel_states
.entry(action_id.clone())
.or_insert_with(|| {
super::agent_view::subagent_inline_panel::SubagentPanelState::new(
*child_conversation_id,
)
});
ctx.notify();
});
}
/// Cleans up state for this block, to be called before the block is `Drop`ped (e.g. deleted from the blocklist).
pub fn cleanup_block(&mut self, ctx: &mut ViewContext<Self>) {
if self.is_finished() {
+156 -38
View File
@@ -128,6 +128,8 @@ const HAS_PENDING_CLI_ACTION_CONTEXT_KEY: &str = "HasPendingCLIAgentAction";
const HAS_PENDING_NON_TRANSFER_CONTROL_ACTION_CONTEXT_KEY: &str =
"HasPendingNonTransferControlCLIAgentAction";
const BLOCKED_ACTION_MESSAGE_FOR_TRANSFER_CONTROL: &str = "Agent is asking you to take control.";
const BLOCKED_ACTION_MESSAGE_FOR_INTERRUPT: &str =
"Agent wants to interrupt this running command with Ctrl+C.";
pub fn init(app: &mut AppContext) {
use galaxyui::keymap::macros::*;
@@ -198,6 +200,7 @@ pub struct CLISubagentView {
action_model: ModelHandle<BlocklistAIActionModel>,
terminal_model: Arc<FairMutex<TerminalModel>>,
conversation_id: AIConversationId,
task_id: TaskId,
terminal_view_id: EntityId,
state_handles: StateHandles,
@@ -349,6 +352,7 @@ impl CLISubagentView {
..
} if *old_id == task_id_clone => {
task_id_clone = new_id.clone();
me.task_id = new_id.clone();
}
BlocklistAIHistoryEvent::AppendedExchange {
exchange_id,
@@ -371,10 +375,6 @@ impl CLISubagentView {
ctx,
);
me.model = Rc::new(model);
me.code_editor_views = Default::default();
me.code_editor_buttons = Default::default();
me.table_section_handles = Default::default();
me.secret_redaction_state.reset();
me.set_state_from_updated_inputs(ctx);
}
ctx.notify();
@@ -459,6 +459,7 @@ impl CLISubagentView {
terminal_model,
subagent_controller,
conversation_id,
task_id,
terminal_view_id: ctx.view_id(),
link_detection_state: Default::default(),
code_editor_views: Default::default(),
@@ -483,9 +484,67 @@ impl CLISubagentView {
selected_text: Arc::new(RwLock::new(None)),
};
view.set_state_from_updated_inputs(ctx);
view.handle_updated_exchange_output(ctx);
view
}
fn task_inputs_to_render(&self, app: &AppContext) -> Vec<AIAgentInput> {
BlocklistAIHistoryModel::as_ref(app)
.conversation(&self.conversation_id)
.and_then(|conversation| conversation.get_task(&self.task_id))
.map(|task| {
task.exchanges()
.flat_map(|exchange| exchange.input.iter().cloned())
.collect()
})
.unwrap_or_else(|| self.model.inputs_to_render(app).to_vec())
}
/// Builds the visible CLI transcript across every exchange in the monitor task.
///
/// User queries and assistant text remain visible across automatic polling exchanges. Internal
/// `ActionResult` inputs remain absent because input rendering still explicitly accepts only
/// `UserQuery`. Historical tool activity is omitted to avoid a growing stack of repeated poll
/// cards; only the newest exchange's live action is retained.
fn task_output_to_render(&self, app: &AppContext) -> AIAgentOutput {
let Some(task) = BlocklistAIHistoryModel::as_ref(app)
.conversation(&self.conversation_id)
.and_then(|conversation| conversation.get_task(&self.task_id))
else {
return self
.model
.status(app)
.output_to_render()
.map(|output| output.get().clone())
.unwrap_or_default();
};
let Some(last_exchange_id) = task.last_exchange().map(|exchange| exchange.id) else {
return AIAgentOutput::default();
};
let mut visible_output = AIAgentOutput::default();
for exchange in task.exchanges() {
let Some(output) = exchange.output_status.output() else {
continue;
};
let output = output.get();
visible_output.messages.extend(
output
.messages
.iter()
.filter(|message| {
should_retain_task_output_message(
&message.message,
exchange.id == last_exchange_id,
)
})
.cloned(),
);
}
visible_output
}
fn execute_pending_action(&mut self, ctx: &mut ViewContext<Self>) {
let Some(blocked_action) = self.model.blocked_action(&self.action_model, ctx) else {
return;
@@ -653,26 +712,12 @@ impl CLISubagentView {
}
fn handle_updated_exchange_output(&mut self, ctx: &mut ViewContext<Self>) {
match self.model.status(ctx) {
AIBlockOutputStatus::Pending => {
self.secret_redaction_state.reset();
}
AIBlockOutputStatus::PartiallyReceived { output } => {
let output = output.get();
self.handle_updated_output(&output, ctx);
}
AIBlockOutputStatus::Complete { output } => {
let output = output.get();
self.handle_updated_output(&output, ctx);
let output = self.task_output_to_render(ctx);
if !output.messages.is_empty() {
self.handle_updated_output(&output, ctx);
if self.model.status(ctx).is_complete() {
self.handle_complete_output(&output, ctx);
}
AIBlockOutputStatus::Cancelled { partial_output, .. } => {
if let Some(output) = partial_output.as_ref() {
let output = output.get();
self.handle_updated_output(&output, ctx);
}
}
AIBlockOutputStatus::Failed { .. } => (),
}
ctx.notify();
}
@@ -827,8 +872,7 @@ impl CLISubagentView {
}
let has_user_input = self
.model
.inputs_to_render(ctx)
.task_inputs_to_render(ctx)
.iter()
.any(|input| input.is_user_query());
let should_hide_responses = self
@@ -859,7 +903,7 @@ impl CLISubagentView {
self.reset_input_dismiss_timer(ctx);
// Detect links in all user queries
for (input_index, input) in self.model.inputs_to_render(ctx).iter().enumerate() {
for (input_index, input) in self.task_inputs_to_render(ctx).iter().enumerate() {
if let AIAgentInput::UserQuery { query, .. } = input {
detect_links(
&mut self.link_detection_state,
@@ -977,7 +1021,7 @@ impl View for CLISubagentView {
.with_cross_axis_alignment(CrossAxisAlignment::Stretch);
// Render user queries/follow-ups with avatar and interactive text
let inputs = self.model.inputs_to_render(app);
let inputs = self.task_inputs_to_render(app);
for (input_index, input) in inputs.iter().enumerate() {
if let AIAgentInput::UserQuery { query, .. } = input {
let text = render_query_text(
@@ -1059,11 +1103,12 @@ impl View for CLISubagentView {
let status = self.model.status(app);
let blocked_action = self.model.blocked_action(&self.action_model, app);
let has_blocked_action = blocked_action.is_some();
let should_hide_responses = block.should_hide_responses();
let mut has_visible_response = false;
if let Some(output) = status.output_to_render() {
let output = output.get();
let output = self.task_output_to_render(app);
if !output.messages.is_empty() {
let mut code_section_index = 0;
let mut text_section_index = 0;
let mut table_section_index = 0;
@@ -1082,6 +1127,7 @@ impl View for CLISubagentView {
AIAgentOutputMessageType::Text(AIAgentText { sections })
if !are_all_text_sections_empty(sections) =>
{
has_visible_response = true;
let text_color = blended_colors::text_main(theme, theme.surface_1());
output_items.add_child(render_text_sections(
TextSectionsProps {
@@ -1129,6 +1175,7 @@ impl View for CLISubagentView {
if blocked_action.is_none() && !is_cancelled && !should_hide_responses {
if let Some(rendered_action) = render_action(action.action.clone(), app)
{
has_visible_response = true;
result.add_child(
render_scrollable_container(
ScrollableContainerProps {
@@ -1156,6 +1203,7 @@ impl View for CLISubagentView {
AIAgentOutputMessageType::WebSearch(WebSearchStatus::Searching { query })
if !should_hide_responses =>
{
has_visible_response = true;
result.add_child(
render_scrollable_container(
ScrollableContainerProps {
@@ -1189,6 +1237,7 @@ impl View for CLISubagentView {
// surfaced only once recovery has actually failed. Dogfood builds (Local/Dev)
// opt out so developers still see every transport failure aggressively.
if !error.should_suppress_during_recovery() {
has_visible_response = true;
output_border = Border::all(1.).with_border_color(theme.ui_error_color());
output_items.add_child(render_failed_output(
FailedOutputProps {
@@ -1244,6 +1293,29 @@ impl View for CLISubagentView {
}
}
if !has_visible_response && !has_blocked_action && !should_hide_responses {
result.add_child(
render_scrollable_container(
ScrollableContainerProps {
scroll_state: self.state_handles.action_scroll_state.clone(),
child: render_action_status(
"Agent is monitoring the command…".to_string(),
Icon::ClockRefresh,
app,
),
background_color: internal_colors::neutral_2(appearance.theme()),
border: Some(
Border::all(1.).with_border_fill(internal_colors::neutral_3(theme)),
),
max_height: resizable_height,
},
app,
)
.with_margin_bottom(8.)
.finish(),
);
}
if !output_items.is_empty() && !should_hide_responses {
let selected_text = self.selected_text.clone();
let query_selection_handle = self.state_handles.query_selection_handle.clone();
@@ -1288,10 +1360,14 @@ impl View for CLISubagentView {
if let Some(rendered_action) = blocked_action.and_then(|action| match action.action {
AIAgentActionType::WriteToLongRunningShellCommand { input, mode, .. } => {
let header = if mode.is_shell_interrupt(&input) {
BLOCKED_ACTION_MESSAGE_FOR_INTERRUPT
} else {
BLOCKED_ACTION_MESSAGE_FOR_WRITE_TO_LONG_RUNNING_SHELL_COMMAND
};
Some(render_blocked_action(
BlockedActionProps {
header: BLOCKED_ACTION_MESSAGE_FOR_WRITE_TO_LONG_RUNNING_SHELL_COMMAND
.to_string(),
header: header.to_string(),
description: Some(render_write_to_pty_input(
WriteToPtyInputProps {
input: input.clone(),
@@ -1557,7 +1633,23 @@ fn should_show_read_files_speedbump(app: &AppContext) -> bool {
&& *AISettings::as_ref(app).should_show_agent_mode_autoread_files_speedbump
}
fn should_retain_task_output_message(
message: &AIAgentOutputMessageType,
is_latest_exchange: bool,
) -> bool {
matches!(message, AIAgentOutputMessageType::Text(_))
|| (is_latest_exchange
&& matches!(
message,
AIAgentOutputMessageType::Action(_) | AIAgentOutputMessageType::WebSearch(_)
))
}
fn get_action_loading_text(action: AIAgentActionType) -> Option<String> {
if action.is_shell_command_interrupt() {
return Some("Interrupting the running command with Ctrl+C…".to_string());
}
match action {
AIAgentActionType::SearchCodebase(_) => {
Some(LOAD_OUTPUT_MESSAGE_FOR_SEARCH_CODEBASE.to_string())
@@ -1565,26 +1657,46 @@ fn get_action_loading_text(action: AIAgentActionType) -> Option<String> {
AIAgentActionType::ReadFiles(_) => Some(LOAD_OUTPUT_MESSAGE_FOR_READING_FILES.to_string()),
AIAgentActionType::Grep { .. } => Some(LOAD_OUTPUT_MESSAGE_FOR_GREP.to_string()),
AIAgentActionType::FileGlobV2 { .. } => Some(LOAD_OUTPUT_MESSAGE_FOR_FILE_GLOB.to_string()),
AIAgentActionType::ReadShellCommandOutput { delay, .. } => match delay {
Some(crate::ai::agent::ShellCommandDelay::OnCompletion) => {
Some("Waiting for the running command to finish…".to_string())
}
Some(crate::ai::agent::ShellCommandDelay::Duration(_)) | None => {
Some("Checking the running command output…".to_string())
}
},
AIAgentActionType::WriteToLongRunningShellCommand { .. } => {
Some("Sending input to the running command…".to_string())
}
_ => None,
}
}
fn get_action_icon(action: AIAgentActionType) -> Option<Icon> {
if action.is_shell_command_interrupt() {
return Some(Icon::Stop);
}
match action {
AIAgentActionType::SearchCodebase(_)
| AIAgentActionType::ReadFiles(_)
| AIAgentActionType::Grep { .. }
| AIAgentActionType::FileGlobV2 { .. } => Some(Icon::Search),
AIAgentActionType::ReadShellCommandOutput { .. } => Some(Icon::ClockRefresh),
AIAgentActionType::WriteToLongRunningShellCommand { .. } => Some(Icon::TerminalInput),
_ => None,
}
}
fn render_action(action: AIAgentActionType, app: &AppContext) -> Option<Box<dyn Element>> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let text = get_action_loading_text(action.clone())?;
let icon = get_action_icon(action)?;
Some(render_action_status(text, icon, app))
}
fn render_action_status(text: String, icon: Icon, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let icon = Container::new(
ConstrainedBox::new(
@@ -1609,13 +1721,11 @@ fn render_action(action: AIAgentActionType, app: &AppContext) -> Option<Box<dyn
)
.finish();
let row = Flex::row()
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_children([icon, text])
.finish();
Some(row)
.finish()
}
fn render_web_search(query: Option<String>, app: &AppContext) -> Box<dyn Element> {
@@ -1915,6 +2025,10 @@ fn render_transfer_control_reason(reason: &str, app: &AppContext) -> Box<dyn Ele
}
fn get_blocked_action_header(action: AIAgentActionType) -> Option<String> {
if action.is_shell_command_interrupt() {
return Some(BLOCKED_ACTION_MESSAGE_FOR_INTERRUPT.to_string());
}
match action {
AIAgentActionType::WriteToLongRunningShellCommand { .. } => {
Some(BLOCKED_ACTION_MESSAGE_FOR_WRITE_TO_LONG_RUNNING_SHELL_COMMAND.to_string())
@@ -2170,3 +2284,7 @@ fn render_blocked_action(props: BlockedActionProps<'_>, app: &AppContext) -> Box
)
.finish()
}
#[cfg(test)]
#[path = "cli_tests.rs"]
mod tests;
+391 -107
View File
@@ -10,15 +10,17 @@ use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentActionId, AIAgentActionResultType, AIAgentContext, CancellationReason,
ReadShellCommandOutputResult, RequestCommandOutputResult,
ReadShellCommandOutputResult, RequestCommandOutputResult, RunningCommand,
TransferShellCommandControlToUserResult, WriteToLongRunningShellCommandResult,
};
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewEntryOrigin};
use crate::ai::blocklist::context_model::block_context_from_terminal_model;
use crate::ai::blocklist::{
BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIController, BlocklistAIHistoryEvent,
BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIController,
BlocklistAIControllerEvent, BlocklistAIHistoryEvent,
};
use crate::server::telemetry::{CLISubagentControlState, TelemetryEvent};
use crate::terminal::event::BlockType;
use crate::terminal::model::block::BlockId;
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::TerminalModel;
@@ -38,8 +40,19 @@ pub enum UserTakeOverReason {
#[derive(Debug, Clone, Default)]
struct ActiveCLISubagentState {
initial_requested_command_action_id: Option<AIAgentActionId>,
task_id: Option<TaskId>,
last_snapshot_at: Option<Instant>,
completion: Option<PendingCommandCompletion>,
}
#[derive(Debug, Clone)]
struct PendingCommandCompletion {
conversation_id: AIConversationId,
initial_requested_command_action_id: Option<AIAgentActionId>,
prompt: String,
completed_command: RunningCommand,
final_turn_started: bool,
}
impl UserTakeOverReason {
@@ -140,6 +153,15 @@ impl CLISubagentController {
) -> Self {
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, Self::handle_history_model_event);
ctx.subscribe_to_model(controller, |me, _, event, ctx| {
let BlocklistAIControllerEvent::FinishedReceivingOutput {
conversation_id, ..
} = event
else {
return;
};
me.advance_completed_subagents(*conversation_id, ctx);
});
ctx.subscribe_to_model(action_model, |me, _, event, ctx| match event {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(_) => {
@@ -166,21 +188,39 @@ impl CLISubagentController {
agent_has_control: active_block.is_agent_in_control(),
});
}
BlocklistAIActionEvent::FinishedAction { action_id, .. } => {
let snapshot_block_id = me
BlocklistAIActionEvent::FinishedAction {
action_id: finished_action_id,
..
} => {
let action_result = me
.action_model
.as_ref(ctx)
.get_action_result(action_id)
.get_action_result(finished_action_id);
let initial_command_finished_without_snapshot =
action_result.is_some_and(|result| {
matches!(
&result.result,
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::Completed { .. }
| RequestCommandOutputResult::CancelledBeforeExecution
| RequestCommandOutputResult::Denylisted { .. }
)
)
});
let snapshot_block_id = action_result
.and_then(|result| snapshot_block_id_for_action_result(&result.result))
.cloned();
let command_finished_block_id = action_result
.and_then(|result| command_finished_block_id(&result.result))
.cloned();
let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
active_block.update_is_agent_blocked(false);
let action_id = active_block.requested_command_action_id().cloned();
let active_command_action_id = active_block.requested_command_action_id().cloned();
ctx.emit(CLISubagentEvent::UpdatedControl {
block_id: active_block.id().clone(),
requested_command_action_id: action_id,
requested_command_action_id: active_command_action_id,
agent_has_control: active_block.is_agent_in_control(),
});
@@ -192,6 +232,22 @@ impl CLISubagentController {
.last_snapshot_at = Some(Instant::now());
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
}
if initial_command_finished_without_snapshot {
me.active_subagents_by_block.retain(|_, state| {
state.task_id.is_some()
|| state.initial_requested_command_action_id.as_ref()
!= Some(finished_action_id)
});
}
if let Some(block_id) = command_finished_block_id {
if let Some(completion) = me
.active_subagents_by_block
.get_mut(&block_id)
.and_then(|state| state.completion.as_mut())
{
completion.final_turn_started = true;
}
}
}
_ => (),
});
@@ -209,55 +265,65 @@ impl CLISubagentController {
let block_id = block.id().clone();
let conversation_id = block.ai_conversation_id();
let requested_command_action_id = block.requested_command_action_id().cloned();
let was_agent_tagged_in = block.interaction_mode().is_agent_tagged_in();
let has_agent_metadata = block.agent_interaction_metadata().is_some();
let completion = match (&block_completed_event.block_type, conversation_id) {
(BlockType::User(completed), Some(conversation_id)) => {
let command = if completed.command_with_obfuscated_secrets.is_empty() {
completed.command.clone()
} else {
completed.command_with_obfuscated_secrets.clone()
};
let output = completed
.output_truncated_with_obfuscated_secrets
.clone();
let exit_code = completed.serialized_block.exit_code.value();
Some(PendingCommandCompletion {
conversation_id,
initial_requested_command_action_id: requested_command_action_id
.clone(),
prompt: format!(
"The monitored command has finished with exit code {exit_code}. \
Give the user a concise final assessment grounded in the final \
output below. Do not call another shell tool or restart the \
command.\n\nCommand:\n```sh\n{command}\n```\n\nFinal output:\n```text\n{output}\n```"
),
completed_command: RunningCommand {
command,
block_id: block_id.clone(),
grid_contents: output,
cursor: String::new(),
requested_command_id: requested_command_action_id.clone(),
is_alt_screen_active: false,
},
final_turn_started: false,
})
}
(
BlockType::BootstrapHidden
| BlockType::BootstrapVisible(_)
| BlockType::Restored
| BlockType::InBandCommand
| BlockType::Background(_)
| BlockType::Static,
_,
)
| (BlockType::User(_), None) => None,
};
drop(terminal_model);
let removed_subagent_state = me.active_subagents_by_block.remove(&block_id);
if removed_subagent_state
.as_ref()
.is_some_and(|state| state.last_snapshot_at.is_some())
{
let Some(subagent_state) = me.active_subagents_by_block.get_mut(&block_id) else {
return;
};
if subagent_state.last_snapshot_at.is_some() {
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
}
if removed_subagent_state
.as_ref()
.is_some_and(|state| state.task_id.is_some())
{
let is_inline_agent_view =
me.agent_view_controller.as_ref().is_some_and(|controller| {
controller.read(ctx, |controller, _| controller.is_inline())
});
if is_inline_agent_view {
// Mark conversation as successfully completed BEFORE exiting agent view.
// The command finished naturally, so this is a successful completion.
if let Some(conversation_id) = conversation_id {
me.controller.update(ctx, |controller, ctx| {
controller.cancel_conversation_progress(
conversation_id,
CancellationReason::CommandFinishedDuringInlineAgentView,
ctx,
);
});
}
}
ctx.emit(CLISubagentEvent::FinishedSubagent {
block_id,
conversation_id,
initial_requested_command_action_id: requested_command_action_id,
});
}
// Exit inline agent view if agent was tagged in or had metadata (was in control).
if let Some(agent_view_controller) = &me.agent_view_controller {
agent_view_controller.update(ctx, |controller, ctx| {
if controller.is_inline() && (was_agent_tagged_in || has_agent_metadata) {
controller.exit_agent_view(ctx);
}
});
subagent_state.completion = completion;
if subagent_state.completion.is_none() {
log::warn!(
"CLI monitor block {block_id:?} completed without final command metadata"
);
return;
}
me.advance_completed_subagent(&block_id, ctx);
}
});
@@ -271,6 +337,112 @@ impl CLISubagentController {
}
}
fn advance_completed_subagents(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
let block_ids = self
.active_subagents_by_block
.iter()
.filter_map(|(block_id, state)| {
state
.completion
.as_ref()
.is_some_and(|completion| completion.conversation_id == conversation_id)
.then_some(block_id.clone())
})
.collect::<Vec<_>>();
for block_id in block_ids {
self.advance_completed_subagent(&block_id, ctx);
}
}
fn advance_completed_subagent(&mut self, block_id: &BlockId, ctx: &mut ModelContext<Self>) {
let Some((task_id, completion)) = self
.active_subagents_by_block
.get(block_id)
.and_then(|state| Some((state.task_id.clone()?, state.completion.as_ref()?.clone())))
else {
return;
};
let has_active_stream = self
.controller
.as_ref(ctx)
.has_active_stream_for_conversation(completion.conversation_id, ctx);
let has_unfinished_action = self
.action_model
.as_ref(ctx)
.has_unfinished_actions_for_conversation(completion.conversation_id);
if has_active_stream || has_unfinished_action {
return;
}
if completion.final_turn_started {
self.finish_completed_subagent(block_id, ctx);
return;
}
let sent = self.controller.update(ctx, |controller, ctx| {
controller.send_command_completion_assessment(
completion.conversation_id,
task_id,
completion.prompt,
completion.completed_command,
ctx,
)
});
if sent {
if let Some(completion) = self
.active_subagents_by_block
.get_mut(block_id)
.and_then(|state| state.completion.as_mut())
{
completion.final_turn_started = true;
}
}
}
fn finish_completed_subagent(&mut self, block_id: &BlockId, ctx: &mut ModelContext<Self>) {
let Some(state) = self.active_subagents_by_block.remove(block_id) else {
return;
};
let Some(completion) = state.completion else {
return;
};
let deactivate_result =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
history_model.deactivate_cli_subagent_task_for_conversation(
block_id,
completion.conversation_id,
)
});
if let Err(error) = deactivate_result {
log::error!(
"Failed to deactivate completed CLI monitor for block {block_id:?}: {error:?}"
);
}
ctx.emit(CLISubagentEvent::FinishedSubagent {
block_id: block_id.clone(),
conversation_id: Some(completion.conversation_id),
initial_requested_command_action_id: completion.initial_requested_command_action_id,
});
if let Some(agent_view_controller) = &self.agent_view_controller {
agent_view_controller.update(ctx, |controller, ctx| {
let is_this_inline_conversation = controller.is_inline()
&& controller.agent_view_state().active_conversation_id()
== Some(completion.conversation_id);
if is_this_inline_conversation {
controller.exit_agent_view(ctx);
}
});
}
}
pub fn is_agent_in_control(&self) -> bool {
let terminal_model = self.terminal_model.lock();
terminal_model
@@ -293,16 +465,34 @@ 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(&mut self, block_id: &BlockId, action_id: &AIAgentActionId) {
self.active_subagents_by_block
.entry(block_id.clone())
.or_default()
.initial_requested_command_action_id = Some(action_id.clone());
}
/// Force the currently in-flight poll for the given long-running command block to
/// resolve immediately with a fresh snapshot, bypassing the agent-set timeout.
/// Backs the `Check now` affordance surfaced next to the `Last seen by agent ...`
/// indicator in the warping footer.
pub fn request_force_refresh(&self, block_id: &BlockId, ctx: &mut ModelContext<Self>) {
/// indicator in the command status footer. Returns whether a matching poll was refreshed.
pub fn request_force_refresh(
&mut self,
block_id: &BlockId,
ctx: &mut ModelContext<Self>,
) -> bool {
let executor_handle = self.action_model.as_ref(ctx).shell_command_executor(ctx);
let block_id = block_id.clone();
executor_handle.update(ctx, move |executor, _| {
executor.force_refresh_block(&block_id);
});
let refreshed =
executor_handle.update(ctx, |executor, _| executor.force_refresh_block(&block_id));
if refreshed {
self.active_subagents_by_block.entry(block_id).or_default();
}
refreshed
}
pub fn switch_control_to_user(&self, reason: UserTakeOverReason, ctx: &mut ModelContext<Self>) {
@@ -475,6 +665,81 @@ impl CLISubagentController {
}
}
fn spawn_cli_subagent_for_task_if_ready(
&mut self,
conversation_id: AIConversationId,
task_id: &TaskId,
ctx: &mut ModelContext<Self>,
) {
let history_model = BlocklistAIHistoryModel::handle(ctx);
let Some(conversation) = history_model.as_ref(ctx).conversation(&conversation_id) else {
return;
};
let Some(task) = conversation.get_task(task_id) else {
return;
};
let Some(cli_subagent_block_id) = task.cli_subagent_block_id() else {
return;
};
// The direct-provider action-result path creates the optimistic task before appending its
// first exchange. Depending on event delivery order, CreatedSubtask can therefore arrive
// before the view model is constructible. AppendedExchange retries this same idempotent
// path.
if task.last_exchange().is_none()
|| conversation
.is_subagent_task_finished(task_id)
.unwrap_or(true)
|| self
.active_subagents_by_block
.get(&cli_subagent_block_id)
.and_then(|state| state.task_id.as_ref())
== Some(task_id)
{
return;
}
let mut terminal_model = self.terminal_model.lock();
let Some(block) = terminal_model
.block_list_mut()
.mut_block_from_id(&cli_subagent_block_id)
else {
return;
};
let block_id = block.id().clone();
if let Err(e) =
block.set_agent_interaction_mode_for_agent_monitored_command(task_id, conversation_id)
{
log::error!("Could not update interaction mode to agent-monitored: {e:?}",);
return;
};
let action_id = block.requested_command_action_id().cloned();
let agent_has_control = block.is_agent_in_control();
drop(terminal_model);
// When the CLI subagent is first created for a long running command,
// the agent now has control. Emit an UpdatedControl event so that
// shared-session state can reflect this initial control state.
ctx.emit(CLISubagentEvent::UpdatedControl {
block_id: block_id.clone(),
requested_command_action_id: action_id.clone(),
agent_has_control,
});
self.active_subagents_by_block
.entry(block_id.clone())
.or_default()
.task_id = Some(task_id.clone());
ctx.emit(CLISubagentEvent::SpawnedSubagent {
task_id: task_id.clone(),
conversation_id,
block_id,
initial_requested_command_action_id: action_id,
});
self.advance_completed_subagent(&cli_subagent_block_id, ctx);
}
fn handle_history_model_event(
&mut self,
_: ModelHandle<BlocklistAIHistoryModel>,
@@ -492,57 +757,12 @@ impl CLISubagentController {
task_id,
conversation_id,
..
} => {
let history_model = BlocklistAIHistoryModel::handle(ctx);
let Some(cli_subagent_block_id) = history_model
.as_ref(ctx)
.conversation(conversation_id)
.and_then(|c| c.get_task(task_id))
.and_then(|task| task.cli_subagent_block_id())
else {
return;
};
let mut terminal_model = self.terminal_model.lock();
let Some(block) = terminal_model
.block_list_mut()
.mut_block_from_id(&cli_subagent_block_id)
else {
return;
};
let block_id = block.id().clone();
if let Err(e) = block.set_agent_interaction_mode_for_agent_monitored_command(
task_id,
*conversation_id,
) {
log::error!("Could not update interaction mode to agent-monitored: {e:?}",);
return;
};
let action_id = block.requested_command_action_id().cloned();
let agent_has_control = block.is_agent_in_control();
drop(terminal_model);
// When the CLI subagent is first created for a long running command,
// the agent now has control. Emit an UpdatedControl event so that
// shared-session state can reflect this initial control state.
ctx.emit(CLISubagentEvent::UpdatedControl {
block_id: block_id.clone(),
requested_command_action_id: action_id.clone(),
agent_has_control,
});
self.active_subagents_by_block
.entry(block_id.clone())
.or_default()
.task_id = Some(task_id.clone());
ctx.emit(CLISubagentEvent::SpawnedSubagent {
task_id: task_id.clone(),
conversation_id: *conversation_id,
block_id: block_id.clone(),
initial_requested_command_action_id: action_id,
});
}
| BlocklistAIHistoryEvent::AppendedExchange {
task_id,
conversation_id,
..
} => self.spawn_cli_subagent_for_task_if_ready(*conversation_id, task_id, ctx),
BlocklistAIHistoryEvent::UpgradedTask {
optimistic_id: old_id,
server_id: new_id,
@@ -635,3 +855,67 @@ fn snapshot_block_id_for_action_result(result: &AIAgentActionResultType) -> Opti
_ => None,
}
}
fn command_finished_block_id(result: &AIAgentActionResultType) -> Option<&BlockId> {
match result {
AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::Completed {
block_id,
..
})
| AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::CommandFinished { block_id, .. },
)
| AIAgentActionResultType::ReadShellCommandOutput(
ReadShellCommandOutputResult::CommandFinished { block_id, .. },
)
| AIAgentActionResultType::TransferShellCommandControlToUser(
TransferShellCommandControlToUserResult::CommandFinished { block_id, .. },
) => Some(block_id),
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::LongRunningCommandSnapshot { .. }
| RequestCommandOutputResult::CancelledBeforeExecution
| RequestCommandOutputResult::Denylisted { .. },
)
| AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::Snapshot { .. }
| WriteToLongRunningShellCommandResult::Cancelled
| WriteToLongRunningShellCommandResult::Error(_),
)
| AIAgentActionResultType::ReadShellCommandOutput(
ReadShellCommandOutputResult::LongRunningCommandSnapshot { .. }
| ReadShellCommandOutputResult::Cancelled
| ReadShellCommandOutputResult::Error(_),
)
| AIAgentActionResultType::TransferShellCommandControlToUser(
TransferShellCommandControlToUserResult::Snapshot { .. }
| TransferShellCommandControlToUserResult::Cancelled
| TransferShellCommandControlToUserResult::Error(_),
)
| AIAgentActionResultType::RequestFileEdits(_)
| AIAgentActionResultType::ReadFiles(_)
| AIAgentActionResultType::UploadArtifact(_)
| AIAgentActionResultType::SearchCodebase(_)
| AIAgentActionResultType::Grep(_)
| AIAgentActionResultType::FileGlob(_)
| AIAgentActionResultType::FileGlobV2(_)
| AIAgentActionResultType::ReadMCPResource(_)
| AIAgentActionResultType::CallMCPTool(_)
| AIAgentActionResultType::ReadSkill(_)
| AIAgentActionResultType::SuggestNewConversation(_)
| AIAgentActionResultType::SuggestPrompt(_)
| AIAgentActionResultType::OpenCodeReview
| AIAgentActionResultType::InitProject
| AIAgentActionResultType::ReadDocuments(_)
| AIAgentActionResultType::EditDocuments(_)
| AIAgentActionResultType::CreateDocuments(_)
| AIAgentActionResultType::UseComputer(_)
| AIAgentActionResultType::InsertReviewComments(_)
| AIAgentActionResultType::RequestComputerUse(_)
| AIAgentActionResultType::FetchConversation(_)
| AIAgentActionResultType::StartAgent(_)
| AIAgentActionResultType::SendMessageToAgent(_)
| AIAgentActionResultType::AskUserQuestion(_)
| AIAgentActionResultType::RunAgents(_)
| AIAgentActionResultType::WaitForEvents(_) => None,
}
}
+61
View File
@@ -0,0 +1,61 @@
use std::time::Duration;
use galaxy_terminal::model::escape_sequences;
use super::{get_action_icon, get_action_loading_text, should_retain_task_output_message};
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionType, AIAgentOutputMessageType,
AIAgentPtyWriteMode, AIAgentText, ShellCommandDelay,
};
use crate::terminal::model::block::BlockId;
use crate::ui_components::icons::Icon;
#[test]
fn command_output_poll_has_visible_monitor_status() {
let action = AIAgentActionType::ReadShellCommandOutput {
block_id: BlockId::new(),
delay: Some(ShellCommandDelay::Duration(Duration::from_secs(2))),
};
assert_eq!(
get_action_loading_text(action.clone()).as_deref(),
Some("Checking the running command output…")
);
assert_eq!(get_action_icon(action), Some(Icon::ClockRefresh));
}
#[test]
fn typed_interrupt_has_distinct_visible_status() {
let action = AIAgentActionType::WriteToLongRunningShellCommand {
block_id: BlockId::new(),
input: vec![escape_sequences::C0::ETX].into(),
mode: AIAgentPtyWriteMode::Raw,
};
assert!(action.is_shell_command_interrupt());
assert_eq!(
get_action_loading_text(action.clone()).as_deref(),
Some("Interrupting the running command with Ctrl+C…")
);
assert_eq!(get_action_icon(action), Some(Icon::Stop));
}
#[test]
fn transcript_retains_prior_text_but_only_latest_tool_activity() {
let text = AIAgentOutputMessageType::Text(AIAgentText { sections: vec![] });
assert!(should_retain_task_output_message(&text, false));
let poll = AIAgentOutputMessageType::Action(AIAgentAction {
id: AIAgentActionId::from("poll".to_string()),
task_id: TaskId::new("cli-task".to_string()),
action: AIAgentActionType::ReadShellCommandOutput {
block_id: BlockId::new(),
delay: None,
},
requires_result: true,
tool_name: Some("read_shell_command_output".to_string()),
});
assert!(!should_retain_task_output_message(&poll, false));
assert!(should_retain_task_output_message(&poll, true));
}
@@ -631,31 +631,40 @@ pub(super) fn render_start_agent(
column.add_child(body);
}
}
if let Some(card_data) = child_conversation_card_data {
let navigation_card_handle = props
if let Some(panel_state) = props.state_handles.subagent_panel_states.get(action_id) {
column.add_child(
crate::ai::blocklist::agent_view::subagent_inline_panel::render_subagent_inline_panel(
panel_state,
action_id,
props.terminal_view_id,
app,
),
);
} else if let Some(card_data) = child_conversation_card_data {
if let Some(navigation_card_handle) = props
.state_handles
.orchestration_navigation_card_handles
.get(action_id)
.cloned()
.unwrap_or_else(|| {
log::error!(
"Missing orchestration navigation card handle for StartAgent action {:?}",
action_id
);
MouseStateHandle::default()
});
let status_icon = card_data
.status
.status_icon_and_color(theme, StatusColorStyle::Standard);
column.add_child(render_conversation_navigation_card_row(
&card_data.agent_name,
Some(&card_data.title),
Some(status_icon),
card_data.conversation_id,
navigation_card_handle,
true,
app,
));
{
let status_icon = card_data
.status
.status_icon_and_color(theme, StatusColorStyle::Standard);
column.add_child(render_conversation_navigation_card_row(
&card_data.agent_name,
Some(&card_data.title),
Some(status_icon),
card_data.conversation_id,
navigation_card_handle,
true,
app,
));
} else {
log::error!(
"Missing orchestration navigation card handle for StartAgent action {:?}",
action_id
);
}
}
return column
@@ -716,6 +725,16 @@ pub(super) fn render_start_agent(
column.add_child(body);
}
}
if let Some(panel_state) = props.state_handles.subagent_panel_states.get(action_id) {
column.add_child(
crate::ai::blocklist::agent_view::subagent_inline_panel::render_subagent_inline_panel(
panel_state,
action_id,
props.terminal_view_id,
app,
),
);
}
column
.finish()
@@ -49,6 +49,40 @@ fn child_conversation_card_data_for_success_result_returns_conversation_id_and_t
});
}
#[test]
fn child_conversation_card_data_resolves_tokenless_direct_provider_inline_output() {
App::test((), |mut app| async move {
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let conversation_id = history_model.update(&mut app, |history_model, ctx| {
let conversation_id =
history_model.start_new_conversation(EntityId::new(), false, false, false, ctx);
history_model
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.set_fallback_display_title("Generated child title".to_string());
conversation_id
});
let result = StartAgentResult::Success {
agent_id: format!(
"{conversation_id}\n\nAgent output:\nThe child completed successfully."
),
version: StartAgentVersion::V1,
};
let actual = app.read(|ctx| child_conversation_card_data_for_result(&result, ctx));
assert_eq!(
actual,
Some(ChildConversationCardData {
conversation_id,
agent_name: "Agent".to_string(),
title: "Generated child title".to_string(),
status: ConversationStatus::InProgress,
})
);
});
}
#[test]
fn start_agent_copy_uses_local_labels_for_local_children() {
let execution_mode = StartAgentExecutionMode::local_harness("claude-code".to_string());
@@ -127,7 +127,7 @@ fn read_skill_display_text_no_double_slash_when_skill_not_found_with_path_refere
fn read_skill_display_text_bundled_id_fallback_when_skill_not_found() {
let reference = SkillReference::BundledSkillId("create-pr".to_string());
let display = read_skill_display_text(None, &reference);
assert_eq!(display, "@warp-skill:create-pr");
assert_eq!(display, "@galaxy-skill:create-pr");
}
fn remote_location(host_id: &HostId, path: &str) -> LocalOrRemotePath {
+60 -5
View File
@@ -4,24 +4,79 @@ use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode};
use ai::agent::action_result::StartAgentVersion;
use ai::skills::SkillReference;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxyui::{App, SingletonEntity};
use galaxyui::{App, EntityId, SingletonEntity};
use settings::Setting;
use super::{
default_collapsible_state_for_orchestration_action,
default_collapsible_state_for_orchestration_message, received_message_collapsible_id,
user_avatar_info_for_conversation_creator, CollapsibleElementState, CollapsibleExpansionState,
UserAvatarInfo,
default_collapsible_state_for_orchestration_message, history_event_affects_conversation,
received_message_collapsible_id, user_avatar_info_for_conversation_creator,
CollapsibleElementState, CollapsibleExpansionState, UserAvatarInfo,
};
use crate::ai::agent::{AIAgentActionType, StartAgentExecutionMode};
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{AIAgentActionType, AIAgentExchangeId, StartAgentExecutionMode};
use crate::ai::blocklist::action_model::{
compose_run_agents_child_prompt, run_agents_to_start_agent_mode,
};
use crate::ai::blocklist::history_model::{BlocklistAIHistoryEvent, ConversationStatusUpdate};
use crate::auth::UserUid;
use crate::settings::{AISettings, OrchestrationMessageDisplayMode};
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspaces::user_profiles::{UserProfileWithUID, UserProfiles};
#[test]
fn child_panel_repaints_for_cross_surface_conversation_events() {
let child_conversation_id = AIConversationId::new();
let unrelated_conversation_id = AIConversationId::new();
let child_terminal_surface_id = EntityId::new();
let events = vec![
BlocklistAIHistoryEvent::AppendedExchange {
exchange_id: AIAgentExchangeId::new(),
task_id: TaskId::new("child-task".to_string()),
terminal_surface_id: child_terminal_surface_id,
conversation_id: child_conversation_id,
is_hidden: false,
response_stream_id: None,
},
BlocklistAIHistoryEvent::UpdatedStreamingExchange {
exchange_id: AIAgentExchangeId::new(),
terminal_surface_id: child_terminal_surface_id,
conversation_id: child_conversation_id,
is_hidden: false,
},
BlocklistAIHistoryEvent::UpdatedConversationStatus {
conversation_id: child_conversation_id,
terminal_surface_id: child_terminal_surface_id,
update: ConversationStatusUpdate::Changed {
prev_status: ConversationStatus::InProgress,
},
new_status: ConversationStatus::Success,
},
BlocklistAIHistoryEvent::UpdatedConversationTitle {
terminal_surface_id: Some(child_terminal_surface_id),
conversation_id: child_conversation_id,
title: "Child agent".to_string(),
},
BlocklistAIHistoryEvent::RemoveConversation {
terminal_surface_id: child_terminal_surface_id,
conversation_id: child_conversation_id,
run_id: None,
},
];
for event in &events {
assert!(history_event_affects_conversation(
event,
child_conversation_id
));
assert!(!history_event_affects_conversation(
event,
unrelated_conversation_id
));
}
}
#[test]
fn reasoning_auto_collapses_when_user_has_not_manually_toggled() {
App::test((), |mut app| async move {
+183 -40
View File
@@ -49,8 +49,8 @@ use crate::ai::agent::{
AIAgentContext, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus, AIIdentifiers,
CancellationOutcome, CancellationReason, DocumentContentAttachmentSource, EntrypointType,
FileContext, FinishedAIAgentOutput, PassiveSuggestionResultType, PassiveSuggestionTrigger,
PassiveSuggestionTriggerType, RenderableAIError, RequestCost, RequestMetadata, RunningCommand,
StaticQueryType, TransientNetworkErrorKind, UserQueryMode,
PassiveSuggestionTriggerType, RenderableAIError, RequestCommandOutputResult, RequestCost,
RequestMetadata, RunningCommand, StaticQueryType, TransientNetworkErrorKind, UserQueryMode,
};
use crate::ai::agent_events::AgentMessageEventMetadata;
#[cfg(not(target_family = "wasm"))]
@@ -263,6 +263,12 @@ pub struct RequestInput {
pub supported_tools_override: Option<Vec<ToolType>>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RunningCommandDetection {
Detect,
Skip,
}
impl RequestInput {
fn for_task(
inputs: Vec<AIAgentInput>,
@@ -808,7 +814,6 @@ impl BlocklistAIController {
false,
self.context_model.as_ref(ctx),
self.active_session.as_ref(ctx),
Some(conversation_id),
vec![],
ctx,
);
@@ -1134,7 +1139,7 @@ impl BlocklistAIController {
query,
conversation_id,
None,
false,
RunningCommandDetection::Detect,
HashMap::new(),
EntrypointType::AgentInitiated,
/*is_queued_prompt*/ false,
@@ -1143,6 +1148,70 @@ impl BlocklistAIController {
);
}
/// Sends one non-preemptive final assessment to a completed CLI-monitor task.
///
/// This deliberately bypasses `send_query`: command completion must not cancel
/// another conversation, drain unrelated action results, or replace a request
/// that is still delivering the command's final tool result.
pub fn send_command_completion_assessment(
&mut self,
conversation_id: AIConversationId,
task_id: TaskId,
query: String,
completed_command: RunningCommand,
ctx: &mut ModelContext<Self>,
) -> bool {
if self
.in_flight_response_streams
.has_active_stream_for_conversation(conversation_id, ctx)
|| self
.action_model
.as_ref(ctx)
.has_unfinished_actions_for_conversation(conversation_id)
{
return false;
}
let context = input_context_for_request(
false,
self.context_model.as_ref(ctx),
self.active_session.as_ref(ctx),
vec![],
ctx,
);
let request_input = RequestInput::for_task(
vec![AIAgentInput::UserQuery {
query,
context,
static_query_type: None,
referenced_attachments: HashMap::new(),
user_query_mode: UserQueryMode::Normal,
running_command: Some(completed_command),
intended_agent: None,
}],
task_id,
&self.active_session,
self.get_current_response_initiator(),
conversation_id,
self.terminal_surface_id,
ctx,
)
.with_supported_tools(vec![]);
self.send_request_input(
request_input,
Some(RequestMetadata {
is_autodetected_user_query: false,
entrypoint: EntrypointType::AgentInitiated,
is_auto_resume_after_error: false,
}),
/*can_attempt_resume_on_error*/ false,
/*is_queued_prompt*/ false,
ctx,
)
.is_ok()
}
/// Sends the given user query to the AI model.
pub fn send_user_query_in_conversation(
&mut self,
@@ -1155,7 +1224,7 @@ impl BlocklistAIController {
query,
conversation_id,
participant_id,
false, // skip_running_command_detection
RunningCommandDetection::Detect,
HashMap::new(),
EntrypointType::UserInitiated,
/*is_queued_prompt*/ false,
@@ -1180,7 +1249,7 @@ impl BlocklistAIController {
query,
conversation_id,
participant_id,
false, // skip_running_command_detection
RunningCommandDetection::Detect,
HashMap::new(),
EntrypointType::UserInitiated,
/*is_queued_prompt*/ true,
@@ -1202,7 +1271,7 @@ impl BlocklistAIController {
query,
conversation_id,
participant_id,
false, // skip_running_command_detection
RunningCommandDetection::Detect,
additional_attachments,
EntrypointType::UserInitiated,
/*is_queued_prompt*/ false,
@@ -1226,7 +1295,7 @@ impl BlocklistAIController {
query,
conversation_id,
participant_id,
true, // skip_running_command_detection
RunningCommandDetection::Skip,
HashMap::new(),
EntrypointType::UserInitiated,
/*is_queued_prompt*/ false,
@@ -1241,7 +1310,7 @@ impl BlocklistAIController {
query: String,
conversation_id: AIConversationId,
participant_id: Option<ParticipantId>,
skip_running_command_detection: bool,
running_command_detection: RunningCommandDetection,
additional_attachments: HashMap<String, AIAgentAttachment>,
entrypoint_type: EntrypointType,
is_queued_prompt: bool,
@@ -1274,6 +1343,14 @@ impl BlocklistAIController {
let (promoted_blocks, task_id, running_command) = {
let mut terminal_model = self.terminal_model.lock();
let running_command_opt = match running_command_detection {
RunningCommandDetection::Detect => {
get_running_command_for_conversation(&terminal_model, conversation_id)
}
RunningCommandDetection::Skip => None,
};
terminal_model
.block_list_mut()
.associate_blocks_with_conversation(context_block_ids.iter(), conversation_id);
@@ -1285,13 +1362,21 @@ impl BlocklistAIController {
.promote_blocks_to_attached_from_conversation(conversation_id);
let active_block = terminal_model.block_list().active_block();
let running_command_opt = if !skip_running_command_detection {
get_running_command(&terminal_model)
} else {
None
};
let existing_cli_task_id = active_block
.is_agent_monitoring()
.then(|| active_block.agent_interaction_metadata())
.flatten()
.filter(|metadata| metadata.conversation_id() == &conversation_id)
.and_then(|metadata| metadata.subagent_task_id().cloned());
let (task_id, running_command) = if let Some(running_command) = running_command_opt {
// Steering for a command that already has a monitor must remain on
// that monitor's task. Creating another optimistic CLI task here
// replaces the active task ID and strands the previous exchange.
// Keep attaching the current running-command snapshot so the
// direct provider continues selecting the CLI-agent model.
let (task_id, running_command) = if let Some(task_id) = existing_cli_task_id {
(task_id, running_command_opt)
} else if let Some(running_command) = running_command_opt {
let history_model = BlocklistAIHistoryModel::handle(ctx);
match history_model.update(ctx, |history_model, ctx| {
history_model.create_cli_subagent_task_for_conversation(
@@ -1307,14 +1392,6 @@ impl BlocklistAIController {
return;
}
}
} else if let Some(task_id) = active_block
.is_agent_monitoring()
.then(|| active_block.agent_interaction_metadata())
.flatten()
.filter(|metadata| metadata.conversation_id() == &conversation_id)
.and_then(|metadata| metadata.subagent_task_id().cloned())
{
(task_id, None)
} else {
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
let Some(conversation) = history_model.conversation(&conversation_id) else {
@@ -1498,7 +1575,6 @@ impl BlocklistAIController {
false,
self.context_model.as_ref(ctx),
self.active_session.as_ref(ctx),
None,
vec![],
ctx,
);
@@ -1533,7 +1609,6 @@ impl BlocklistAIController {
false,
self.context_model.as_ref(ctx),
self.active_session.as_ref(ctx),
conversation_id,
vec![],
ctx,
);
@@ -1599,13 +1674,63 @@ impl BlocklistAIController {
history.mark_active_conversation_id(conversation_id, self.terminal_surface_id, ctx);
});
let finished_results = self.action_model.update(ctx, |action_model, _| {
let mut finished_results = self.action_model.update(ctx, |action_model, _| {
action_model.drain_finished_action_results(conversation_id)
});
if finished_results.is_empty() {
return;
}
// Direct providers do not rely on a hosted orchestrator to create a CLI
// subtask after the initial long-running-command snapshot. Create that
// task locally at the action-result boundary, then route the snapshot
// and every resulting monitor response through it. This is non-preemptive:
// the original model stream has already finished and the action result is
// ready for its normal follow-up.
let initial_cli_block_id =
finished_results
.iter()
.find_map(|result| match &result.result {
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::LongRunningCommandSnapshot { block_id, .. },
) => Some(block_id.clone()),
_ => None,
});
if let Some(block_id) = initial_cli_block_id {
let cli_task_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model.create_cli_subagent_task_for_conversation(
block_id.clone(),
conversation_id,
self.terminal_surface_id,
ctx,
)
});
match cli_task_id {
Ok(cli_task_id) => {
for result in &mut finished_results {
if matches!(
&result.result,
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::LongRunningCommandSnapshot {
block_id: result_block_id,
..
}
) if result_block_id == &block_id
) {
result.task_id = cli_task_id.clone();
}
}
}
Err(error) => {
log::error!(
"Could not create direct-provider CLI monitor task for block \
{block_id:?}: {error:?}"
);
}
}
}
// Loop detection: record failures and check for repeated patterns
let loop_warning = self.check_and_record_loop_detection(conversation_id, &finished_results);
@@ -1624,7 +1749,6 @@ impl BlocklistAIController {
false,
self.context_model.as_ref(ctx),
self.active_session.as_ref(ctx),
Some(conversation_id),
vec![],
ctx,
);
@@ -2121,7 +2245,6 @@ impl BlocklistAIController {
false,
self.context_model.as_ref(ctx),
self.active_session.as_ref(ctx),
Some(conversation_id),
additional_context,
ctx,
);
@@ -2523,7 +2646,6 @@ impl BlocklistAIController {
false,
self.context_model.as_ref(ctx),
self.active_session.as_ref(ctx),
Some(conversation_id),
vec![],
ctx,
),
@@ -2575,7 +2697,6 @@ impl BlocklistAIController {
false,
self.context_model.as_ref(ctx),
self.active_session.as_ref(ctx),
None,
vec![],
ctx,
),
@@ -4128,6 +4249,7 @@ impl BlocklistAIController {
.iter()
.map(|p| match p {
ContentPart::Text(t) => t.clone(),
ContentPart::Image { .. } => "[Image attachment]".to_string(),
ContentPart::ToolUse { name, input, .. } => {
format!("[Tool: {}] {}", name, input)
}
@@ -4236,6 +4358,7 @@ impl BlocklistAIController {
.iter()
.map(|p| match p {
ContentPart::Text(t) => (t.len() / 4) as u32,
ContentPart::Image { .. } => 1_600,
ContentPart::ToolUse { input, .. } => {
(input.to_string().len() / 4) as u32
}
@@ -4363,14 +4486,8 @@ fn input_for_query(
}
}
let context = input_context_for_request(
true,
context_model,
active_session,
Some(conversation_id),
image_context,
app,
);
let context =
input_context_for_request(true, context_model, active_session, image_context, app);
let intended_agent = BlocklistAIHistoryModel::as_ref(app)
.conversation(&conversation_id)
.and_then(|c| c.get_task(task_id))
@@ -4465,8 +4582,34 @@ fn get_running_command(terminal_model: &TerminalModel) -> Option<RunningCommand>
if !active_block.is_active_and_long_running() || active_block.is_agent_monitoring() {
return None;
}
Some(running_command_snapshot(terminal_model))
}
/// Returns the active command when it is unclaimed or already monitored by the
/// requested conversation. This keeps steering on the CLI task and preserves
/// the terminal-specialized model/tool set for every subsequent user turn.
fn get_running_command_for_conversation(
terminal_model: &TerminalModel,
conversation_id: AIConversationId,
) -> Option<RunningCommand> {
let active_block = terminal_model.block_list().active_block();
if !active_block.is_active_and_long_running() {
return None;
}
if active_block.is_agent_monitoring()
&& active_block
.agent_interaction_metadata()
.is_none_or(|metadata| metadata.conversation_id() != &conversation_id)
{
return None;
}
Some(running_command_snapshot(terminal_model))
}
fn running_command_snapshot(terminal_model: &TerminalModel) -> RunningCommand {
let active_block = terminal_model.block_list().active_block();
let is_alt_screen_active = terminal_model.is_alt_screen_active();
Some(RunningCommand {
RunningCommand {
block_id: active_block.id().clone(),
command: active_block.command_to_string(),
grid_contents: if is_alt_screen_active {
@@ -4486,7 +4629,7 @@ fn get_running_command(terminal_model: &TerminalModel) -> Option<RunningCommand>
cursor: CURSOR_MARKER.to_owned(),
requested_command_id: active_block.requested_command_action_id().cloned(),
is_alt_screen_active,
})
}
}
#[cfg(test)]
@@ -9,7 +9,6 @@ use galaxyui::{AppContext, SingletonEntity};
use lazy_static::lazy_static;
use regex::Regex;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{
AIAgentAttachment, AIAgentContext, DocumentContentAttachmentSource, DriveObjectPayload,
};
@@ -17,7 +16,7 @@ use crate::ai::block_context::BlockContext;
use crate::ai::blocklist::{BlocklistAIContextModel, SessionContext};
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel};
use crate::ai::facts::CloudAIFactModel;
use crate::ai::skills::list_skills_if_changed;
use crate::ai::skills::list_skills_for_request;
use crate::cloud_object::model::generic_string_model::{CloudStringObject, GenericStringObjectId};
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::{
@@ -48,7 +47,6 @@ pub(super) fn input_context_for_request(
is_user_query: bool,
context_model: &BlocklistAIContextModel,
active_session: &ActiveSession,
conversation_id: Option<AIConversationId>,
additional_context: Vec<AIAgentContext>,
app: &AppContext,
) -> Arc<[AIAgentContext]> {
@@ -80,16 +78,12 @@ pub(super) fn input_context_for_request(
if FeatureFlag::ListSkills.is_enabled() {
let path_origin = SessionContext::from_session(active_session, app).skill_path_origin();
let skills = list_skills_if_changed(
let skills = list_skills_for_request(
current_working_directory_location.as_ref(),
&path_origin,
conversation_id,
app,
);
if let Some(skills) = skills {
context.push(AIAgentContext::Skills { skills });
}
context.push(AIAgentContext::Skills { skills });
}
context.extend(additional_context);
@@ -20,7 +20,7 @@ use crate::ai::llms::LLMPreferences;
use crate::ai::openai::client::OpenAIClientConfig;
use crate::ai::provider::ProviderConfig;
use crate::network::NetworkStatus;
use crate::server::server_api::{AIApiError, ServerApiProvider};
use crate::server::server_api::AIApiError;
use crate::{report_error, send_telemetry_from_ctx, AISettings};
/// Maximum number of times a single MAA request is re-sent before the failure is
@@ -159,6 +159,7 @@ impl ResponseStream {
id,
params: api::RequestParams::new_for_test(),
retry_count: 0,
coding_model_fallback_attempted: false,
start_time: Local::now(),
time_to_latest_event: TimeDelta::seconds(0),
cancellation_tx: Some(cancellation_tx),
@@ -233,17 +234,10 @@ impl ResponseStream {
let request_id = Uuid::new_v4();
let provider_config = Self::resolve_provider_config(params.model.as_str(), ctx);
let server_api = ServerApiProvider::as_ref(ctx).get_ai_client().clone();
let params_clone = params.clone();
let _ = ctx.spawn(
async move {
generate_multi_agent_output(
provider_config,
server_api,
params_clone,
cancellation_rx,
)
.await
generate_multi_agent_output(provider_config, params_clone, cancellation_rx).await
},
move |me, stream, ctx| {
me.handle_response_stream_result(request_id, stream, ctx);
@@ -323,18 +317,17 @@ impl ResponseStream {
let request_id = Uuid::new_v4();
self.current_request_id = Some(request_id);
let mut params = self.params.clone();
let params = self.params.clone();
let provider_config = Self::resolve_provider_config(params.model.as_str(), ctx);
let server_api = ServerApiProvider::as_ref(ctx).get_ai_client().clone();
let _ = ctx.spawn(
async move {
generate_multi_agent_output(provider_config, server_api, params, cancellation_rx)
.await
},
move |me, stream, ctx| {
me.handle_response_stream_result(request_id, stream, ctx);
},
);
let _ =
ctx.spawn(
async move {
generate_multi_agent_output(provider_config, params, cancellation_rx).await
},
move |me, stream, ctx| {
me.handle_response_stream_result(request_id, stream, ctx);
},
);
}
fn should_fallback_to_coding_model(
@@ -503,7 +496,7 @@ impl ResponseStream {
self.original_error = Some(format!("{e:?}"));
}
if self.should_fallback_to_coding_model(&e) {
if self.should_fallback_to_coding_model(e) {
log::warn!(
"Thinking model rate-limited; retrying with the profile coding model"
);
@@ -104,7 +104,6 @@ impl SlashCommandRequest {
is_invoke_skill,
controller.context_model.as_ref(ctx),
controller.active_session.as_ref(ctx),
conversation_id,
image_context,
ctx,
);
+13
View File
@@ -1206,6 +1206,19 @@ impl BlocklistAIHistoryModel {
Ok(conversation.create_optimistic_cli_subagent_task(&block_id, terminal_surface_id, ctx))
}
pub fn deactivate_cli_subagent_task_for_conversation(
&mut self,
block_id: &BlockId,
conversation_id: AIConversationId,
) -> Result<(), UpdateHistoryError> {
let conversation = self
.conversations_by_id
.get_mut(&conversation_id)
.ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?;
conversation.deactivate_optimistic_cli_subagent_task(block_id);
Ok(())
}
pub fn update_conversation_status(
&mut self,
terminal_surface_id: EntityId,
+160
View File
@@ -37,6 +37,7 @@ use crate::persistence::model::{
use crate::persistence::ModelEvent;
use crate::server::ids::ServerId;
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
use crate::terminal::model::block::BlockId;
use crate::terminal::model::session::SessionId;
use crate::test_util::ai_agent_tasks::{create_api_task, create_message};
use crate::test_util::settings::{
@@ -66,6 +67,165 @@ fn create_persisted_query(
}
}
#[test]
fn repeated_command_steering_reuses_the_active_cli_subtask() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let block_id = BlockId::new();
let (first_task_id, second_task_id) = history_model.update(&mut app, |model, ctx| {
let conversation_id =
model.start_new_conversation(terminal_view_id, false, false, false, ctx);
let first_task_id = model
.create_cli_subagent_task_for_conversation(
block_id.clone(),
conversation_id,
terminal_view_id,
ctx,
)
.expect("initial CLI subtask should be created");
let second_task_id = model
.create_cli_subagent_task_for_conversation(
block_id,
conversation_id,
terminal_view_id,
ctx,
)
.expect("steering should reuse the active CLI subtask");
(first_task_id, second_task_id)
});
assert_eq!(first_task_id, second_task_id);
});
}
#[test]
fn deactivating_cli_subtask_clears_activity_without_deleting_task() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let block_id = BlockId::new();
let other_block_id = BlockId::new();
history_model.update(&mut app, |model, ctx| {
let conversation_id =
model.start_new_conversation(terminal_view_id, false, false, false, ctx);
let task_id = model
.create_cli_subagent_task_for_conversation(
block_id.clone(),
conversation_id,
terminal_view_id,
ctx,
)
.expect("CLI subtask should be created");
let conversation = model
.conversation(&conversation_id)
.expect("conversation should exist");
assert!(conversation.has_active_subagent());
model
.deactivate_cli_subagent_task_for_conversation(&other_block_id, conversation_id)
.expect("a block mismatch should be a safe no-op");
let conversation = model
.conversation(&conversation_id)
.expect("conversation should still exist");
assert!(conversation.has_active_subagent());
model
.deactivate_cli_subagent_task_for_conversation(&block_id, conversation_id)
.expect("matching CLI subtask should deactivate");
let conversation = model
.conversation(&conversation_id)
.expect("conversation should still exist");
assert!(!conversation.has_active_subagent());
assert!(
conversation.get_task(&task_id).is_some(),
"deactivation must preserve the direct-provider task"
);
});
});
}
#[test]
fn monitoring_a_different_block_preserves_completed_cli_task_history() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let first_block_id = BlockId::new();
let second_block_id = BlockId::new();
history_model.update(&mut app, |model, ctx| {
let conversation_id =
model.start_new_conversation(terminal_view_id, false, false, false, ctx);
let first_task_id = model
.create_cli_subagent_task_for_conversation(
first_block_id,
conversation_id,
terminal_view_id,
ctx,
)
.expect("first CLI subtask should be created");
model
.update_conversation_for_new_request_input(
RequestInput {
conversation_id,
input_messages: HashMap::from([(first_task_id.clone(), vec![])]),
working_directory: None,
model_id: LLMId::from("test-model"),
coding_model_id: LLMId::from("test-coding-model"),
cli_agent_model_id: LLMId::from("test-cli-agent-model"),
computer_use_model_id: LLMId::from("test-computer-use-model"),
shared_session_response_initiator: None,
request_start_ts: Local::now(),
supported_tools_override: None,
},
crate::ai::blocklist::ResponseStreamId::new_for_test(),
terminal_view_id,
ctx,
)
.expect("first CLI subtask exchange should be recorded");
let second_task_id = model
.create_cli_subagent_task_for_conversation(
second_block_id.clone(),
conversation_id,
terminal_view_id,
ctx,
)
.expect("second CLI subtask should be created");
assert_ne!(first_task_id, second_task_id);
let conversation = model
.conversation(&conversation_id)
.expect("conversation should exist");
assert_eq!(
conversation
.get_task(&first_task_id)
.expect("first task should be retained")
.exchanges_len(),
1
);
assert!(conversation.get_task(&second_task_id).is_some());
assert!(conversation.has_active_subagent());
model
.deactivate_cli_subagent_task_for_conversation(&second_block_id, conversation_id)
.expect("second CLI subtask should deactivate");
let conversation = model
.conversation(&conversation_id)
.expect("conversation should still exist");
assert!(!conversation.has_active_subagent());
assert!(conversation.get_task(&first_task_id).is_some());
assert!(conversation.get_task(&second_task_id).is_some());
});
});
}
fn create_user_query_message(
id: &str,
task_id: &str,
@@ -1,23 +1,8 @@
use galaxy_core::ui::appearance::Appearance;
use warpui::elements::Empty;
use warpui::elements::{Element, Empty};
use warpui::platform::WindowStyle;
use warpui::{
AddSingletonModel, App, AppContext, Element, Entity, TypedActionView, View, WindowId,
};
use warpui::{App, AppContext, Entity, TypedActionView, View};
use super::CreateEnvironmentModal;
use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier;
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::CloudModel;
use crate::network::NetworkStatus;
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::server_api::ServerApiProvider;
use crate::server::sync_queue::SyncQueue;
use crate::settings::PrivacySettings;
use crate::settings_view::keybindings::KeybindingChangedNotifier;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspaces::team_tester::TeamTesterStatus;
use crate::workspaces::user_workspaces::UserWorkspaces;
#[derive(Default)]
struct TestRootView;
@@ -26,9 +11,13 @@ impl Entity for TestRootView {
type Event = ();
}
impl TypedActionView for TestRootView {
type Action = ();
}
impl View for TestRootView {
fn ui_name() -> &'static str {
"TestRootView"
"CreateEnvironmentModalTestRoot"
}
fn render(&self, _: &AppContext) -> Box<dyn Element> {
@@ -36,49 +25,21 @@ impl View for TestRootView {
}
}
impl TypedActionView for TestRootView {
type Action = ();
}
fn create_test_window(app: &mut App) -> WindowId {
let (window_id, _root_view) = app.add_window(WindowStyle::NotStealFocus, |_| TestRootView);
window_id
}
fn init_create_environment_modal_test_models(app: &mut App) {
initialize_settings_for_tests(app);
app.add_singleton_model(|_ctx| ServerApiProvider::new_for_test());
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(|_| Appearance::mock());
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(UserWorkspaces::default_mock);
app.add_singleton_model(|_| NetworkStatus::new());
app.add_singleton_model(PrivacySettings::mock);
app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(SyncQueue::mock);
app.add_singleton_model(UpdateManager::mock);
app.add_singleton_model(|_| KeybindingChangedNotifier::new());
app.add_singleton_model(|_| GitHubAuthNotifier::new());
}
#[test]
fn test_create_environment_modal_uses_orchestration_form_configuration() {
fn create_environment_modal_visibility_can_be_toggled() {
App::test((), |mut app| async move {
init_create_environment_modal_test_models(&mut app);
let window_id = create_test_window(&mut app);
let (window_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestRootView);
let modal =
app.update(|ctx| ctx.add_typed_action_view(window_id, CreateEnvironmentModal::new));
app.update(|ctx| {
let view_handle = ctx.add_typed_action_view(window_id, CreateEnvironmentModal::new);
let modal = view_handle.as_ref(ctx);
modal.update(&mut app, |modal, ctx| {
assert!(!modal.is_visible());
assert!(
modal
.handoff_modal
.as_ref(ctx)
.uses_orchestration_form_configuration_for_test(ctx),
"Expected CreateEnvironmentModal to construct the handoff modal with orchestration form configuration"
);
modal.show(ctx);
assert!(modal.is_visible());
modal.hide(ctx);
assert!(!modal.is_visible());
});
})
});
}
@@ -45,6 +45,7 @@ impl View for ContextWindowView {
.iter()
.map(|p| match p {
ContentPart::Text(t) => t.len(),
ContentPart::Image { .. } => 6_400,
ContentPart::ToolUse { input, .. } => input.to_string().len(),
ContentPart::ToolResult { content, .. } => content.len(),
})
@@ -111,6 +112,14 @@ impl View for ContextWindowView {
ContentPart::Text(t) => {
out.push_str(&format!("[Part {} Text] {}\n", pi, t));
}
ContentPart::Image { data, mime_type } => {
out.push_str(&format!(
"[Part {} Image] mime_type={}, bytes={}\n",
pi,
mime_type,
data.len()
));
}
ContentPart::ToolUse {
name,
tool_use_id,