Skip to content

Commit c3df6eb

Browse files
authored
Fix cloud mode for 3rd party harnesses (warpdotdev#10422)
## Description Fixes the third-party Cloud Mode harness transition so the live harness block is shown and scoped to the active agent-view conversation when the harness command starts. Previously, third-party harness startup teardown reused the generic startup-command flag flip, which unhid/unmarked the active block. In runs with earlier setup blocks, the active block may not be the harness block by the time the event is handled, leaving the actual Claude/Gemini/OpenCode/Codex block classified as setup output and hidden from the agent view. This PR: - Carries the started harness `BlockId` on `AmbientAgentViewModelEvent::HarnessCommandStarted`. - Clears startup-command state on that specific block and attaches it to the active conversation. - Keeps earlier setup blocks hidden/collapsible instead of exposing them as normal terminal blocks. - Updates event matches for the new event payload. - Adds a block-list regression test covering the target-block unhide/attach behavior. ## Linked Issue N/A - [ ] The linked issue is labeled `ready-to-spec` or `ready-to-implement`. - [ ] Where appropriate, screenshots or a short video of the implementation are included below (especially for user-visible or UI changes). ## Testing - Added `test_finish_startup_commands_at_block_attaches_and_unhides_only_target_block` in `app/src/terminal/model/blocks_tests.rs`. - Not run locally yet. - [ ] I have manually tested my changes locally with `./script/run` ### Screenshots / Videos Not included yet; this is a user-visible Cloud Mode behavior change and should be manually validated with a third-party harness run before review/merge. ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mod Co-Authored-By: Oz [oz-agent@warp.dev](mailto:oz-agent@warp.dev)
1 parent 59e802e commit c3df6eb

6 files changed

Lines changed: 125 additions & 22 deletions

File tree

app/src/ai/blocklist/block.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1224,7 +1224,7 @@ impl AIBlock {
12241224
| AmbientAgentViewModelEvent::Failed { .. }
12251225
| AmbientAgentViewModelEvent::NeedsGithubAuth
12261226
| AmbientAgentViewModelEvent::Cancelled
1227-
| AmbientAgentViewModelEvent::HarnessCommandStarted => ctx.notify(),
1227+
| AmbientAgentViewModelEvent::HarnessCommandStarted { .. } => ctx.notify(),
12281228
_ => {}
12291229
});
12301230
}

app/src/terminal/model/blocks.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1342,6 +1342,27 @@ impl BlockList {
13421342
}
13431343
}
13441344

1345+
pub fn finish_oz_environment_startup_commands_at_block(
1346+
&mut self,
1347+
block_id: &BlockId,
1348+
conversation_id: Option<AIConversationId>,
1349+
) {
1350+
self.is_executing_oz_environment_startup_commands = false;
1351+
if let Some(block_index) = self.block_index_for_id(block_id) {
1352+
for block in self.blocks.iter_mut().skip(block_index.0) {
1353+
if block.is_background() || block.is_static() {
1354+
continue;
1355+
}
1356+
block.unhide();
1357+
block.set_is_oz_environment_startup_command(false);
1358+
if let Some(conversation_id) = conversation_id {
1359+
block.add_attached_conversation_id(conversation_id);
1360+
}
1361+
}
1362+
}
1363+
self.update_blocks_and_sumtree(None, None, |_| {}, |_| {});
1364+
}
1365+
13451366
/// Resets the internal block object's index to its actual index in the block list.
13461367
/// This does not move the block, but is necessary to be called after a move (inserting or removing blocks).
13471368
/// Also updates the block ID to block index mapping.

app/src/terminal/model/blocks_tests.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1565,6 +1565,74 @@ fn test_agent_origin_block_can_be_attached_to_other_conversation() {
15651565
assert!(user_block.is_empty(block_list.agent_view_state()));
15661566
}
15671567

1568+
#[test]
1569+
fn test_finish_startup_commands_at_block_attaches_and_unhides_command_blocks_since_target_block() {
1570+
let _agent_view_flag = FeatureFlag::AgentView.override_enabled(true);
1571+
let mut block_list =
1572+
new_bootstrapped_block_list(None, None, ChannelEventListener::new_for_test());
1573+
block_list.set_is_executing_oz_environment_startup_commands(true);
1574+
1575+
let setup_block_index = insert_block(&mut block_list, "setup", "output");
1576+
let harness_block_index = insert_block(&mut block_list, "claude", "output");
1577+
let followup_block_index = insert_block(&mut block_list, "pwd", "output");
1578+
let setup_block_id = block_list.block_at(setup_block_index).unwrap().id().clone();
1579+
let harness_block_id = block_list
1580+
.block_at(harness_block_index)
1581+
.unwrap()
1582+
.id()
1583+
.clone();
1584+
let followup_block_id = block_list
1585+
.block_at(followup_block_index)
1586+
.unwrap()
1587+
.id()
1588+
.clone();
1589+
let conversation_id = AIConversationId::new();
1590+
1591+
block_list.set_agent_view_state(AgentViewState::Active {
1592+
conversation_id,
1593+
origin: AgentViewEntryOrigin::ThirdPartyCloudAgent,
1594+
display_mode: AgentViewDisplayMode::FullScreen,
1595+
original_conversation_length: 0,
1596+
});
1597+
1598+
block_list
1599+
.finish_oz_environment_startup_commands_at_block(&harness_block_id, Some(conversation_id));
1600+
1601+
assert!(!block_list.is_executing_oz_environment_startup_commands());
1602+
1603+
for block_id in [&harness_block_id, &followup_block_id] {
1604+
let block = block_list
1605+
.block_with_id(block_id)
1606+
.expect("block should still exist");
1607+
assert!(!block.is_hidden());
1608+
assert!(!block.is_oz_environment_startup_command());
1609+
assert!(!block.should_hide_block(block_list.agent_view_state()));
1610+
match block.agent_view_visibility() {
1611+
AgentViewVisibility::Terminal {
1612+
pending_conversation_ids,
1613+
conversation_ids,
1614+
} => {
1615+
assert!(pending_conversation_ids.is_empty());
1616+
assert!(conversation_ids.contains(&conversation_id));
1617+
}
1618+
AgentViewVisibility::Agent {
1619+
origin_conversation_id,
1620+
pending_other_conversation_ids,
1621+
other_conversation_ids,
1622+
} => panic!(
1623+
"expected terminal visibility, got agent visibility: {origin_conversation_id:?}, {pending_other_conversation_ids:?}, {other_conversation_ids:?}"
1624+
),
1625+
}
1626+
}
1627+
1628+
let setup_block = block_list
1629+
.block_with_id(&setup_block_id)
1630+
.expect("setup block should still exist");
1631+
assert!(setup_block.is_hidden());
1632+
assert!(setup_block.is_oz_environment_startup_command());
1633+
assert!(setup_block.should_hide_block(block_list.agent_view_state()));
1634+
}
1635+
15681636
#[test]
15691637
pub fn test_seek_up_to_next_grid() {
15701638
let mut block_list =

app/src/terminal/view/ambient_agent/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ pub fn create_cloud_mode_view(
116116
| AmbientAgentViewModelEvent::HarnessSelected
117117
| AmbientAgentViewModelEvent::HostSelected
118118
| AmbientAgentViewModelEvent::HarnessModelSelected
119-
| AmbientAgentViewModelEvent::HarnessCommandStarted
119+
| AmbientAgentViewModelEvent::HarnessCommandStarted { .. }
120120
| AmbientAgentViewModelEvent::PendingHandoffChanged
121121
| AmbientAgentViewModelEvent::HandoffSnapshotUploadFailed { .. }
122122
| AmbientAgentViewModelEvent::UpdatedSetupCommandVisibility => {}

app/src/terminal/view/ambient_agent/model.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use session_sharing_protocol::common::SessionId;
55
use warp_cli::agent::Harness;
66
use warp_core::features::FeatureFlag;
77
use warp_core::send_telemetry_from_ctx;
8+
use warp_terminal::model::BlockId;
89
use warpui::r#async::{SpawnedFutureHandle, Timer};
910
use warpui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity};
1011

@@ -550,7 +551,11 @@ impl AmbientAgentViewModel {
550551

551552
/// Marks the harness CLI as started and emits `HarnessCommandStarted`.
552553
/// Idempotent: subsequent calls after the first are no-ops and do not re-emit.
553-
pub(super) fn mark_harness_command_started(&mut self, ctx: &mut ModelContext<Self>) {
554+
pub(super) fn mark_harness_command_started(
555+
&mut self,
556+
block_id: BlockId,
557+
ctx: &mut ModelContext<Self>,
558+
) {
554559
debug_assert!(
555560
self.harness != Harness::Oz,
556561
"harness_command_started is only meaningful for non-oz runs"
@@ -559,7 +564,7 @@ impl AmbientAgentViewModel {
559564
return;
560565
}
561566
self.harness_command_started = true;
562-
ctx.emit(AmbientAgentViewModelEvent::HarnessCommandStarted);
567+
ctx.emit(AmbientAgentViewModelEvent::HarnessCommandStarted { block_id });
563568
}
564569

565570
/// Sets the selected environment ID.
@@ -1413,7 +1418,9 @@ pub enum AmbientAgentViewModelEvent {
14131418
/// The harness CLI (for non-oz runs) has started executing in the shared session.
14141419
/// Fires once per run and signals the transition out of the pre-first-exchange phase
14151420
/// for claude / gemini / other third-party harnesses.
1416-
HarnessCommandStarted,
1421+
HarnessCommandStarted {
1422+
block_id: BlockId,
1423+
},
14171424
/// The pane's `pending_handoff` was updated.
14181425
PendingHandoffChanged,
14191426
/// The async handoff snapshot upload failed. The input layer subscribes to

app/src/terminal/view/ambient_agent/view_impl.rs

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ impl TerminalView {
111111
AmbientAgentViewModelEvent::Failed { .. }
112112
| AmbientAgentViewModelEvent::NeedsGithubAuth
113113
| AmbientAgentViewModelEvent::Cancelled
114-
| AmbientAgentViewModelEvent::HarnessCommandStarted
114+
| AmbientAgentViewModelEvent::HarnessCommandStarted { .. }
115115
) {
116116
self.remove_pending_user_query_block(ctx);
117117
}
@@ -297,11 +297,14 @@ impl TerminalView {
297297
}
298298
AmbientAgentViewModelEvent::HostSelected => {}
299299
AmbientAgentViewModelEvent::HarnessModelSelected => {}
300-
AmbientAgentViewModelEvent::HarnessCommandStarted => {
301-
// Stop classifying new blocks as environment setup commands, mirroring the
302-
// Oz path in the `AppendedExchange` handler. Flipping this flag to `false`
303-
// also un-hides and un-marks the active block so it renders like a normal
304-
// CLI-agent session.
300+
AmbientAgentViewModelEvent::HarnessCommandStarted { block_id } => {
301+
// Stop classifying the harness block as an environment setup command, mirroring
302+
// the Oz path in the `AppendedExchange` handler.
303+
let conversation_id = self
304+
.agent_view_controller
305+
.as_ref(ctx)
306+
.agent_view_state()
307+
.active_conversation_id();
305308
{
306309
let mut model = self.model.lock();
307310
if model
@@ -310,7 +313,10 @@ impl TerminalView {
310313
{
311314
model
312315
.block_list_mut()
313-
.set_is_executing_oz_environment_startup_commands(false);
316+
.finish_oz_environment_startup_commands_at_block(
317+
block_id,
318+
conversation_id,
319+
);
314320
}
315321
}
316322
// Collapse the setup-commands summary, matching the oz first-exchange behavior.
@@ -368,10 +374,10 @@ impl TerminalView {
368374
if ambient_agent_view_model
369375
.as_ref(ctx)
370376
.is_third_party_harness()
371-
&& self.active_block_matches_run_harness(ctx)
377+
&& self.block_matches_run_harness(block_id, ctx)
372378
{
373379
ambient_agent_view_model.update(ctx, |model, ctx| {
374-
model.mark_harness_command_started(ctx);
380+
model.mark_harness_command_started(block_id.clone(), ctx);
375381
});
376382
return;
377383
}
@@ -511,19 +517,20 @@ impl TerminalView {
511517
}
512518
}
513519

514-
/// Returns `true` when the active block's command is the CLI for the run's configured
520+
/// Returns `true` when the block's command is the CLI for the run's configured
515521
/// non-oz harness (e.g. `claude …` for [`Harness::Claude`]).
516522
/// Used to detect the harness-start transition at `AfterBlockStarted` time. Unlike
517523
/// `detect_cli_agent_from_model`, this does NOT gate on `is_active_and_long_running` —
518524
/// we want to classify the block as the harness session as soon as it starts, before the
519525
/// long-running timer would otherwise elapse.
520-
fn active_block_matches_run_harness(&self, ctx: &AppContext) -> bool {
521-
let command = self
522-
.model
523-
.lock()
524-
.block_list()
525-
.active_block()
526-
.command_with_secrets_obfuscated(false);
526+
fn block_matches_run_harness(&self, block_id: &BlockId, ctx: &AppContext) -> bool {
527+
let command = {
528+
let model = self.model.lock();
529+
let Some(block) = model.block_list().block_with_id(block_id) else {
530+
return false;
531+
};
532+
block.command_with_secrets_obfuscated(false)
533+
};
527534
let Some(cli_agent) = CLIAgent::detect(&command, None, None, ctx) else {
528535
return false;
529536
};

0 commit comments

Comments
 (0)