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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user