Skip to content

Commit 1148ae3

Browse files
kjankovoz-agent
andauthored
Wake up remote Claude Code agents on new events (warpdotdev#9399)
## Description <!-- Please remember to add your design buddy onto the PR for review, if it contains any UI changes! --> Fixes orchestration v2 parent/child agent wake-up and messaging behavior for remote child agents. This PR updates the client-side orchestration flow so that incoming parent-agent messages wake remote child agents through the server run follow-up path instead of trying to treat them like local dormant Claude harnesses. Previously, a remote child could receive the parent’s message, but it would not be restarted correctly in a harness and could fail or hang when trying to send a message back to the parent. Main changes: • Adds a remote-child wake path in the blocklist AI controller: ◦ detects remote child conversations with pending parent-agent message events ◦ submits a run follow-up to agent/runs/{run_id}/followups ◦ removes delivered pending message events after successful follow-up submission ◦ retries/logs failures instead of silently hanging • Keeps local dormant Claude wake behavior separate from remote child wake behavior. • Restores remote hidden child panes as cloud/ambient agent panes instead of local terminal-backed child panes. • Ensures restored remote child panes enter the existing ambient session in AgentRunning state. • Persists and restores remote-child conversation metadata so the client can distinguish local children from remote children across reloads. • Improves orchestration v2 message sending: ◦ uses task-scoped server APIs when available ◦ adds bounded timeouts and error logging for send failures ◦ surfaces failures instead of leaving action execution indefinitely pending • Adds regression coverage for: ◦ remote child conversation restoration ◦ remote child pane/session state ◦ task-scoped ambient agent messaging ◦ orchestration v2 message/error behavior ## Testing <!-- How did you test this change? What automated tests did you add? If you didn't add any new tests, what's your justification for not adding any? If you're not sure whether you should add a test, check our testing policy: https://www.notion.so/warpdev/How-We-Code-at-Warp-257fe43d556e4b3c8dfd42f70004cc72#1f97825450504baa9c5fd87a737daa09 --> ## Server API dependencies <!-- You may remove this section if your PR does not have any server dependencies. --> - [ ] Is this change necessary to make the client compatible with a desired [server API breaking change](https://www.notion.so/warpdev/How-to-safely-introduce-server-API-breaking-changes-0aa805ff5d5d41fd8834f3c95caba0b4?pvs=4#d55ecf8aea3449949d3c33b0e67f6800)? - [ ] Does this change rely on a [new server API](https://www.notion.so/warpdev/How-to-add-a-new-full-stack-feature-8412cede405a4ec194b32bdd4b951035?pvs=4#04da1e6a493542d68b3e998c7d339640)? - [ ] If so, is the use of this API restricted to client channels that rely on the staging server (e.g. WarpDev)? - [ ] Is this change enabling the use of a server API on client channels that rely on the production server (e.g. WarpStable)? - [ ] If so, has the new server API been stable on production for at least one server release cycle? See [here](https://www.notion.so/warpdev/How-to-add-a-new-full-stack-feature-8412cede405a4ec194b32bdd4b951035?pvs=4#73b202f939834b97ab1fbdf7fc82cd53) for more details. ## Agent Mode - [ ] Warp Agent Mode - This PR was created via Warp's AI Agent Mode ## Changelog Entries for Stable <!-- The entries below will be used when constructing a soft-copy of the stable release changelog. Leave blank or remove the lines if no entry in the stable changelog is needed. Entries should be on the same line, without the `{{` `}}` brackets. You can use multiple lines, even of the same type. The valid suffixes are: * NEW-FEATURE: for new, relatively sizable features. Features listed here will likely have docs / social media posts / marketing launches associated with them, so use sparingly. * IMPROVEMENT: for new functionality of existing features. * BUG-FIX: for fixes related to known bugs or regressions. * IMAGE: the image specified by the URL (hosted on GCP) will be added to Dev & Preview releases. For Stable releases, see the pinned doc in the #release Slack channel. * OZ: Oz-related updates. Use `CHANGELOG-OZ`. At most 4 Oz updates are shown in-app per release. --> CHANGELOG-NEW-FEATURE: {{text goes here...}} CHANGELOG-IMPROVEMENT: {{text goes here...}} CHANGELOG-BUG-FIX: {{text goes here...}} CHANGELOG-BUG-FIX: {{more text goes here...}} CHANGELOG-IMAGE: {{GCP-hosted URL goes here...}} CHANGELOG-OZ: {{text goes here...}} --------- Co-authored-by: Oz <oz-agent@warp.dev>
1 parent 9cfe80e commit 1148ae3

47 files changed

Lines changed: 3262 additions & 320 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/src/ai/agent/api/convert_conversation.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ pub fn convert_conversation_data_to_ai_conversation(
8282
parent_agent_id: None,
8383
agent_name: None,
8484
parent_conversation_id: None,
85+
is_remote_child: false,
8586
run_id: None,
8687
autoexecute_override: None,
8788
last_event_sequence: None,
@@ -97,6 +98,7 @@ pub fn convert_conversation_data_to_ai_conversation(
9798
parent_agent_id: None,
9899
agent_name: None,
99100
parent_conversation_id: None,
101+
is_remote_child: false,
100102
// TODO: Populate run_id from server metadata once it is exposed
101103
// in ServerAIConversationMetadata. For cloud conversations that
102104
// were spawned via the server API, the run_id is created at task

app/src/ai/agent/conversation.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ impl AIConversation {
355355
parent_agent_id,
356356
agent_name,
357357
parent_conversation_id,
358+
is_remote_child,
358359
run_id,
359360
autoexecute_override,
360361
last_event_sequence,
@@ -380,6 +381,7 @@ impl AIConversation {
380381
let parent_conversation_id = data
381382
.parent_conversation_id
382383
.and_then(|id| AIConversationId::try_from(id).ok());
384+
let is_remote_child = data.is_remote_child;
383385
let run_id = data.run_id;
384386
let autoexecute_override = if FeatureFlag::RememberFastForwardState.is_enabled() {
385387
data.autoexecute_override
@@ -399,6 +401,7 @@ impl AIConversation {
399401
parent_agent_id,
400402
agent_name,
401403
parent_conversation_id,
404+
is_remote_child,
402405
run_id,
403406
autoexecute_override,
404407
last_event_sequence,
@@ -413,6 +416,7 @@ impl AIConversation {
413416
None,
414417
None,
415418
None,
419+
false,
416420
None,
417421
AIConversationAutoexecuteMode::default(),
418422
None,
@@ -457,7 +461,7 @@ impl AIConversation {
457461
parent_agent_id,
458462
agent_name,
459463
parent_conversation_id,
460-
is_remote_child: false,
464+
is_remote_child,
461465
last_event_sequence,
462466
})
463467
}
@@ -809,7 +813,7 @@ impl AIConversation {
809813

810814
/// Returns true if this conversation was spawned by a parent orchestrator agent.
811815
pub fn is_child_agent_conversation(&self) -> bool {
812-
self.parent_conversation_id.is_some()
816+
self.parent_conversation_id.is_some() || self.parent_agent_id.is_some()
813817
}
814818

815819
/// True iff this conversation knows about a parent agent — either via a
@@ -2838,6 +2842,7 @@ impl AIConversation {
28382842
parent_agent_id: self.parent_agent_id.clone(),
28392843
agent_name: self.agent_name.clone(),
28402844
parent_conversation_id: self.parent_conversation_id.map(|id| id.to_string()),
2845+
is_remote_child: self.is_remote_child,
28412846
run_id: self.task_id.map(|id| id.to_string()),
28422847
autoexecute_override: Some(self.autoexecute_override.into()),
28432848
last_event_sequence: self.last_event_sequence,

app/src/ai/agent/conversation_tests.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,41 @@ fn restored_conversation_defaults_autoexecute_override_when_not_persisted() {
121121
);
122122
}
123123

124+
#[test]
125+
fn restored_conversation_uses_persisted_last_event_sequence() {
126+
let conversation_data: AgentConversationData =
127+
serde_json::from_str(r#"{"server_conversation_token":null,"last_event_sequence":42}"#)
128+
.unwrap();
129+
130+
let conversation = restored_conversation(Some(conversation_data));
131+
132+
assert_eq!(conversation.last_event_sequence(), Some(42));
133+
}
134+
135+
#[test]
136+
fn restored_conversation_uses_persisted_remote_child_marker() {
137+
let conversation_data: AgentConversationData =
138+
serde_json::from_str(r#"{"server_conversation_token":null,"is_remote_child":true}"#)
139+
.unwrap();
140+
141+
let conversation = restored_conversation(Some(conversation_data));
142+
143+
assert!(conversation.is_remote_child());
144+
}
145+
146+
#[test]
147+
fn child_conversation_detection_uses_parent_agent_id() {
148+
let conversation_data: AgentConversationData = serde_json::from_str(
149+
r#"{"server_conversation_token":null,"parent_agent_id":"parent-run-id"}"#,
150+
)
151+
.unwrap();
152+
153+
let conversation = restored_conversation(Some(conversation_data));
154+
155+
assert!(conversation.is_child_agent_conversation());
156+
assert_eq!(conversation.parent_conversation_id(), None);
157+
}
158+
124159
#[test]
125160
fn restored_conversation_defaults_unknown_persisted_autoexecute_override() {
126161
let _flag = FeatureFlag::RememberFastForwardState.override_enabled(true);

app/src/ai/agent_conversations_model_tests.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ fn test_display_status_uses_matching_conversation_for_in_progress_task() {
152152
parent_agent_id: None,
153153
agent_name: None,
154154
parent_conversation_id: None,
155+
is_remote_child: false,
155156
run_id: Some(task_id.clone()),
156157
autoexecute_override: None,
157158
last_event_sequence: None,
@@ -204,6 +205,7 @@ fn test_display_status_updates_when_blocked_conversation_resumes() {
204205
parent_agent_id: None,
205206
agent_name: None,
206207
parent_conversation_id: None,
208+
is_remote_child: false,
207209
run_id: Some(task_id.clone()),
208210
autoexecute_override: None,
209211
last_event_sequence: None,
@@ -280,6 +282,7 @@ fn test_display_status_terminal_task_state_overrides_matching_conversation() {
280282
parent_agent_id: None,
281283
agent_name: None,
282284
parent_conversation_id: None,
285+
is_remote_child: false,
283286
run_id: Some(task_id.clone()),
284287
autoexecute_override: None,
285288
last_event_sequence: None,
@@ -332,6 +335,7 @@ fn test_status_filter_uses_display_status_for_task_backed_conversations() {
332335
parent_agent_id: None,
333336
agent_name: None,
334337
parent_conversation_id: None,
338+
is_remote_child: false,
335339
run_id: Some(task_id.clone()),
336340
autoexecute_override: None,
337341
last_event_sequence: None,
@@ -766,6 +770,7 @@ fn test_get_tasks_and_conversations_prefers_task_when_task_id_matches_conversati
766770
parent_agent_id: None,
767771
agent_name: None,
768772
parent_conversation_id: None,
773+
is_remote_child: false,
769774
run_id: Some(task_id.clone()),
770775
autoexecute_override: None,
771776
last_event_sequence: None,
@@ -823,6 +828,7 @@ fn test_get_tasks_and_conversations_prefers_task_when_server_token_matches() {
823828
parent_agent_id: None,
824829
agent_name: None,
825830
parent_conversation_id: None,
831+
is_remote_child: false,
826832
run_id: None,
827833
autoexecute_override: None,
828834
last_event_sequence: None,
@@ -879,6 +885,7 @@ fn test_get_tasks_and_conversations_keeps_unrelated_tasks_and_conversations() {
879885
parent_agent_id: None,
880886
agent_name: None,
881887
parent_conversation_id: None,
888+
is_remote_child: false,
882889
run_id: None,
883890
autoexecute_override: None,
884891
last_event_sequence: None,

app/src/ai/agent_events/message_hydrator.rs

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::sync::Arc;
22
use std::time::Duration;
33

4+
use crate::ai::ambient_agents::AmbientAgentTaskId;
45
use anyhow::{anyhow, Context, Result};
56
#[cfg(not(target_family = "wasm"))]
67
use futures::future::Either;
@@ -9,6 +10,7 @@ use warpui::r#async::Timer;
910

1011
use crate::ai::agent::ReceivedMessageInput;
1112
use crate::server::server_api::ai::{AIClient, AgentRunEvent, ReadAgentMessageResponse};
13+
use crate::server::server_api::ServerApi;
1214

1315
pub(crate) const DEFAULT_AGENT_MESSAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(5);
1416

@@ -17,6 +19,8 @@ pub(crate) const DEFAULT_AGENT_MESSAGE_FETCH_TIMEOUT: Duration = Duration::from_
1719
#[derive(Clone)]
1820
pub(crate) struct MessageHydrator {
1921
ai_client: Arc<dyn AIClient>,
22+
task_scoped_server_api: Option<Arc<ServerApi>>,
23+
task_id: Option<AmbientAgentTaskId>,
2024
#[cfg_attr(target_family = "wasm", allow(dead_code))]
2125
fetch_timeout: Duration,
2226
}
@@ -26,16 +30,40 @@ impl MessageHydrator {
2630
Self::with_fetch_timeout(ai_client, DEFAULT_AGENT_MESSAGE_FETCH_TIMEOUT)
2731
}
2832

33+
pub(crate) fn for_task(server_api: Arc<ServerApi>, task_id: AmbientAgentTaskId) -> Self {
34+
let ai_client: Arc<dyn AIClient> = server_api.clone();
35+
Self {
36+
ai_client,
37+
task_scoped_server_api: Some(server_api),
38+
task_id: Some(task_id),
39+
fetch_timeout: DEFAULT_AGENT_MESSAGE_FETCH_TIMEOUT,
40+
}
41+
}
42+
2943
pub(crate) fn with_fetch_timeout(
3044
ai_client: Arc<dyn AIClient>,
3145
fetch_timeout: Duration,
3246
) -> Self {
3347
Self {
3448
ai_client,
49+
task_scoped_server_api: None,
50+
task_id: None,
3551
fetch_timeout,
3652
}
3753
}
3854

55+
async fn read_message(&self, message_id: &str) -> Result<ReadAgentMessageResponse> {
56+
match (self.task_scoped_server_api.as_ref(), self.task_id) {
57+
(Some(server_api), Some(task_id)) => {
58+
server_api
59+
.read_agent_message_for_task(&task_id, message_id)
60+
.await
61+
}
62+
_ => self.ai_client.read_agent_message(message_id).await,
63+
}
64+
.with_context(|| format!("Failed to read agent message {message_id}"))
65+
}
66+
3967
pub(crate) async fn hydrate_event_for_recipient(
4068
&self,
4169
event: &AgentRunEvent,
@@ -55,6 +83,17 @@ impl MessageHydrator {
5583
return None;
5684
}
5785
};
86+
if message.body.is_empty() {
87+
log::warn!(
88+
"Hydrated empty-body agent message: message_id={} event_sequence={} recipient_run_id={} sender_run_id={} subject={:?} task_id={:?}",
89+
message.message_id,
90+
event.sequence,
91+
recipient_run_id,
92+
message.sender_run_id,
93+
message.subject,
94+
self.task_id.map(|task_id| task_id.to_string())
95+
);
96+
}
5897

5998
Some(ReceivedMessageInput {
6099
message_id: message.message_id,
@@ -70,15 +109,13 @@ impl MessageHydrator {
70109
&self,
71110
message_id: &str,
72111
) -> Result<ReadAgentMessageResponse> {
73-
let read_message = self.ai_client.read_agent_message(message_id);
112+
let read_message = self.read_message(message_id);
74113
let timeout = Timer::after(self.fetch_timeout);
75114
futures::pin_mut!(read_message);
76115
futures::pin_mut!(timeout);
77116

78117
match futures::future::select(read_message, timeout).await {
79-
Either::Left((result, _)) => {
80-
result.with_context(|| format!("Failed to read agent message {message_id}"))
81-
}
118+
Either::Left((result, _)) => result,
82119
Either::Right(_) => Err(anyhow!("Timed out reading agent message {message_id}")),
83120
}
84121
}
@@ -88,10 +125,7 @@ impl MessageHydrator {
88125
&self,
89126
message_id: &str,
90127
) -> Result<ReadAgentMessageResponse> {
91-
self.ai_client
92-
.read_agent_message(message_id)
93-
.await
94-
.with_context(|| format!("Failed to read agent message {message_id}"))
128+
self.read_message(message_id).await
95129
}
96130

97131
pub(crate) async fn read_message_from_event_with_timeout(
@@ -105,10 +139,15 @@ impl MessageHydrator {
105139
}
106140

107141
pub(crate) async fn mark_message_delivered(&self, message_id: &str) -> Result<()> {
108-
self.ai_client
109-
.mark_message_delivered(message_id)
110-
.await
111-
.with_context(|| format!("Failed to mark agent message {message_id} as delivered"))
142+
match (self.task_scoped_server_api.as_ref(), self.task_id) {
143+
(Some(server_api), Some(task_id)) => {
144+
server_api
145+
.mark_message_delivered_for_task(&task_id, message_id)
146+
.await
147+
}
148+
_ => self.ai_client.mark_message_delivered(message_id).await,
149+
}
150+
.with_context(|| format!("Failed to mark agent message {message_id} as delivered"))
112151
}
113152

114153
pub(crate) async fn mark_messages_delivered_best_effort<'a, I>(

0 commit comments

Comments
 (0)