Skip to content

Commit c1cdbf5

Browse files
Join the live session when hydrating remote-child panes
Revealing a subtree-discovered descendant of a running orchestration rendered the empty cloud zero-state instead of the run's transcript. The task-backed hydration path's LiveAttach arm only called enter_viewing_existing_session, which binds the task to the ambient view model but never connects the pane's deferred shared-session viewer TerminalManager to the run's execution session — the join drivers (SessionReady / ExecutionSessionReady) only fire from the dispatch-time spawn stream or an explicit attach_execution_session, neither of which runs on the restore/discovery path. Direct children masked the gap because their panes join via the spawn stream at dispatch; discovered descendants always hydrate through this path. LiveAttach now carries the parsed session id and the dispatch arm records it on the ambient view model and connects the deferred viewer with full scrollback replay (not the follow-up append mode, which suppresses the agent-conversation replay), so a mid-run join backfills the transcript so far. Restored direct children of a still-running orchestration get the same fix on reveal after a restart. Co-Authored-By: Warp Agent <agent@warp.dev>
1 parent d9ef445 commit c1cdbf5

3 files changed

Lines changed: 156 additions & 11 deletions

File tree

app/src/pane_group/child_agent/hydration.rs

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use session_sharing_protocol::common::SessionId;
12
use warp_errors::report_error;
23
use warpui::{SingletonEntity, ViewContext};
34

@@ -11,6 +12,7 @@ use crate::ai::blocklist::BlocklistAIHistoryModel;
1112
use crate::ai::blocklist::agent_view::AgentViewEntryOrigin;
1213
use crate::ai::blocklist::history_model::CloudConversationData;
1314
use crate::pane_group::{AmbientAgentViewModelHandleExt, PaneGroup, PaneId};
15+
use crate::terminal::shared_session;
1416
use crate::terminal::view::load_ai_conversation::{
1517
RestoreConversationEntryBehavior, RestoredAIConversation,
1618
};
@@ -20,7 +22,7 @@ use crate::terminal::view::load_ai_conversation::{
2022
#[derive(Debug, Clone, PartialEq, Eq)]
2123
pub(in crate::pane_group) enum RemoteChildHydrationAction {
2224
/// Attachable live session — join it in place.
23-
LiveAttach,
25+
LiveAttach { session_id: SessionId },
2426
/// No live session but a server conversation token is available;
2527
/// `task_is_terminal` controls whether the post-merge step inserts a
2628
/// conversation-ended tombstone (only terminal runs do).
@@ -41,11 +43,8 @@ pub(in crate::pane_group) fn decide_remote_child_hydration_action(
4143
task: &AmbientAgentTask,
4244
) -> RemoteChildHydrationAction {
4345
let live_session_state = task.active_live_session_state();
44-
if matches!(
45-
live_session_state,
46-
AmbientAgentLiveSessionState::Attachable { .. }
47-
) {
48-
return RemoteChildHydrationAction::LiveAttach;
46+
if let AmbientAgentLiveSessionState::Attachable { session_id } = live_session_state {
47+
return RemoteChildHydrationAction::LiveAttach { session_id };
4948
}
5049

5150
let task_is_terminal = matches!(live_session_state, AmbientAgentLiveSessionState::Inactive);
@@ -188,8 +187,9 @@ impl PaneGroup {
188187
};
189188

190189
match decide_remote_child_hydration_action(&task) {
191-
RemoteChildHydrationAction::LiveAttach => {
190+
RemoteChildHydrationAction::LiveAttach { session_id } => {
192191
self.apply_existing_ambient_task_to_pane(pane_id, child_id, task_id, ctx);
192+
self.connect_hidden_child_pane_to_live_session(pane_id, session_id, ctx);
193193
}
194194
RemoteChildHydrationAction::LoadTranscript {
195195
server_token,
@@ -220,6 +220,65 @@ impl PaneGroup {
220220
}
221221
}
222222

223+
/// Connects a hydrated hidden child pane's deferred shared-session
224+
/// viewer to the run's live execution session.
225+
///
226+
/// `enter_viewing_existing_session` only binds the task to the ambient
227+
/// view model; the deferred viewer `TerminalManager` still needs an
228+
/// explicit join, otherwise the pane renders the empty cloud zero-state
229+
/// while the run streams. The join drivers used by dispatch-time panes
230+
/// (`SessionReady` / `ExecutionSessionReady`) only fire from the spawn
231+
/// stream or an explicit `attach_execution_session`, neither of which
232+
/// runs on this restore/discovery path. Connects with full scrollback
233+
/// replay (not the follow-up append mode, which suppresses the agent
234+
/// conversation replay) so a mid-run join backfills the transcript.
235+
fn connect_hidden_child_pane_to_live_session(
236+
&mut self,
237+
pane_id: PaneId,
238+
session_id: SessionId,
239+
ctx: &mut ViewContext<Self>,
240+
) {
241+
if let Some(terminal_view) = self.terminal_view_from_pane_id(pane_id, ctx) {
242+
terminal_view.update(ctx, |terminal_view, ctx| {
243+
if let Some(ambient_agent_view_model) = terminal_view
244+
.ambient_agent_view_model()
245+
.into_optional_handle()
246+
.cloned()
247+
{
248+
// Record the live session without emitting
249+
// `ExecutionSessionReady` — the viewer join below is
250+
// driven directly, and the recorded id keeps
251+
// `is_ready_for_cloud_followup_prompt` false while the
252+
// session is live.
253+
ambient_agent_view_model.update(ctx, |model, _| {
254+
model.set_live_execution_session(session_id);
255+
});
256+
}
257+
terminal_view.prepare_for_live_session_reattach(ctx);
258+
});
259+
}
260+
261+
let Some(terminal_manager) = self
262+
.terminal_session_by_id(pane_id)
263+
.map(|session| session.terminal_manager(ctx))
264+
else {
265+
return;
266+
};
267+
terminal_manager.update(ctx, |terminal_manager, ctx| {
268+
let Some(viewer_manager) = terminal_manager
269+
.as_any_mut()
270+
.downcast_mut::<shared_session::viewer::TerminalManager>()
271+
else {
272+
log::warn!(
273+
"connect_hidden_child_pane_to_live_session: pane manager is not a \
274+
shared-session viewer"
275+
);
276+
return;
277+
};
278+
viewer_manager.connect_to_session(session_id, false, ctx);
279+
});
280+
}
281+
223282
/// Attaches the hidden child pane's ambient agent view model to the
224283
/// live ambient session for `task_id`. Wrapper around
225284
/// `AmbientAgentViewModel::enter_viewing_existing_session` that also

app/src/pane_group/mod_tests.rs

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,85 @@ fn test_restored_remote_hidden_child_pane_enters_existing_ambient_session() {
10081008
});
10091009
}
10101010

1011+
/// A hydrated remote-child placeholder whose task has an attachable live
1012+
/// session must actually join that session — not just bind the task to the
1013+
/// ambient view model. Regression test for revealing a subtree-discovered
1014+
/// descendant of a running orchestration rendering the empty cloud
1015+
/// zero-state: `enter_viewing_existing_session` moved the pane to
1016+
/// `AgentRunning` but the deferred shared-session viewer was never
1017+
/// connected, so no transcript streamed. Direct children mask this because
1018+
/// their dispatch-time panes join via the spawn stream; this hydration path
1019+
/// is the only join driver for restored/discovered placeholders.
1020+
#[test]
1021+
fn test_hydrated_remote_child_with_attachable_live_session_joins_shared_session() {
1022+
App::test((), |mut app| async move {
1023+
initialize_app(&mut app);
1024+
let pane_group = mock_pane_group(&mut app, Default::default());
1025+
1026+
pane_group.update(&mut app, |panes, ctx| {
1027+
let parent_pane_id = get_newly_created_pane_id(panes, &[]);
1028+
let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx);
1029+
let task_id = new_ambient_agent_task_id();
1030+
let session_id: SessionId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
1031+
1032+
let mut task = ambient_agent_task_for_current_user(task_id);
1033+
task.state = AmbientAgentTaskState::InProgress;
1034+
task.is_sandbox_running = true;
1035+
task.session_id = Some(session_id.to_string());
1036+
AgentConversationsModel::handle(ctx).update(ctx, |model, _| {
1037+
model.insert_task_for_test(task);
1038+
});
1039+
1040+
let mut child_conversation = AIConversation::new(false, false);
1041+
child_conversation.set_parent_conversation_id(parent_conversation_id);
1042+
child_conversation.set_task_id(task_id);
1043+
child_conversation.mark_as_remote_child();
1044+
let child_conversation_id = child_conversation.id();
1045+
1046+
panes.create_hidden_child_agent_pane(child_conversation, parent_pane_id, ctx);
1047+
1048+
let child_pane_id = panes
1049+
.child_agent_panes
1050+
.get(&child_conversation_id)
1051+
.copied()
1052+
.expect("hydrated remote child pane should be tracked");
1053+
1054+
let (ambient_task_id, is_agent_running, active_conversation_id) =
1055+
ambient_child_session_state(panes, child_pane_id, ctx);
1056+
assert_eq!(ambient_task_id, Some(task_id));
1057+
assert!(is_agent_running);
1058+
assert_eq!(active_conversation_id, Some(child_conversation_id));
1059+
1060+
let terminal_view = panes
1061+
.terminal_view_from_pane_id(child_pane_id, ctx)
1062+
.expect("remote child pane should have a terminal view");
1063+
assert_eq!(
1064+
terminal_view
1065+
.as_ref(ctx)
1066+
.ambient_agent_view_model()
1067+
.expect("child pane should have an ambient agent model")
1068+
.as_ref(ctx)
1069+
.active_execution_session_id_for_test(),
1070+
Some(session_id),
1071+
"live-attach hydration must record the run's live execution session",
1072+
);
1073+
1074+
// The join is initiated synchronously: `connect_session` moves
1075+
// the deferred viewer to `ViewPending` before the async network
1076+
// handshake runs.
1077+
assert!(
1078+
terminal_view
1079+
.as_ref(ctx)
1080+
.model
1081+
.lock()
1082+
.shared_session_status()
1083+
.is_view_pending(),
1084+
"live-attach hydration must connect the deferred shared-session viewer",
1085+
);
1086+
});
1087+
});
1088+
}
1089+
10111090
/// Fix B: when task data for a restored remote child is NOT yet cached at
10121091
/// `create_hidden_child_agent_pane` time, the placeholder must still be
10131092
/// registered in `child_agent_panes` keyed by its local AIConversationId,
@@ -3301,6 +3380,9 @@ fn hydration_decision_task(
33013380
#[test]
33023381
fn decide_remote_child_hydration_attachable_live_session_chooses_live_attach() {
33033382
// InProgress + sandbox running + parseable session id -> Attachable.
3383+
// The decision must carry the parsed session id so the dispatch arm can
3384+
// actually join the live session.
3385+
let session_id: SessionId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
33043386
let task = hydration_decision_task(
33053387
AmbientAgentTaskState::InProgress,
33063388
true,
@@ -3309,14 +3391,12 @@ fn decide_remote_child_hydration_attachable_live_session_chooses_live_attach() {
33093391
);
33103392
assert_eq!(
33113393
task.active_live_session_state(),
3312-
AmbientAgentLiveSessionState::Attachable {
3313-
session_id: "11111111-1111-1111-1111-111111111111".parse().unwrap(),
3314-
},
3394+
AmbientAgentLiveSessionState::Attachable { session_id },
33153395
);
33163396

33173397
assert_eq!(
33183398
decide_remote_child_hydration_action(&task),
3319-
RemoteChildHydrationAction::LiveAttach,
3399+
RemoteChildHydrationAction::LiveAttach { session_id },
33203400
);
33213401
}
33223402

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -882,6 +882,12 @@ impl AmbientAgentViewModel {
882882
ctx.emit(AmbientAgentViewModelEvent::EnvironmentSelected);
883883
}
884884

885+
/// Test-only accessor for the recorded live execution session id.
886+
#[cfg(test)]
887+
pub fn active_execution_session_id_for_test(&self) -> Option<SessionId> {
888+
self.active_execution_session_id
889+
}
890+
885891
pub fn record_ambient_execution_ended(
886892
&mut self,
887893
session_id: SessionId,

0 commit comments

Comments
 (0)