30 KiB
REMOTE-1454: Tech Spec — Cloud Mode setup UI for non-oz harnesses
Problem
The CloudModeSetupV2 setup UI only transitions out of the "setup commands" phase when an Oz AppendedExchange event fires. Non-oz harness runs (claude, gemini) never produce an Oz exchange — the sandboxed oz agent run --harness=<name> invokes the harness CLI (e.g. claude --session-id … < /tmp/oz_prompt) as a shell command in the shared session, and that command is permanently flagged as an environment startup command. See specs/REMOTE-1454/PRODUCT.md for the desired UX.
Relevant code
crates/warp_features/src/lib.rs:757,826—FeatureFlag::AgentHarness,FeatureFlag::CloudModeSetupV2.app/src/terminal/view/ambient_agent/model.rs:79-151,240-250,384-418,442-488,726-727,886-899—AmbientAgentViewModel(status,harnessfield,spawn_agent,enter_viewing_existing_session,AmbientAgentViewModelEvent,DispatchedAgentemission).app/src/terminal/view/ambient_agent/view_impl.rs:91-228,230-300—handle_ambient_agent_event(insertsCloudModeInitialUserQueryonDispatchedAgent),maybe_insert_setup_command_blocks(gated onis_cloud_agent_pre_first_exchange).app/src/terminal/view/ambient_agent/mod.rs:81-114—is_cloud_agent_pre_first_exchangehelper (checksexchange_count() == 0).app/src/terminal/view/ambient_agent/block/query.rs—CloudModeInitialUserQueryrich content (top-of-conversation prompt block).app/src/terminal/view/ambient_agent/block/setup_command_text.rs:122-157— summary row copy keys offis_cloud_agent_pre_first_exchange.app/src/terminal/view/ambient_agent/block/setup_command.rs:56-91— per-command row, listens forUpdatedSetupCommandVisibilityandBlockCompleted.app/src/terminal/model/terminal_model.rs:1213-1241—new_for_cloud_mode_shared_session_viewersetsis_executing_oz_environment_startup_commands = truewhenCloudModeSetupV2is enabled.app/src/terminal/model/blocks.rs:1284-1302,2486-2489— block-list flag and per-blockis_oz_environment_startup_command.app/src/terminal/model/block.rs:1462-1468—Block::is_oz_environment_startup_command/ setter.app/src/terminal/view.rs:5042-5053— existingAppendedExchangehandler that flips the block-list flag off (Oz path).app/src/terminal/view.rs:10413—ModelEvent::AfterBlockStartedbranch callsmaybe_insert_setup_command_blocks(block_id, ctx).app/src/terminal/view.rs:6609-6634,6785-6793,6841-6863— uses ofis_cloud_agent_pre_first_exchange(tombstone, input visibility, input hide logic).app/src/ai/blocklist/block/pending_user_query_block.rs—PendingUserQueryBlockview, takesinterruptible: bool, always renders a close button plus optional "Send now".app/src/terminal/view/pending_user_query.rs—TerminalView::insert_pending_user_query_block,remove_pending_user_query_block,send_user_query_after_next_conversation_finished. Tracked byTerminalView::pending_user_query_view_id.app/src/terminal/view.rs:2789-2796,4160—TerminalView::pending_user_query_view_id,queued_prompt_callbackfields.app/src/terminal/view.rs:3237-3246,4648-4650— existing removal paths for the pending-query block on agent-view/conversation transitions.app/src/ai/ambient_agents/task.rs:26-122,212-238—AgentConfigSnapshot.harness: Option<HarnessConfig>,AmbientAgentTask.agent_config_snapshot.app/src/terminal/cli_agent.rs:107-260—CLIAgentenum (Claude,Gemini, …) withcommand_prefix,CLIAgent::detect.app/src/terminal/view/use_agent_footer/mod.rs:354-387—TerminalView::detect_cli_agent_from_model(returns theCLIAgentfor the active long-running block using alias-aware detection).app/src/terminal/shared_session/viewer/terminal_manager.rs:871-896,1186-1209—is_cloud_agent_pre_first_exchangeuse in remote input / input-mode gating.app/src/terminal/view/shared_session/view_impl.rs:1700-1721— viewer-driven sizing helpers:restore_pty_to_sharer_size,resize_from_viewer_report, and the newforce_report_viewer_terminal_sizeentry point used at harness start.app/src/terminal/view/ambient_agent/block/setup_command.rs:176-197— per-command row, currently renders viawith_agent_output_item_spacing; needs a third-party-harness branch that uses*terminal::view::PADDING_LEFThorizontal margins.app/src/terminal/view/ambient_agent/block/setup_command_text.rs:184-206— summary row, same story as the per-command row.app/src/ai/blocklist/block/view_impl.rs:1264-1312—CONTENT_HORIZONTAL_PADDING,CONTENT_ITEM_VERTICAL_MARGIN, and theWithContentItemSpacing::with_agent_output_item_spacinghelper used by the oz setup UI.app/src/terminal/view.rs:746-752—PADDING_LEFT(20px, or 16px behindLessHorizontalTerminalPadding) — the horizontal padding used by regular terminal command blocks.app/src/pane_group/mod.rs:3763-3792,5682-5692— CLI-agent conversation restoration (FeatureFlag::AgentHarnessgate for replay).warp_cli::agent::Harness(crates/warp_cli/src/agent.rs:120-131) —Oz,Claude,Gemini.
Current state
AmbientAgentViewModel::harness is populated on spawn for the local spawner via the harness selector. For viewers that join a shared cloud session, the field is left at its default Oz even for claude / gemini runs — enter_viewing_existing_session fetches the task but only reads agent_config_snapshot.environment_id. The viewer therefore reports the wrong harness until this is fixed.
When the cloud-mode terminal model is created, is_executing_oz_environment_startup_commands is set to true unconditionally under CloudModeSetupV2. The block-list flag:
- Marks every new block
is_oz_environment_startup_command = trueand hides it. - Is flipped to
falseonly inModelEvent::AfterBlockStarted's siblingBlocklistAIHistoryEvent::AppendedExchangehandler (view.rs:5042-5053).is_cloud_agent_pre_first_exchangeis a view-level helper that returnstruewhile the active cloud-agent conversation hasexchange_count() == 0. It gates: the "Setting up environment" warping indicator, the pre-harness input-box hiding, remote-input suppression inshared_session/viewer/terminal_manager.rs, andmaybe_insert_setup_command_blocks. For non-oz there are no exchanges, so this staystrueforever.maybe_insert_setup_command_blocksruns in theAfterBlockStartedpath and, when pre-first-exchange, inserts theCloudModeSetupTextBlocksummary row (once) plus aCloudModeSetupCommandBlockrow for the block. It has no knowledge of whether the block is the harness CLI command.PendingUserQueryBlock::newcurrently takesinterruptible: booland always renders a close button; the "Send now" button is added only wheninterruptible.TerminalView::insert_pending_user_query_blocksubscribes toDismissed/SendNowevents;send_user_query_after_next_conversation_finishedalso registers aqueued_prompt_callbackso the prompt is re-submitted when the current conversation finishes.
Proposed changes
1. PendingUserQueryBlock — per-button bools
app/src/ai/blocklist/block/pending_user_query_block.rs
Replace the interruptible: bool constructor param with two bool fields on the block itself:
show_close_button: bool— whether to render the dismiss ("X") button.show_send_now_button: bool— whether to render the "Send now" button. UpdatePendingUserQueryBlock:- Make
close_button: Option<ViewHandle<ActionButton>>(was unconditional), constructed only whenshow_close_buttonis true. send_now_button: Option<ViewHandle<ActionButton>>stays optional, constructed only whenshow_send_now_buttonis true.- Build the buttons column with 0, 1, or 2 children. If it ends up empty, skip the whole "buttons" container.
- Constructor signature becomes
PendingUserQueryBlock::new(prompt, user_display_name, profile_image_path, show_close_button, show_send_now_button, ctx). This keeps the wire-up trivial for each call site (pass the two bools) and avoids introducing an enum just to express three combinations.
2. TerminalView helpers — thread the button bools and add a cloud-mode insertion path
app/src/terminal/view/pending_user_query.rs
- Change
insert_pending_user_query_block'sinterruptible: boolparam toshow_close_button: bool, show_send_now_button: booland pass them toPendingUserQueryBlock::new. - Keep subscribing to
PendingUserQueryBlockEvent::DismissedandPendingUserQueryBlockEvent::SendNow. When both bools arefalse, the block never emits these events, so the subscription is effectively inert. - Change
send_user_query_after_next_conversation_finished'sinterruptible: boolparam to the same two bools and pass them through. Update existing callers:app/src/workspace/view.rs:11605(handle_forked_conversation_prompts,/fork-and-compact) —show_close_button: true, show_send_now_button: false(wasfalse).app/src/workspace/view.rs:11701(summarize_active_ai_conversation,/compact-and) —show_close_button: true, show_send_now_button: false(wasfalse).app/src/workspace/view.rs:20955(QueuePromptForConversation,/queue) —show_close_button: true, show_send_now_button: true(wastrue).
- Add a new public method used by Cloud Mode:
This does NOT register a
pub(in crate::terminal::view) fn insert_cloud_mode_queued_user_query_block( &mut self, prompt: String, ctx: &mut ViewContext<Self>, ) { self.remove_pending_user_query_block(ctx); self.insert_pending_user_query_block( prompt, /* show_close_button */ false, /* show_send_now_button */ false, ctx, ); }queued_prompt_callback— the prompt is not re-submitted; it is already being carried by the harness inside the sandbox.remove_pending_user_query_blockalready clearsqueued_prompt_callbackon top of removing the view. It remains the single teardown entry point.
3. AmbientAgentViewModel — resolve harness for viewers, track harness-command-started, new event
app/src/terminal/view/ambient_agent/model.rs
Add a helper for the non-oz check:
impl AmbientAgentViewModel {
/// True when the run is configured to use a non-Oz execution harness and the
/// required feature flags are enabled.
pub fn is_third_party_harness(&self) -> bool {
FeatureFlag::AgentHarness.is_enabled() && self.harness != Harness::Oz
}
}
Fix the viewer-side harness resolution so AmbientAgentViewModel::harness reflects the run's actual harness for every client, not just the spawner. In enter_viewing_existing_session, extend the success branch to also resolve the harness from task.agent_config_snapshot.harness and call set_harness:
Ok(task) => {
let snapshot = task.agent_config_snapshot.as_ref();
let environment_id = snapshot
.and_then(|s| s.environment_id.as_deref())
.and_then(|id| ServerId::try_from(id).ok())
.map(SyncId::ServerId);
let harness = snapshot
.and_then(|s| s.harness.as_ref())
.map(|h| parse_harness_config_name(&h.harness_type))
.unwrap_or(Harness::Oz);
me.set_environment_id(environment_id, ctx);
me.set_harness(harness, ctx);
}
parse_harness_config_name is a small module-local helper that matches "claude" / "gemini" / "oz" explicitly and falls back to Harness::Oz (with a warn log) on unknown values. set_harness is a no-op when unchanged.
Add a harness-command-started tracker:
pub struct AmbientAgentViewModel {
// ... existing fields ...
harness_command_started: bool,
}
impl AmbientAgentViewModel {
pub fn harness_command_started(&self) -> bool {
self.harness_command_started
}
pub fn mark_harness_command_started(&mut self, ctx: &mut ModelContext<Self>) {
if self.harness_command_started {
return;
}
self.harness_command_started = true;
ctx.emit(AmbientAgentViewModelEvent::HarnessCommandStarted);
}
}
Initialize it to false in new and reset_status.
Add the event variant:
pub enum AmbientAgentViewModelEvent {
// ... existing ...
HarnessCommandStarted,
}
4. is_cloud_agent_pre_first_exchange — also honor harness-started
app/src/terminal/view/ambient_agent/mod.rs
Extend the helper so both Oz's "first exchange" and non-oz's "harness command started" transitions end the pre-first-exchange phase. At the site that currently returns based on exchange_count() == 0:
if ambient_agent_view_model.as_ref(app).harness_command_started() {
return false;
}
BlocklistAIHistoryModel::as_ref(app)
.conversation(&conversation_id)
.is_some_and(|conversation| conversation.exchange_count() == 0)
This keeps every existing consumer (status_bar, input-box visibility, remote-input suppression, setup_command_text) correctly reading false after the harness block is detected.
5. Detect the harness block and emit HarnessCommandStarted
app/src/terminal/view/ambient_agent/view_impl.rs — maybe_insert_setup_command_blocks
This function is already the single hook called from ModelEvent::AfterBlockStarted for cloud-mode setup bookkeeping, so it's the right place to detect the harness block.
Before doing any setup-row insertion, short-circuit if the active block's CLI-agent matches the run's harness:
pub(in crate::terminal::view) fn maybe_insert_setup_command_blocks(
&mut self,
block_id: &BlockId,
ctx: &mut ViewContext<Self>,
) {
if !FeatureFlag::CloudModeSetupV2.is_enabled() {
return;
}
// Only act while we're still in the pre-first-exchange phase.
if !super::is_cloud_agent_pre_first_exchange(
&self.ambient_agent_view_model,
&self.agent_view_controller,
ctx,
) {
return;
}
// For non-oz harness runs, transition out of the setup phase when the
// harness CLI starts. The block becomes a normal CLI-agent session rather
// than a setup command.
if self.ambient_agent_view_model.as_ref(ctx).is_third_party_harness()
&& self.active_block_matches_run_harness(ctx)
{
self.ambient_agent_view_model.update(ctx, |m, ctx| {
m.mark_harness_command_started(ctx);
});
return;
}
// ... existing setup-row insertion logic, now guarded by the
// is_cloud_agent_pre_first_exchange check above ...
}
active_block_matches_run_harness is a small new helper on TerminalView that runs CLIAgent::detect directly (no alias or shell-escape handling) and matches against the run's harness:
fn active_block_matches_run_harness(&self, ctx: &AppContext) -> bool {
let command = self
.model
.lock()
.block_list()
.active_block()
.command_with_secrets_obfuscated(false);
let Some(cli_agent) = CLIAgent::detect(&command, None, None, ctx) else {
return false;
};
match self.ambient_agent_view_model.as_ref(ctx).selected_harness() {
Harness::Oz => false,
Harness::Claude => matches!(cli_agent, CLIAgent::Claude),
Harness::Gemini => matches!(cli_agent, CLIAgent::Gemini),
}
}
Cloud-mode harness runs always invoke the canonical claude / gemini CLI prefix from the harness sidecar (hardcoded in app/src/ai/agent_sdk/driver/harness/claude_code.rs:84-95 and the matching gemini.rs). Passing None for both the escape char and aliases is sufficient; if a future wrapper (e.g. bash -l -c 'claude …') breaks canonical detection, the fallback trigger under Risks and mitigations applies. We deliberately avoid detect_cli_agent_from_model because it gates on is_active_and_long_running, which has not yet elapsed at AfterBlockStarted time.
6. Handle HarnessCommandStarted in the view
app/src/terminal/view/ambient_agent/view_impl.rs — handle_ambient_agent_event
Add a new arm that mirrors how AppendedExchange already tears down the setup phase for Oz, removes the cloud-mode queued prompt block, and forces a fresh viewer-size report to the sharer so the harness CLI lays out at our current dimensions:
AmbientAgentViewModelEvent::HarnessCommandStarted => {
// Stop classifying new blocks as environment setup commands and let the
// existing CLI-agent rendering take over for the harness block.
let mut model = self.model.lock();
if model.block_list().is_executing_oz_environment_startup_commands() {
model
.block_list_mut()
.set_is_executing_oz_environment_startup_commands(false);
}
drop(model);
self.remove_pending_user_query_block(ctx);
// Collapse the setup-commands summary now that we're past setup (matches
// the existing behavior when the first AI exchange appears).
self.ambient_agent_view_model.update(ctx, |model, ctx| {
model.set_setup_command_visibility(false, ctx);
});
// Force a fresh viewer size report to the sharer so the harness CLI
// (e.g. the claude TUI) starts at the viewer's actual dimensions
// instead of whatever the sandbox PTY was sized to during setup.
self.force_report_viewer_terminal_size(ctx);
ctx.notify();
}
The Oz AppendedExchange handler in view.rs:5042-5053 stays as-is.
6a. force_report_viewer_terminal_size helper
app/src/terminal/view/shared_session/view_impl.rs
Viewer-driven resize normally dedups on the last reported natural size (SharedSessionViewer::last_reported_natural_size) so a noop refresh doesn't spam the sharer. At harness start we specifically want to break that dedup: the sandbox PTY was resized during setup (to whatever size the pre-harness commands dictated), and the harness TUI will fail to lay out correctly if we don't re-issue our report.
Add:
/// Forces a fresh viewer-size report to the sharer by clearing the dedup cache and
/// refreshing size. No-op when not an active viewer or when viewer-driven sizing is
/// not eligible.
pub(in crate::terminal::view) fn force_report_viewer_terminal_size(
&mut self,
ctx: &mut ViewContext<Self>,
) {
if let Some(viewer) = self.shared_session_viewer_mut() {
viewer.last_reported_natural_size = None;
}
self.refresh_size(ctx);
}
refresh_size already funnels through the standard viewer-driven-sizing eligibility checks (is_viewer_driven_sizing_eligible), so this is a no-op for sharers, for multi-viewer shared sessions, and for non-cloud-agent shared sessions. Cloud-agent shared sessions bypass the same-user check (is_shared_session_for_ambient_agent), so the viewer will always resize the sandbox PTY on harness start.
7. Route the initial prompt to the queued-prompt UI for non-oz runs
app/src/terminal/view/ambient_agent/view_impl.rs — DispatchedAgent handler (currently view_impl.rs:106-135)
Branch on the run's harness:
AmbientAgentViewModelEvent::DispatchedAgent => {
self.update_pane_configuration(ctx);
if FeatureFlag::CloudModeSetupV2.is_enabled() {
let model = self.ambient_agent_view_model.as_ref(ctx);
if model.is_third_party_harness() {
let prompt = model
.request()
.map(|request| request.prompt.clone())
.unwrap_or_default();
if !prompt.is_empty() {
self.insert_cloud_mode_queued_user_query_block(prompt, ctx);
}
// Do NOT set has_inserted_cloud_mode_user_query_block for third-party
// harnesses — the queued block path handles its own lifecycle and the
// flag only gates Oz-specific first-exchange dedup logic.
} else {
// ... existing Oz path: insert CloudModeInitialUserQuery, set
// has_inserted_cloud_mode_user_query_block(true) ...
}
} else {
// ... existing tip-reset branch, unchanged ...
}
ctx.notify();
}
For viewers / historical replay, request() is None on the viewer's model (the viewer doesn't spawn), so the queued block is not inserted — matching the product-spec rule that late joiners don't see it.
8. Remove pending-query block on pre-harness terminal states
app/src/terminal/view/ambient_agent/view_impl.rs — extend existing Failed, Cancelled, and NeedsGithubAuth arms:
Each arm gets the same additional line (idempotent and cheap; no-op if no block present):
self.remove_pending_user_query_block(ctx);
This covers the product-spec requirement that failing/cancelling/auth-required runs before the harness block starts remove the queued indicator and fall back to the existing error/cancel/auth UI. The fallback UI is already rendered by the existing status-bar and loading-screen code paths under CloudModeSetupV2.
9. Cloud-mode submission-block guard remains harness-agnostic
app/src/terminal/input.rs:5725-5734 — should_block_cloud_mode_setup_submission currently blocks local input submission during the pre-harness/pre-first-exchange window. Because we updated is_cloud_agent_pre_first_exchange to also return false after HarnessCommandStarted, this guard already stops firing once the harness block starts, allowing input to flow to the harness TUI normally. No change needed.
10. Setup UI horizontal padding for non-oz harnesses
app/src/terminal/view/ambient_agent/block.rs, block/setup_command.rs, block/setup_command_text.rs
The oz setup UI wraps its per-command row and summary row in WithContentItemSpacing::with_agent_output_item_spacing, which applies a left margin of CONTENT_HORIZONTAL_PADDING + icon_size + 16. so the setup rows line up with other oz agent-output items (reasoning, actions, etc.). For non-oz harness runs, the harness CLI block that takes over after setup is a regular terminal command block and uses *terminal::view::PADDING_LEFT (20px, or 16px with LessHorizontalTerminalPadding) as its horizontal padding. Using the oz indent for non-oz setup rows causes a visible horizontal jump at harness start.
A shared helper cloud_mode_setup_row_spacing in block.rs picks the right spacing per harness:
pub(super) fn cloud_mode_setup_row_spacing(
element: Box<dyn Element>,
ambient_agent_view_model: &ModelHandle<AmbientAgentViewModel>,
app: &AppContext,
) -> Container {
if ambient_agent_view_model
.as_ref(app)
.is_third_party_harness()
{
Container::new(element)
.with_margin_left(*PADDING_LEFT)
.with_margin_right(*PADDING_LEFT)
.with_margin_bottom(CONTENT_ITEM_VERTICAL_MARGIN)
} else {
element.with_agent_output_item_spacing(app)
}
}
CloudModeSetupCommandBlock calls this in its collapsed-header branch (the expanded detail view keeps its own corner-radius / container treatment). CloudModeSetupTextBlock calls this at the end of render instead of the standalone .with_agent_output_item_spacing(app).finish().
11. Feature flag gating
- All new code paths that check
is_third_party_harnessalso implicitly depend onFeatureFlag::AgentHarness(the helper checks it). - The outer
if FeatureFlag::CloudModeSetupV2.is_enabled()guard inDispatchedAgentandmaybe_insert_setup_command_blockscontinues to gate the whole setup-v2 UI; when the flag is off, the legacy loading screen / full-screen overlay path is unchanged. - When
AgentHarnessis disabled,is_third_party_harnessalways returnsfalse, the Oz paths are taken for everything, and the spec's new behavior never activates. Replay of CLI-agent conversations is already guarded by the same flag inpane_group/mod.rs:3763,5682andterminal/view/load_ai_conversation.rs:268.
End-to-end flow (non-oz Cloud Mode run)
- User selects claude/gemini in the harness selector and submits a prompt.
AmbientAgentViewModel::spawn_agentbuilds aSpawnAgentRequestwithHarnessConfig::from_harness_type;self.harnessis non-oz. Stream emitsDispatchedAgent.handle_ambient_agent_eventseesis_third_party_harness()and callsinsert_cloud_mode_queued_user_query_block(prompt).PendingUserQueryBlockis rendered withshow_close_button: falseandshow_send_now_button: false.- Spawn stream progresses to
SessionReady; the cloud-mode terminal-model viewer is built withis_executing_oz_environment_startup_commands = true. - Environment setup blocks start.
maybe_insert_setup_command_blocksseesis_cloud_agent_pre_first_exchange == trueandactive_block_matches_run_harness == false, so it inserts theCloudModeSetupTextBlocksummary row and aCloudModeSetupCommandBlockper block. Blocks are markedis_oz_environment_startup_commandby the existing block-list logic. - Sandbox's
oz agent runexecutesclaude --session-id … < /tmp/oz_prompt(fromagent_sdk/driver/harness/claude_code.rs). The viewer sees this as a new long-running block. maybe_insert_setup_command_blocksdetectsCLIAgent::ClaudematchesHarness::Claude, callsmark_harness_command_started, and returns without inserting setup rows for this block.HarnessCommandStartedfires → view arm flipsis_executing_oz_environment_startup_commands = false, removes the pending-query block, collapses the setup-commands summary, and callsforce_report_viewer_terminal_sizeso the sandbox PTY is resized to our current dimensions before claude's TUI lays out its first frame.- From now on,
is_cloud_agent_pre_first_exchangereturns false. The claude block is a normal CLI-agent session:detect_cli_agent_from_model+CLIAgentSessionsModel::set_sessionalready run inview.rs:10343-10378, so the CLI-agent footer and rich-input UI activate. Setup rows rendered earlier in the run usePADDING_LEFTmargins so the visible left edge of content does not shift when the harness block takes over. - On pre-harness
Failed/Cancelled/NeedsGithubAuth: the newremove_pending_user_query_blockcalls tear down the queued indicator; the existing error/cancel/auth UI remains.
Risks and mitigations
Missing harness match. If CLIAgent::detect fails to identify the harness (e.g. a future wrapper like bash -c 'claude …'), we never transition out of setup and the claude block stays flagged as a setup command. The short-circuit only covers the sandbox's canonical invocation; if detection fails, behavior regresses to the current broken state rather than getting worse. A fallback trigger (e.g. timeout-based transition) can be added as a follow-up if this becomes a problem.
Viewer harness resolution races. enter_viewing_existing_session fetches the task asynchronously. If the harness block starts before the fetch completes, is_third_party_harness() returns false at the time of detection and we treat the block as a setup command. In practice the task fetch is issued on join and the viewer only sees blocks after the shared session connects, which is later. If needed, we can re-evaluate the harness on task fetch completion and fire mark_harness_command_started retroactively.
is_cloud_agent_pre_first_exchange semantics shift. Consumers were designed around "conversation has no exchanges yet". After this change, the helper also returns false when a harness command has started. Every caller (status_bar, is_input_box_visible, shared_session/viewer/terminal_manager, setup_command_text, maybe_insert_setup_command_blocks) behaves correctly under the new semantics for non-oz runs and is a no-op for Oz runs (since harness_command_started is never set on Oz).
Testing and validation
Unit coverage:
- Add a test for
PendingUserQueryBlockrendering for each(show_close_button, show_send_now_button)combination (both-true, close-only, neither). - Add a test for
AmbientAgentViewModel::mark_harness_command_startedidempotency and event emission, and foris_third_party_harnessgating onAgentHarness+harnessfield. - Add a test that
enter_viewing_existing_sessionpopulatesharnessfrom the fetchedAmbientAgentTask.agent_config_snapshot.harnessfor claude / gemini / oz. - Extend
is_cloud_agent_pre_first_exchangetests (or add one) to cover the harness-started early-return. - Add a test that
force_report_viewer_terminal_sizeclearslast_reported_natural_sizeand callsrefresh_size, and is a no-op for sharers / when viewer-driven sizing is ineligible. Integration / manual validation (mirrors the product spec's validation list): - Spawn a claude-code Cloud Mode run with multiple startup commands; confirm queued prompt block (no buttons) appears, setup-commands summary expands during setup, and on claude start the queued block is removed, summary collapses to "Ran setup commands", and the claude TUI is the active CLI-agent block. Confirm the claude TUI reports the viewer's current rows/cols (resize the pane during setup and verify the TUI lays out at the new size on harness start) and that the setup-command rows are horizontally flush with the subsequent harness terminal block (no visible left-edge jump).
- Spawn a gemini run; confirm the same behavior when
geminiis detected. - Spawn a claude run with no environment setup commands; confirm the queued block is removed when the harness block starts without a setup-commands summary having been inserted.
- Spawn a claude run that fails during env setup; confirm queued block is removed on
Failedand the existing error UI renders. - Cancel a claude run pre-harness; confirm queued block removal + cancelled UI.
- Trigger GitHub-auth-required pre-harness; confirm queued block removal + auth UI.
- Join an existing claude shared session after the harness has started; confirm no queued block, collapsed setup summary (from prior blocks), claude TUI visible.
- Replay a completed claude conversation; confirm no queued block, transcript rendered correctly via the existing CLI-agent block-snapshot path.
- Re-run all Oz validation cases from
specs/REMOTE-172/PRODUCT.mdto confirm no regression. - Toggle
CloudModeSetupV2off; confirm legacy cloud-mode behavior restored for all harnesses. - Toggle
AgentHarnessoff; confirm harness selector hides non-oz options and nothing in this spec activates.
Follow-ups
- Consider telemetry for
HarnessCommandStarted(time fromDispatchedAgent/SessionReadyto harness start) so we can monitor setup latency per-harness. - If detection proves flaky in practice, add a timeout-based fallback transition (e.g. "still in setup 60s after session ready with no matching CLI agent detection → transition anyway and log a warning").
- Once
CloudModeSetupV2ships to stable, consider unifying the "pre-first-exchange" and "pre-harness" concepts behind a single view-model state rather than the current combination of block-list flag + exchange count + harness-started bool.