first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
@@ -0,0 +1,159 @@
use url::Url;
use crate::ChannelState;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GithubAuthRedirectTarget {
SettingsEnvironments,
FocusCloudMode,
}
impl GithubAuthRedirectTarget {
fn next_path(self) -> &'static str {
match self {
Self::SettingsEnvironments => "settings/environments",
Self::FocusCloudMode => "action/focus_cloud_mode",
}
}
}
/// Indicates where the GitHub authorization flow was initiated from.
/// This affects the redirect URL used after auth completes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AuthSource {
/// Auth initiated from the settings page (default behavior: redirect to settings)
#[default]
Settings,
/// Auth initiated from cloud agent setup (skip redirect, just refresh in place)
CloudSetup,
}
#[derive(Clone, Copy, Debug)]
enum OAuthNextPlatform {
Native,
Web,
}
pub fn auth_url_with_next(
base_auth_url: &str,
target: GithubAuthRedirectTarget,
auth_source: AuthSource,
) -> String {
let scheme = oauth_next_scheme();
build_auth_url_with_next(base_auth_url, target, &scheme, auth_source)
}
pub fn settings_environments_auth_url_with_next(base_auth_url: &str) -> String {
auth_url_with_next(
base_auth_url,
GithubAuthRedirectTarget::SettingsEnvironments,
AuthSource::Settings,
)
}
pub fn cloud_setup_auth_url_with_next(base_auth_url: &str) -> String {
auth_url_with_next(
base_auth_url,
GithubAuthRedirectTarget::FocusCloudMode,
AuthSource::CloudSetup,
)
}
pub(crate) fn build_auth_url_with_next(
base_auth_url: &str,
target: GithubAuthRedirectTarget,
scheme: &str,
auth_source: AuthSource,
) -> String {
let Ok(mut url) = Url::parse(base_auth_url) else {
return base_auth_url.to_string();
};
let scheme_for_next = std::env::var("WARP_OAUTH_NEXT_SCHEME")
.ok()
.filter(|value| !value.is_empty())
.or_else(|| {
url.query_pairs()
.find(|(key, _)| key == "scheme")
.map(|(_, value)| value.into_owned())
})
.filter(|value| !value.is_empty())
.unwrap_or_else(|| scheme.to_string());
let platform = if cfg!(target_family = "wasm") {
OAuthNextPlatform::Web
} else {
OAuthNextPlatform::Native
};
let next_url = build_next_url(target, &scheme_for_next, auth_source, platform)
.unwrap_or_else(|| format!("{scheme_for_next}://{}", target.next_path()));
let existing_pairs = url
.query_pairs()
.filter(|(key, _)| key != "next")
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect::<Vec<_>>();
{
let mut query_pairs = url.query_pairs_mut();
query_pairs.clear();
for (key, value) in existing_pairs {
query_pairs.append_pair(&key, &value);
}
query_pairs.append_pair("next", &next_url);
}
url.to_string()
}
fn build_next_url(
target: GithubAuthRedirectTarget,
scheme_for_next: &str,
auth_source: AuthSource,
platform: OAuthNextPlatform,
) -> Option<String> {
match platform {
OAuthNextPlatform::Native => {
let base = format!("{scheme_for_next}://{}", target.next_path());
let mut url = Url::parse(&base).ok()?;
if matches!(auth_source, AuthSource::CloudSetup) {
url.query_pairs_mut()
.append_pair("source", crate::uri::CLOUD_SETUP_SOURCE);
}
Some(url.to_string())
}
OAuthNextPlatform::Web => {
let mut url = Url::parse(&ChannelState::server_root_url()).ok()?;
url.set_query(None);
match target {
GithubAuthRedirectTarget::SettingsEnvironments => {
url.set_path("/settings/environments");
{
let mut pairs = url.query_pairs_mut();
pairs.append_pair("oauth", "github");
if matches!(auth_source, AuthSource::CloudSetup) {
pairs.append_pair("source", crate::uri::CLOUD_SETUP_SOURCE);
}
}
}
GithubAuthRedirectTarget::FocusCloudMode => {
url.set_path("/action/focus_cloud_mode");
}
}
Some(url.to_string())
}
}
}
fn oauth_next_scheme() -> String {
if let Ok(override_value) = std::env::var("WARP_OAUTH_NEXT_SCHEME") {
if !override_value.is_empty() {
return override_value;
}
}
ChannelState::url_scheme().to_string()
}
+71 -24
View File
@@ -1,21 +1,24 @@
use std::fmt::Display;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use uuid::{NonNilUuid, Uuid};
use crate::ai::agent::conversation::{AIConversation, ConversationStatus};
use crate::ai::agent::{
AIAgentOutputStatus, CancellationReason, FinishedAIAgentOutput, RenderableAIError,
};
use serde::{Deserialize, Serialize};
use std::fmt::Display;
use std::str::FromStr;
use uuid::{NonNilUuid, Uuid};
pub mod github_auth_notifier;
pub mod github_auth_url;
pub mod scheduled;
pub mod spawn;
pub mod task;
pub mod telemetry;
pub use task::{
cancel_task_with_toast, AgentConfigSnapshot, AgentSource, AmbientAgentTask,
AmbientAgentTaskState, TaskStatusMessage,
cancel_task_silently, cancel_task_with_toast, AgentConfigSnapshot, AgentSource,
AmbientAgentLiveSessionState, AmbientAgentTask, AmbientAgentTaskState, TaskStatusMessage,
};
pub const OUT_OF_CREDITS_TASK_FAILURE_MESSAGE: &str =
"Out of credits. Upgrade your Warp plan to continue running cloud agents.";
@@ -73,25 +76,69 @@ pub enum AmbientConversationStatus {
pub fn conversation_output_status_from_conversation(
conversation: &AIConversation,
) -> Option<AmbientConversationStatus> {
if let ConversationStatus::Blocked { blocked_action } = conversation.status() {
return Some(AmbientConversationStatus::Blocked {
blocked_action: blocked_action.clone(),
});
}
match conversation.status() {
// A pending recovery is not a terminal outcome.
ConversationStatus::TransientError => None,
let last_exchange = conversation.root_task_exchanges().last()?;
if let AIAgentOutputStatus::Finished { finished_output } = &last_exchange.output_status {
let status = match finished_output {
FinishedAIAgentOutput::Cancelled { output: _, reason } => {
AmbientConversationStatus::Cancelled { reason: *reason }
ConversationStatus::Blocked { blocked_action } => {
Some(AmbientConversationStatus::Blocked {
blocked_action: blocked_action.clone(),
})
}
ConversationStatus::Error => {
// Prefer the structured error on the last exchange: it carries the precise
// error variant and rendering hints. Fall back to the conversation-level
// `status_error` for out-of-band failures (e.g. shell exit) recorded
// without an exchange. Both drive FAILED-vs-ERROR classification downstream.
if let Some(AIAgentOutputStatus::Finished {
finished_output: FinishedAIAgentOutput::Error { error, .. },
}) = conversation
.root_task_exchanges()
.last()
.map(|exchange| &exchange.output_status)
{
return Some(AmbientConversationStatus::Error {
error: error.clone(),
});
}
FinishedAIAgentOutput::Error { output: _, error } => AmbientConversationStatus::Error {
error: error.clone(),
},
FinishedAIAgentOutput::Success { output: _ } => AmbientConversationStatus::Success,
};
return Some(status);
}
if let Some(error) = conversation.status_error() {
return Some(AmbientConversationStatus::Error {
error: error.clone(),
});
}
// No structured error anywhere; fall back to whatever terminal outcome
// the last exchange carries.
terminal_status_from_last_exchange(conversation)
}
None
// `InProgress` and `WaitingForEvents` are not terminal, but we preserve the
// existing behavior of reporting a terminal outcome whenever the last exchange
// has already finished.
ConversationStatus::InProgress
| ConversationStatus::Success
| ConversationStatus::Cancelled
| ConversationStatus::WaitingForEvents => terminal_status_from_last_exchange(conversation),
}
}
/// Derive a terminal [`AmbientConversationStatus`] from the conversation's last
/// exchange, if that exchange has finished.
fn terminal_status_from_last_exchange(
conversation: &AIConversation,
) -> Option<AmbientConversationStatus> {
let AIAgentOutputStatus::Finished { finished_output } =
&conversation.root_task_exchanges().last()?.output_status
else {
return None;
};
Some(match finished_output {
FinishedAIAgentOutput::Cancelled { reason, .. } => {
AmbientConversationStatus::Cancelled { reason: *reason }
}
FinishedAIAgentOutput::Error { error, .. } => AmbientConversationStatus::Error {
error: error.clone(),
},
FinishedAIAgentOutput::Success { .. } => AmbientConversationStatus::Success,
})
}
+19 -90
View File
@@ -1,92 +1,29 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::future::Future;
use super::AgentConfigSnapshot;
use crate::{
cloud_object::{
model::{
generic_string_model::{GenericStringModel, GenericStringObjectId, StringModel},
json_model::{JsonModel, JsonSerializer},
persistence::CloudModel,
},
GenericCloudObject, GenericStringObjectFormat, GenericStringObjectUniqueKey,
JsonObjectType, Owner, Revision, ServerCloudObject,
},
drive::CloudObjectTypeAndId,
server::{
cloud_objects::update_manager::{
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
},
ids::{ClientId, SyncId},
server_api::ServerApiProvider,
sync_queue::QueueItem,
},
pub use cloud_object_models::{
CloudScheduledAmbientAgent, CloudScheduledAmbientAgentModel, ScheduledAmbientAgent,
};
use futures::channel::oneshot;
use futures::FutureExt;
use serde_json::{Map, Value};
use galaxy_graphql::queries::get_scheduled_agent_history::ScheduledAgentHistory;
use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity};
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
/// A ScheduledAmbientAgent represents configuration for ambient agents that run on a cron schedule.
pub struct ScheduledAmbientAgent {
/// Agent name
#[serde(default)]
pub name: String,
/// Cron schedule expression
#[serde(default)]
pub cron_schedule: String,
/// Whether the scheduled agent is enabled
#[serde(default)]
pub enabled: bool,
/// The prompt to use for the scheduled agent
#[serde(default)]
pub prompt: String,
/// The latest failure to execute this scheduled agent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_spawn_error: Option<String>,
/// Configuration for how the ambient agent should run.
#[serde(default, skip_serializing_if = "AgentConfigSnapshot::is_empty")]
pub agent_config: AgentConfigSnapshot,
}
pub type CloudScheduledAmbientAgent =
GenericCloudObject<GenericStringObjectId, CloudScheduledAmbientAgentModel>;
pub type CloudScheduledAmbientAgentModel =
GenericStringModel<ScheduledAmbientAgent, JsonSerializer>;
impl CloudScheduledAmbientAgent {
pub fn get_all(app: &AppContext) -> Vec<CloudScheduledAmbientAgent> {
CloudModel::as_ref(app)
.get_all_objects_of_type::<GenericStringObjectId, CloudScheduledAmbientAgentModel>()
.cloned()
.collect()
}
pub fn get_by_id<'a>(
sync_id: &'a SyncId,
app: &'a AppContext,
) -> Option<&'a CloudScheduledAmbientAgent> {
CloudModel::as_ref(app)
.get_object_of_type::<GenericStringObjectId, CloudScheduledAmbientAgentModel>(sync_id)
}
}
impl ScheduledAmbientAgent {
pub fn new(name: String, cron_schedule: String, enabled: bool, prompt: String) -> Self {
Self {
name,
cron_schedule,
enabled,
prompt,
last_spawn_error: None,
agent_config: Default::default(),
}
}
}
use crate::cloud_object::model::generic_string_model::StringModel;
use crate::cloud_object::model::json_model::JsonModel;
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::{
CloudObjectLookup as _, GenericStringObjectFormat, GenericStringObjectUniqueKey,
JsonObjectType, Owner, Revision,
};
use crate::drive::CloudObjectTypeAndId;
use crate::server::cloud_objects::update_manager::{
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
};
use crate::server::ids::{ClientId, SyncId};
use crate::server::server_api::ServerApiProvider;
use crate::server::sync_queue::QueueItem;
impl StringModel for ScheduledAmbientAgent {
type CloudObjectType = CloudScheduledAmbientAgent;
@@ -123,15 +60,6 @@ impl StringModel for ScheduledAmbientAgent {
None
}
fn new_from_server_update(&self, server_cloud_object: &ServerCloudObject) -> Option<Self> {
if let ServerCloudObject::ScheduledAmbientAgent(server_scheduled_agent) =
server_cloud_object
{
return Some(server_scheduled_agent.model.clone().string_model);
}
None
}
fn should_show_activity_toasts() -> bool {
false
}
@@ -230,6 +158,7 @@ impl ScheduledAgentManager {
fn handle_update_manager_event(
&mut self,
_: ModelHandle<UpdateManager>,
event: &UpdateManagerEvent,
_ctx: &mut ModelContext<Self>,
) {
+184 -46
View File
@@ -1,27 +1,38 @@
//! Stream-based API for spawning and monitoring ambient agents.
#![cfg_attr(target_family = "wasm", expect(dead_code))]
use std::{str::FromStr, sync::Arc, time::Duration};
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use futures::{select, FutureExt, Stream};
use anyhow::anyhow;
use futures::{select, FutureExt, Stream, StreamExt};
use session_sharing_protocol::common::SessionId;
use super::AmbientAgentTaskId;
use super::{AmbientAgentTask, AmbientAgentTaskState};
use crate::{
server::server_api::ai::{AIClient, SpawnAgentRequest, TaskStatusMessage},
terminal::shared_session,
use super::{AmbientAgentTask, AmbientAgentTaskId, AmbientAgentTaskState};
use crate::server::retry_strategies::with_bounded_retry;
use crate::server::server_api::ai::{
AIClient, RunFollowupRequest, SpawnAgentRequest, TaskStatusMessage,
};
use crate::terminal::shared_session;
/// How long to poll for the agent to be ready.
/// This should be long enough that the shared session will be joinable.
pub const TASK_STATUS_POLLING_DURATION: Duration = Duration::from_secs(80);
#[cfg(not(test))]
const TASK_STATUS_POLL_INTERVAL: Duration = Duration::from_secs(1);
const TASK_STATUS_POLL_INTERVAL: Duration = Duration::from_secs(3);
#[cfg(test)]
const TASK_STATUS_POLL_INTERVAL: Duration = Duration::from_millis(1);
/// Maximum number of consecutive polls that may observe a non-working, non-cancelled task
/// state for a follow-up before we give up and surface a failure. Bounds the worst-case
/// wait when the server is wedged and never transitions the task off its prior terminal
/// state. At the production `TASK_STATUS_POLL_INTERVAL` of 3s, 10 skipped observations
/// is ~30s — comfortably longer than the dispatcher's `ProcessingInterval` plus typical
/// worker claim latency.
const MAX_STALE_POLLS_BEFORE_FAILURE: usize = 10;
/// Information about a session join link for an ambient agent task.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SessionJoinInfo {
@@ -31,30 +42,22 @@ pub struct SessionJoinInfo {
impl SessionJoinInfo {
pub fn from_task(task: &AmbientAgentTask) -> Option<Self> {
// Prefer the server-provided session_link when available; it is a better signal
// that a session-sharing link is ready to be shown to the user.
if let Some(link) = task.session_link.as_ref().filter(|l| !l.is_empty()) {
let session_id = task
.session_id
.as_deref()
.and_then(|s| SessionId::from_str(s).ok());
return Some(Self {
session_id,
session_link: link.to_string(),
});
}
let run_execution = task.active_run_execution();
// The cloud-mode pane joins on `session_id`; a standalone `session_link` isn't
// actionable without it.
let session_id_str = run_execution.session_id?;
let session_id = SessionId::from_str(session_id_str).ok()?;
// Fallback to constructing a link from the session_id.
if let Some(session_id_str) = task.session_id.as_deref() {
if let Ok(session_id) = SessionId::from_str(session_id_str) {
return Some(Self {
session_id: Some(session_id),
session_link: shared_session::join_link(&session_id),
});
}
}
None
// Prefer the server-provided `session_link`; fall back to constructing one from
// `session_id`. `active_run_execution()` already filters out empty links.
let session_link = run_execution
.session_link
.map(String::from)
.unwrap_or_else(|| shared_session::join_link(&session_id));
Some(Self {
session_id: Some(session_id),
session_link,
})
}
}
@@ -80,6 +83,13 @@ pub enum AmbientAgentEvent {
AtCapacity,
}
enum RunPollMode {
InitialRun,
Followup {
previous_session_id: Option<SessionId>,
},
}
/// Spawns an ambient agent task and monitors its state.
///
/// The stream completes when:
@@ -113,16 +123,76 @@ pub fn spawn_task(
yield Ok(AmbientAgentEvent::AtCapacity);
}
let mut stream = Box::pin(poll_run_until_joinable_session(
task_id,
ai_client,
RunPollMode::InitialRun,
timeout,
));
while let Some(event) = stream.next().await {
yield event;
}
}
}
pub fn submit_run_followup(
message: String,
run_id: AmbientAgentTaskId,
previous_session_id: Option<SessionId>,
ai_client: Arc<dyn AIClient>,
timeout: Option<Duration>,
) -> impl Stream<Item = Result<AmbientAgentEvent, anyhow::Error>> {
async_stream::stream! {
let request = RunFollowupRequest { message };
if let Err(err) = ai_client.submit_run_followup(&run_id, request).await {
yield Err(err);
return;
}
let mut stream = Box::pin(poll_run_until_joinable_session(
run_id,
ai_client,
RunPollMode::Followup {
previous_session_id,
},
timeout,
));
while let Some(event) = stream.next().await {
yield event;
}
}
}
fn poll_run_until_joinable_session(
run_id: AmbientAgentTaskId,
ai_client: Arc<dyn AIClient>,
mode: RunPollMode,
timeout: Option<Duration>,
) -> impl Stream<Item = Result<AmbientAgentEvent, anyhow::Error>> {
async_stream::stream! {
// Poll for the task until it completes OR has session join info.
// We use a timeout to ensure we don't wait indefinitely for session info.
// If no timeout is provided, we use a future that never completes.
let mut timeout_timer = match timeout {
let mut timeout_timer = FutureExt::fuse(match timeout {
Some(d) => galaxyui::r#async::Timer::after(d),
None => galaxyui::r#async::Timer::never(),
}.fuse();
});
let mut last_state = None;
// For follow-ups, the server does NOT synchronously transition the task off its
// prior terminal state when `submit_run_followup` returns — the transition happens
// later, asynchronously, when the dispatcher loop picks up the newly enqueued
// execution and the worker claims it. If we treated the first observed state as
// the follow-up's outcome we'd misreport the prior run's `status_message` as a
// failure and end the stream, leaving the model permanently stuck in `Failed`
// even though the new run is actually about to start.
//
// To avoid that, gate event emission for follow-ups on having observed at least
// one working state. Initial spawns don't need this — they start from a fresh
// task whose first observation reflects the spawn itself.
let mut seen_working_state = matches!(&mode, RunPollMode::InitialRun);
let mut skipped_stale_polls: usize = 0;
loop {
let mut poll_timer = galaxyui::r#async::Timer::after(TASK_STATUS_POLL_INTERVAL).fuse();
let mut poll_timer = FutureExt::fuse(galaxyui::r#async::Timer::after(TASK_STATUS_POLL_INTERVAL));
select! {
_ = timeout_timer => {
@@ -130,9 +200,50 @@ pub fn spawn_task(
return;
}
_ = poll_timer => {
match ai_client.get_ambient_agent_task(&task_id).await {
// Wrap the status poll in with_bounded_retry so transient
// HTTP errors (429, 5xx) are retried with exponential
// backoff instead of immediately killing the CLI.
let poll_result = {
let client = ai_client.clone();
with_bounded_retry(
&format!("poll agent {run_id}"),
|| {
let client = client.clone();
async move { client.get_ambient_agent_task(&run_id).await }
},
)
.await
};
match poll_result {
Ok(task) => {
// Only emit a state-change event if the state has changed.
// Log every non-InProgress observation BEFORE the skip check
// so we retain visibility into stalls where the server is
// wedged on the prior terminal state.
if task.state != AmbientAgentTaskState::InProgress {
log::info!("Agent {run_id} state: {:?}", task.state);
}
if task.state.is_working() {
seen_working_state = true;
} else if !seen_working_state
&& task.state != AmbientAgentTaskState::Cancelled
&& skipped_stale_polls < MAX_STALE_POLLS_BEFORE_FAILURE
{
// Likely the prior run's residual terminal state; the
// server hasn't transitioned the task yet. Skip without
// emitting events or ending the stream.
//
// Carve-outs:
// - `Cancelled` always falls through: it's only reached
// via explicit user/admin/server cancellation and the
// server will never transition out of it on its own.
// - After `MAX_STALE_POLLS_BEFORE_FAILURE` skipped polls
// we give up so a wedged server can't keep the stream
// alive indefinitely.
skipped_stale_polls += 1;
continue;
}
if last_state.as_ref() != Some(&task.state) {
last_state = Some(task.state.clone());
yield Ok(AmbientAgentEvent::StateChanged {
@@ -141,23 +252,50 @@ pub fn spawn_task(
});
}
// Check if the task has completed or started sharing its session.
if task.state.is_terminal() {
// Task completed, stream ends.
if matches!(&mode, RunPollMode::Followup { .. }) {
let exhausted_stale_skips = !seen_working_state
&& skipped_stale_polls >= MAX_STALE_POLLS_BEFORE_FAILURE;
let message = if exhausted_stale_skips {
"Cloud follow-up did not start in time".to_string()
} else {
task.status_message
.as_ref()
.map(|msg| msg.message.clone())
.unwrap_or_else(|| {
if task.state.is_failure_like() {
"Cloud agent failed".to_string()
} else {
"Cloud follow-up finished before a new session became available".to_string()
}
})
};
yield Err(anyhow!(message));
}
return;
}
if task.state == AmbientAgentTaskState::InProgress {
if let Some(session_join_info) = SessionJoinInfo::from_task(&task) {
yield Ok(AmbientAgentEvent::SessionStarted {
session_join_info,
});
return;
let has_new_session = match &mode {
RunPollMode::InitialRun
| RunPollMode::Followup {
previous_session_id: None,
} => true,
RunPollMode::Followup {
previous_session_id: Some(previous_session_id),
} => session_join_info
.session_id
.as_ref()
.is_some_and(|session_id| session_id != previous_session_id),
};
if has_new_session {
yield Ok(AmbientAgentEvent::SessionStarted {
session_join_info,
});
return;
}
}
// Continue polling.
} else {
log::info!("Agent {task_id} state: {:?}", task.state);
// Continue polling.
}
}
Err(err) => {
File diff suppressed because it is too large Load Diff
+230 -150
View File
@@ -1,153 +1,42 @@
//! Ambient agent task types and utilities.
use anyhow::anyhow;
use chrono::{DateTime, Utc};
use galaxy_cli::agent::Harness;
use chrono::{DateTime, Duration as ChronoDuration, Utc};
#[cfg(not(target_family = "wasm"))]
pub use cloud_object_models::HarnessModelConfig;
pub use cloud_object_models::{AgentConfigSnapshot, HarnessAuthSecretsConfig, HarnessConfig};
use iso8601_duration::Duration as Iso8601Duration;
use serde::{Deserialize, Serialize};
use session_sharing_protocol::common::SessionId;
use url::Url;
use galaxy_core::report_error;
use galaxy_core::ui::theme::GalaxyTheme;
use galaxy_core::ui::theme::WarpTheme;
use galaxyui::color::ColorU;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use galaxyui::{SingletonEntity, View, ViewContext};
use super::AmbientAgentTaskId;
use crate::ai::artifacts::{deserialize_artifacts, Artifact};
use crate::server::server_api::ServerApiProvider;
use crate::ui_components::icons::Icon;
use crate::view_components::DismissibleToast;
use crate::workspace::ToastStack;
use galaxyui::{SingletonEntity, View, ViewContext};
use super::AmbientAgentTaskId;
/// Runtime configuration snapshot for agent execution.
///
/// This is the merged/resolved config used when spawning or running an agent.
/// It combines settings from config files and CLI args.
/// Unlike `AgentConfig` (the cloud model), field names here use the runtime format
/// (e.g. `model_id` instead of `base_model_id`).
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct AgentConfigSnapshot {
/// Config name for searchability/traceability.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_prompt: Option<String>,
/// MCP server configuration map (unwrapped; no `mcpServers` wrapper).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp_servers: Option<serde_json::Map<String, serde_json::Value>>,
/// Profile ID for local agent runs. This configures the terminal session
/// with the specified execution profile. Only used for local runs, not cloud runs.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_id: Option<String>,
/// Self-hosted worker ID that should execute this task.
/// If None or Some("warp"), the task will be dispatched to Warp-hosted (Namespace) workers.
/// Otherwise, the task will only be assigned to a connected self-hosted worker with matching ID.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_host: Option<String>,
/// Skill spec to use as the base prompt for the agent.
/// Format: "skill_name", "repo:skill_name", or "org/repo:skill_name".
/// The skill is resolved at runtime in the agent environment.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skill_spec: Option<String>,
/// Whether computer use is enabled for this agent run.
/// If None, the default behavior is used.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub computer_use_enabled: Option<bool>,
/// Execution harness for the agent run.
/// If None, we use Warp's default ("oz").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub harness: Option<HarnessConfig>,
/// Authentication secrets for third-party harnesses.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub harness_auth_secrets: Option<HarnessAuthSecretsConfig>,
fn parse_session_id_from_link(session_link: &str) -> Option<SessionId> {
Url::parse(session_link).ok().and_then(|url| {
url.path_segments()
.into_iter()
.flatten()
.last()
.and_then(|segment| segment.parse().ok())
})
}
/// Configuration for a third-party execution harness.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct HarnessConfig {
/// The harness type, e.g. [`Harness::Claude`].
#[serde(
rename = "type",
serialize_with = "serialize_harness",
deserialize_with = "deserialize_harness"
)]
pub harness_type: Harness,
fn parse_execution_session_id(execution: RunExecution<'_>) -> Option<SessionId> {
execution
.session_id
.and_then(|id| id.parse().ok())
.or_else(|| execution.session_link.and_then(parse_session_id_from_link))
}
impl HarnessConfig {
/// Builds a harness config from just the harness type.
pub fn from_harness_type(harness_type: Harness) -> Self {
Self { harness_type }
}
}
/// Parses a harness type name (e.g. `"claude"`) into a [`Harness`] variant.
/// Unknown values fall back to [`Harness::Unknown`] so we don't
/// misrepresent a future-server harness as Oz; UI surfaces should treat
/// `Unknown` as a non-Oz, non-runnable harness.
pub(crate) fn harness_from_name(name: &str) -> Harness {
match name {
"claude" => Harness::Claude,
"opencode" => Harness::OpenCode,
"gemini" => Harness::Gemini,
"oz" => Harness::Oz,
other => {
log::warn!("Unknown harness config name: {other:?}; treating as Unknown");
Harness::Unknown
}
}
}
fn serialize_harness<S: Serializer>(harness: &Harness, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&harness.to_string())
}
fn deserialize_harness<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Harness, D::Error> {
let name = String::deserialize(deserializer)?;
Ok(harness_from_name(&name))
}
/// Authentication secrets for third-party harnesses.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct HarnessAuthSecretsConfig {
/// Name of a managed secret for Claude Code harness authentication.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_auth_secret_name: Option<String>,
}
impl AgentConfigSnapshot {
/// Returns true if this config is empty (no options are set).
pub fn is_empty(&self) -> bool {
let Self {
name,
environment_id,
model_id,
base_prompt,
mcp_servers,
profile_id,
worker_host,
skill_spec,
computer_use_enabled,
harness,
harness_auth_secrets,
} = self;
name.is_none()
&& environment_id.is_none()
&& model_id.is_none()
&& base_prompt.is_none()
&& mcp_servers.is_none()
&& profile_id.is_none()
&& worker_host.is_none()
&& skill_spec.is_none()
&& computer_use_enabled.is_none()
&& harness.is_none()
&& harness_auth_secrets.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AgentSource {
Linear,
@@ -185,10 +74,24 @@ impl AgentSource {
AgentSource::Slack => "Slack",
AgentSource::Cli => "CLI",
AgentSource::ScheduledAgent => "Scheduled",
AgentSource::Interactive => "Warp (local agent)",
AgentSource::Interactive | AgentSource::CloudMode => "Warp App",
AgentSource::WebApp => "Oz Web",
AgentSource::GitHubAction => "GitHub Action",
AgentSource::CloudMode => "Warp (cloud agent)",
}
}
/// Returns true when tasks from this source must not accept user-triggered cloud follow-ups.
pub fn blocks_cloud_followups(&self) -> bool {
match self {
AgentSource::GitHubAction => true,
AgentSource::Linear
| AgentSource::AgentWebhook
| AgentSource::Slack
| AgentSource::Cli
| AgentSource::ScheduledAgent
| AgentSource::Interactive
| AgentSource::WebApp
| AgentSource::CloudMode => false,
}
}
@@ -247,12 +150,16 @@ pub struct AmbientAgentTask {
pub created_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub run_time: Option<Iso8601Duration>,
pub status_message: Option<TaskStatusMessage>,
#[serde(default, deserialize_with = "deserialize_ambient_agent_source")]
pub source: Option<AgentSource>,
pub session_id: Option<String>,
pub session_link: Option<String>,
pub creator: Option<TaskCreatorInfo>,
pub creator: Option<TaskPrincipalInfo>,
#[serde(default)]
pub executor: Option<TaskPrincipalInfo>,
pub conversation_id: Option<String>,
pub request_usage: Option<RequestUsage>,
pub is_sandbox_running: bool,
@@ -278,6 +185,35 @@ pub struct AmbientAgentTask {
pub children: Vec<String>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RunExecution<'a> {
pub session_id: Option<&'a str>,
pub session_link: Option<&'a str>,
pub request_usage: Option<&'a RequestUsage>,
pub is_sandbox_running: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AmbientAgentLiveSessionState {
/// The task does not currently have a running execution with a joinable session signal.
Inactive,
/// The task has a running execution, but this client does not have a parsed
/// shared-session id it can attach to.
ActiveUnattachable,
/// The task has a running execution and this client can attach to its shared session.
Attachable { session_id: SessionId },
}
impl RunExecution<'_> {
pub fn has_joinable_session(&self) -> bool {
self.session_id.is_some() || self.session_link.is_some()
}
pub fn is_active(&self) -> bool {
self.is_sandbox_running && self.has_joinable_session()
}
}
/// Represents a single attachment input from the client (e.g., file upload)
#[derive(Clone, Debug, Serialize)]
pub struct AttachmentInput {
@@ -295,19 +231,115 @@ pub struct TaskAttachment {
pub mime_type: String,
}
/// Returns the trimmed orchestrator agent name, or `None` when empty / whitespace-only.
pub fn normalize_orchestrator_agent_name(raw: &str) -> Option<String> {
let trimmed = raw.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
impl AmbientAgentTask {
/// Total credits used (inference + compute).
pub fn credits_used(&self) -> Option<f32> {
self.request_usage
.as_ref()
.map(|u| (u.inference_cost.unwrap_or(0.0) + u.compute_cost.unwrap_or(0.0)) as f32)
pub fn run_id(&self) -> AmbientAgentTaskId {
self.task_id
}
/// Duration from started_at to updated_at.
pub fn run_time(&self) -> Option<chrono::Duration> {
let started = self.started_at?;
let duration = self.updated_at.signed_duration_since(started);
(duration.num_seconds() >= 0).then_some(duration)
/// Returns the short label for this task: trimmed `agent_config_snapshot.name`,
/// trimmed `title`, or `"Agent"`.
pub fn display_name(&self) -> &str {
if let Some(name) = self
.agent_config_snapshot
.as_ref()
.and_then(|c| c.name.as_deref())
{
let trimmed = name.trim();
if !trimmed.is_empty() {
return trimmed;
}
}
let trimmed_title = self.title.trim();
if !trimmed_title.is_empty() {
return trimmed_title;
}
"Agent"
}
pub fn conversation_id(&self) -> Option<&str> {
self.conversation_id.as_deref()
}
/// Returns true when this task's source must not accept user-triggered cloud follow-ups.
pub fn blocks_cloud_followups(&self) -> bool {
self.source
.as_ref()
.is_some_and(AgentSource::blocks_cloud_followups)
}
pub fn active_run_execution(&self) -> RunExecution<'_> {
RunExecution {
session_id: self.session_id.as_deref(),
session_link: self.session_link.as_deref().filter(|link| !link.is_empty()),
request_usage: self.request_usage.as_ref(),
is_sandbox_running: self.is_sandbox_running,
}
}
pub fn active_execution_session_id(&self) -> Option<&str> {
let execution = self.active_run_execution();
if self.state == AmbientAgentTaskState::InProgress && execution.is_active() {
execution.session_id
} else {
None
}
}
/// Returns the canonical live-session state for this task from the client's perspective.
///
/// This separates task liveness from attachability: an in-progress task can have an active
/// execution without a usable shared-session id, and callers should not treat that as a
/// completed transcript/follow-up state.
pub fn active_live_session_state(&self) -> AmbientAgentLiveSessionState {
let execution = self.active_run_execution();
if self.state != AmbientAgentTaskState::InProgress || !execution.is_active() {
return AmbientAgentLiveSessionState::Inactive;
}
match parse_execution_session_id(execution) {
Some(session_id) => AmbientAgentLiveSessionState::Attachable { session_id },
None => AmbientAgentLiveSessionState::ActiveUnattachable,
}
}
pub fn active_execution_conversation_id(&self) -> Option<&str> {
if self.has_active_execution() {
self.conversation_id()
} else {
None
}
}
pub fn has_active_execution(&self) -> bool {
self.state == AmbientAgentTaskState::InProgress && self.active_run_execution().is_active()
}
pub fn is_terminal_run_state(&self) -> bool {
self.state.is_terminal()
}
pub fn can_submit_cloud_followup(&self) -> bool {
self.is_terminal_run_state() && !self.has_active_execution()
}
/// Total credits used (inference + compute + platform).
pub fn credits_used(&self) -> Option<f32> {
self.active_run_execution().request_usage.map(|u| {
(u.inference_cost.unwrap_or(0.0)
+ u.compute_cost.unwrap_or(0.0)
+ u.platform_cost.unwrap_or(0.0)) as f32
})
}
/// Server-reported run duration.
pub fn run_time(&self) -> Option<ChronoDuration> {
self.run_time.and_then(|run_time| run_time.to_chrono())
}
/// Creator's display name, if available.
@@ -315,9 +347,14 @@ impl AmbientAgentTask {
self.creator.as_ref().and_then(|c| c.display_name.clone())
}
/// Principal the run executed as, formatted for user-facing surfaces.
pub fn executor_display_name(&self) -> Option<String> {
self.executor.as_ref().and_then(|e| e.display_name.clone())
}
/// Returns true if the underlying session for the ambient agent is no longer running.
pub fn is_no_longer_running(&self) -> bool {
!self.is_sandbox_running && !self.state.is_working()
!self.active_run_execution().is_sandbox_running && !self.state.is_working()
}
}
@@ -443,7 +480,7 @@ impl std::fmt::Display for AmbientAgentTaskState {
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub struct TaskCreatorInfo {
pub struct TaskPrincipalInfo {
#[serde(rename = "type")]
pub creator_type: String,
pub uid: String,
@@ -453,12 +490,38 @@ pub struct TaskCreatorInfo {
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub struct TaskStatusMessage {
pub message: String,
#[serde(default, alias = "errorCode")]
pub error_code: Option<TaskStatusErrorCode>,
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatusErrorCode {
#[serde(alias = "ENVIRONMENT_SETUP_FAILED")]
EnvironmentSetupFailed,
#[serde(other)]
Unknown,
}
impl TaskStatusErrorCode {
pub fn is_environment_setup_failure(&self) -> bool {
matches!(self, TaskStatusErrorCode::EnvironmentSetupFailed)
}
}
impl TaskStatusMessage {
pub fn is_environment_setup_failure(&self) -> bool {
self.error_code
.as_ref()
.is_some_and(TaskStatusErrorCode::is_environment_setup_failure)
}
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub struct RequestUsage {
pub inference_cost: Option<f64>,
pub compute_cost: Option<f64>,
pub platform_cost: Option<f64>,
}
/// Cancel an ambient agent task and show a toast with the result.
@@ -482,3 +545,20 @@ pub fn cancel_task_with_toast<V: View>(task_id: AmbientAgentTaskId, ctx: &mut Vi
},
);
}
/// Cancel an ambient agent task without surfacing a toast to the user.
pub fn cancel_task_silently<V: View>(task_id: AmbientAgentTaskId, ctx: &mut ViewContext<V>) {
let ai_client = ServerApiProvider::handle(ctx).as_ref(ctx).get_ai_client();
ctx.spawn(
async move { ai_client.cancel_ambient_agent_task(&task_id).await },
move |_view, result, _| {
if let Err(e) = result {
log::error!("Failed to cancel task: {e}");
}
},
);
}
#[cfg(test)]
#[path = "task_tests.rs"]
mod tests;
+145
View File
@@ -0,0 +1,145 @@
use chrono::{Duration, Utc};
use serde_json::{json, Value};
use super::{
AgentConfigSnapshot, AmbientAgentTask, AmbientAgentTaskState, TaskStatusErrorCode,
TaskStatusMessage,
};
fn make_task(snapshot_name: Option<&str>, title: &str) -> AmbientAgentTask {
let now = Utc::now();
let agent_config_snapshot = snapshot_name.map(|name| AgentConfigSnapshot {
name: Some(name.to_string()),
..Default::default()
});
AmbientAgentTask {
task_id: "11111111-1111-1111-1111-111111111111".parse().unwrap(),
parent_run_id: None,
title: title.to_string(),
state: AmbientAgentTaskState::InProgress,
prompt: String::new(),
created_at: now,
started_at: Some(now),
updated_at: now,
run_time: Some("PT1S".parse().unwrap()),
status_message: None,
source: None,
session_id: None,
session_link: None,
creator: None,
executor: None,
conversation_id: None,
request_usage: None,
is_sandbox_running: false,
agent_config_snapshot,
artifacts: vec![],
last_event_sequence: None,
children: vec![],
}
}
fn task_json_with_run_time(run_time_key: &str, run_time: Value) -> Value {
let now = Utc::now().to_rfc3339();
let mut task = json!({
"task_id": "11111111-1111-1111-1111-111111111111",
"title": "Task",
"state": "SUCCEEDED",
"prompt": "test",
"created_at": now,
"started_at": now,
"updated_at": now,
"status_message": null,
"session_id": null,
"session_link": null,
"creator": null,
"conversation_id": null,
"request_usage": null,
"is_sandbox_running": false
});
task[run_time_key] = run_time;
task
}
#[test]
fn display_name_prefers_agent_config_snapshot_name_over_title() {
let task = make_task(Some("frontend-tests"), "Long descriptive task title");
assert_eq!(task.display_name(), "frontend-tests");
}
#[test]
fn display_name_falls_back_to_title_when_snapshot_name_is_missing() {
let task = make_task(None, "Long descriptive task title");
assert_eq!(task.display_name(), "Long descriptive task title");
}
#[test]
fn display_name_falls_back_to_title_when_snapshot_name_is_whitespace() {
let task = make_task(Some(" "), "Long descriptive task title");
assert_eq!(task.display_name(), "Long descriptive task title");
}
#[test]
fn display_name_returns_literal_agent_when_both_sources_are_empty() {
let task = make_task(None, "");
assert_eq!(task.display_name(), "Agent");
}
#[test]
fn display_name_returns_literal_agent_for_whitespace_only_title() {
let task = make_task(None, " \t\n ");
assert_eq!(task.display_name(), "Agent");
}
#[test]
fn display_name_trims_whitespace_at_each_layer() {
let task = make_task(Some(" frontend-tests "), " Long descriptive title ");
assert_eq!(task.display_name(), "frontend-tests");
let task = make_task(None, " Long descriptive title ");
assert_eq!(task.display_name(), "Long descriptive title");
}
#[test]
fn task_status_error_code_deserializes_public_api_casing() {
let message: TaskStatusMessage = serde_json::from_str(
"{\"message\":\"setup failed\",\"error_code\":\"environment_setup_failed\"}",
)
.unwrap();
assert_eq!(
message.error_code,
Some(TaskStatusErrorCode::EnvironmentSetupFailed)
);
assert!(message.is_environment_setup_failure());
}
#[test]
fn task_status_error_code_deserializes_graphql_casing() {
let message: TaskStatusMessage = serde_json::from_str(
"{\"message\":\"setup failed\",\"errorCode\":\"ENVIRONMENT_SETUP_FAILED\"}",
)
.unwrap();
assert_eq!(
message.error_code,
Some(TaskStatusErrorCode::EnvironmentSetupFailed)
);
assert!(message.is_environment_setup_failure());
}
#[test]
fn task_status_error_code_deserializes_unknown_codes() {
let message: TaskStatusMessage =
serde_json::from_str("{\"message\":\"failed\",\"error_code\":\"new_error\"}").unwrap();
assert_eq!(message.error_code, Some(TaskStatusErrorCode::Unknown));
assert!(!message.is_environment_setup_failure());
}
#[test]
fn ambient_agent_task_deserializes_run_time_iso8601() {
let task: AmbientAgentTask =
serde_json::from_value(task_json_with_run_time("run_time", json!("PT2M30S"))).unwrap();
assert_eq!(task.run_time(), Some(Duration::seconds(150)));
}
+109 -3
View File
@@ -1,10 +1,9 @@
use crate::server::ids::ServerId;
use galaxy_core::features::FeatureFlag;
use galaxy_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
use serde::Serialize;
use serde_json::{json, Value};
use strum_macros::{EnumDiscriminants, EnumIter};
use crate::server::ids::ServerId;
/// The entry point through which Cloud Mode was entered.
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
@@ -19,6 +18,41 @@ pub enum CloudModeEntryPoint {
EntryBlock,
}
/// The entry point through which a local-to-cloud handoff was initiated.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum HandoffEntryPoint {
/// User typed `&` in the input to enter handoff compose mode.
#[default]
Ampersand,
/// User used the `/handoff` slash command.
SlashCommand,
/// User clicked the "Hand off to cloud" chip in the footer toolbar.
FooterChip,
/// The client automatically initiated handoff for an eligible local agent.
Automatic,
}
/// Describes which synthetic-input path drives an empty-prompt handoff.
/// Captured at handoff initiation so telemetry reflects the intended path
/// regardless of whether the snapshot derivation later produces content.
#[cfg_attr(target_family = "wasm", allow(dead_code))]
#[derive(Clone, Copy, Debug, Default, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum HandoffInjectionPath {
/// The handoff carried a non-empty user prompt; no client-side injection.
#[default]
None,
/// Empty prompt + in-progress source. The client substituted `"Continue"`
/// on the wire so the cloud agent picks up where the local agent left off.
Continue,
/// Empty prompt + idle source. The client substituted
/// `"Apply the workspace changes from my previous session."` on the wire
/// alongside the snapshot token; the cloud agent's first user-role turn
/// carries an intent for the rehydrated workspace state.
SnapshotRehydration,
}
/// Telemetry events for client interactions with cloud agents.
#[derive(Debug, EnumDiscriminants)]
#[strum_discriminants(derive(EnumIter))]
@@ -69,6 +103,41 @@ pub enum CloudAgentTelemetryEvent {
/// Error message describing the failure.
error: String,
},
/// User initiated a local-to-cloud handoff.
#[cfg_attr(target_family = "wasm", allow(dead_code))]
HandoffInitiated {
/// How the handoff was triggered.
entry_point: HandoffEntryPoint,
/// Whether the handoff forked an existing conversation.
forked_existing_conversation: bool,
/// Whether the user submitted with an empty prompt buffer.
empty_prompt: bool,
/// Which synthetic-input path drives this submission (relevant only
/// when `empty_prompt` is true; always `None` otherwise). Captured at
/// handoff initiation, before snapshot derivation has settled — the
/// `HandoffSnapshotPrepared` event reports the actual snapshot result.
injection_path: HandoffInjectionPath,
},
/// The async snapshot-upload pipeline that backs a handoff has settled.
/// Fires once per handoff after `derive_touched_workspace` completes.
/// Pair with `HandoffInitiated` on the same run to learn whether the
/// `SnapshotRehydration` injection path actually carried snapshot content.
#[cfg_attr(target_family = "wasm", allow(dead_code))]
HandoffSnapshotPrepared {
/// True when the derived `TouchedWorkspace` had at least one repo or
/// orphan file. Reports what the snapshot pipeline produced; the upload
/// itself may still fail downstream, so the wire prompt is not implied.
derived_workspace_had_content: bool,
},
/// The auto-handoff sleep discoverability prompt was surfaced on wake.
#[cfg_attr(target_family = "wasm", allow(dead_code))]
SleepPromptShown,
/// User clicked "Enable" on the auto-handoff sleep prompt.
#[cfg_attr(target_family = "wasm", allow(dead_code))]
SleepPromptEnabled,
/// User clicked "Dismiss" on the auto-handoff sleep prompt.
#[cfg_attr(target_family = "wasm", allow(dead_code))]
SleepPromptDismissed,
}
impl TelemetryEvent for CloudAgentTelemetryEvent {
@@ -108,6 +177,25 @@ impl TelemetryEvent for CloudAgentTelemetryEvent {
CloudAgentTelemetryEvent::DispatchFailed { error } => Some(json!({
"error": error,
})),
CloudAgentTelemetryEvent::HandoffInitiated {
entry_point,
forked_existing_conversation,
empty_prompt,
injection_path,
} => Some(json!({
"entry_point": entry_point,
"forked_existing_conversation": forked_existing_conversation,
"empty_prompt": empty_prompt,
"injection_path": injection_path,
})),
CloudAgentTelemetryEvent::HandoffSnapshotPrepared {
derived_workspace_had_content,
} => Some(json!({
"derived_workspace_had_content": derived_workspace_had_content,
})),
CloudAgentTelemetryEvent::SleepPromptShown
| CloudAgentTelemetryEvent::SleepPromptEnabled
| CloudAgentTelemetryEvent::SleepPromptDismissed => None,
}
}
@@ -149,6 +237,11 @@ impl TelemetryEventDesc for CloudAgentTelemetryEventDiscriminants {
"AmbientAgent.CloudMode.EnvironmentSettings.GitHubAuth"
}
Self::DispatchFailed => "AmbientAgent.DispatchFailed",
Self::HandoffInitiated => "AmbientAgent.Handoff.Initiated",
Self::HandoffSnapshotPrepared => "AmbientAgent.Handoff.SnapshotPrepared",
Self::SleepPromptShown => "AmbientAgent.Handoff.SleepPrompt.Shown",
Self::SleepPromptEnabled => "AmbientAgent.Handoff.SleepPrompt.Enabled",
Self::SleepPromptDismissed => "AmbientAgent.Handoff.SleepPrompt.Dismissed",
}
}
@@ -170,6 +263,19 @@ impl TelemetryEventDesc for CloudAgentTelemetryEventDiscriminants {
"User started GitHub authentication from the environment form"
}
Self::DispatchFailed => "Ambient agent failed to dispatch or encountered an error",
Self::HandoffInitiated => "User initiated a local-to-cloud handoff",
Self::HandoffSnapshotPrepared => {
"Handoff snapshot upload settled; reports whether it carried content"
}
Self::SleepPromptShown => {
"The auto-handoff sleep discoverability prompt was shown on wake"
}
Self::SleepPromptEnabled => {
"User enabled auto-handoff on sleep from the discoverability prompt"
}
Self::SleepPromptDismissed => {
"User dismissed the auto-handoff sleep discoverability prompt"
}
}
}