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 {
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user