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 {