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
+118 -31
View File
@@ -1,17 +1,21 @@
use std::collections::{HashMap, HashSet};
use chrono::{DateTime, Utc};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent};
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::terminal::model::session::active_session::ActiveSession;
use galaxyui::{
AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity, WeakModelHandle,
WindowId,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_conversations_model::{AgentConversationEntry, AgentConversationEntryId};
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent};
use crate::ai::blocklist::orchestration_event_streamer::{
register_agent_event_consumer, unregister_agent_event_consumer,
};
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::terminal::model::session::active_session::ActiveSession;
/// Contains the handles needed to track an active agent view.
struct ActiveAgentViewHandles {
controller: WeakModelHandle<AgentViewController>,
@@ -44,6 +48,17 @@ pub enum ConversationOrTaskId {
TaskId(AmbientAgentTaskId),
}
impl From<AgentConversationEntryId> for ConversationOrTaskId {
fn from(id: AgentConversationEntryId) -> Self {
match id {
AgentConversationEntryId::Conversation(conversation_id) => {
ConversationOrTaskId::ConversationId(conversation_id)
}
AgentConversationEntryId::AmbientRun(task_id) => ConversationOrTaskId::TaskId(task_id),
}
}
}
impl ConversationOrTaskId {
pub fn conversation_id(&self) -> Option<AIConversationId> {
match self {
@@ -94,6 +109,34 @@ impl ActiveAgentViewsModel {
}
}
fn update_focused_conversation_for_terminal(
&mut self,
terminal_view_id: EntityId,
conversation_id: Option<ConversationOrTaskId>,
) {
for focused_terminal_state in self.focused_terminal_states.values_mut() {
if focused_terminal_state.focused_terminal_id == terminal_view_id
&& !matches!(
focused_terminal_state.active_conversation_id,
Some(ConversationOrTaskId::TaskId(_))
)
{
focused_terminal_state.active_conversation_id = conversation_id;
}
}
if let Some(last_focused_terminal_state) = &mut self.last_focused_terminal_state {
if last_focused_terminal_state.focused_terminal_id == terminal_view_id
&& !matches!(
last_focused_terminal_state.active_conversation_id,
Some(ConversationOrTaskId::TaskId(_))
)
{
last_focused_terminal_state.active_conversation_id = conversation_id;
}
}
}
/// Register an agent view controller to track when the agent view is entered/exited.
#[cfg_attr(target_family = "wasm", allow(dead_code))]
pub fn register_agent_view_controller(
@@ -122,7 +165,19 @@ impl ActiveAgentViewsModel {
},
);
ctx.subscribe_to_model(controller, move |model, event, ctx| match event {
// On pane re-attach the controller's `agent_view_state` is still
// `Active` while the unregister path has already torn down the
// streamer consumer. Re-register here; the `EnteredAgentView`
// subscription only fires on subsequent state transitions.
if let Some(conversation_id) = controller
.as_ref(ctx)
.agent_view_state()
.active_conversation_id()
{
register_agent_event_consumer(conversation_id, terminal_view_id, ctx);
}
ctx.subscribe_to_model(controller, move |model, _, event, ctx| match event {
AgentViewControllerEvent::EnteredAgentView {
conversation_id, ..
} => {
@@ -132,37 +187,31 @@ impl ActiveAgentViewsModel {
// Update the focused conversation in whichever window owns this terminal view.
// We ignore agent view changes if we are focused on an ambient conversation,
// as ambient conversation navigation operates at the task level instead of the conversation level.
for focused_terminal_state in model.focused_terminal_states.values_mut() {
if focused_terminal_state.focused_terminal_id == terminal_view_id
&& !matches!(
focused_terminal_state.active_conversation_id,
Some(ConversationOrTaskId::TaskId(_))
)
{
focused_terminal_state.active_conversation_id = Some(conv_id);
}
}
model.update_focused_conversation_for_terminal(terminal_view_id, Some(conv_id));
// Bridge the controller's lifecycle into the streamer's
// per-conversation consumer registry.
register_agent_event_consumer(*conversation_id, terminal_view_id, ctx);
// Emit so subscribers can move this conversation to the Active section.
ctx.emit(ActiveAgentViewsEvent::TerminalViewFocused);
}
AgentViewControllerEvent::ExitedAgentView {
conversation_id, ..
conversation_id,
is_exit_before_new_entrance,
..
} => {
// Skip if this exit is part of an in-place switch — the follow-up
// entrance will register the new conversation's consumer.
if *is_exit_before_new_entrance {
return;
}
model
.last_opened_times
.remove(&ConversationOrTaskId::ConversationId(*conversation_id));
// Clear the focused conversation in whichever window owns this terminal view.
for state in model.focused_terminal_states.values_mut() {
if state.focused_terminal_id == terminal_view_id
&& !matches!(
state.active_conversation_id,
Some(ConversationOrTaskId::TaskId(_))
)
{
state.active_conversation_id = None;
}
}
model.update_focused_conversation_for_terminal(terminal_view_id, None);
unregister_agent_event_consumer(*conversation_id, terminal_view_id, ctx);
// Emit so subscribers can move this conversation to the Past section.
ctx.emit(ActiveAgentViewsEvent::ConversationClosed {
conversation_id: *conversation_id,
@@ -198,6 +247,9 @@ impl ActiveAgentViewsModel {
}
if let Some(conversation_id) = closed_conversation_id {
// The pane-close path bypasses exit_agent_view_internal, so
// unregister the streamer consumer here.
unregister_agent_event_consumer(conversation_id, terminal_pane_id, ctx);
ctx.emit(ActiveAgentViewsEvent::ConversationClosed { conversation_id });
}
}
@@ -252,6 +304,12 @@ impl ActiveAgentViewsModel {
.and_then(|state| state.active_conversation_id)
}
pub fn get_focused_terminal_view_id(&self, window_id: WindowId) -> Option<EntityId> {
self.focused_terminal_states
.get(&window_id)
.map(|state| state.focused_terminal_id)
}
/// Get the last focused terminal view id (persisted across non-terminal focus changes).
pub fn get_last_focused_terminal_id(&self) -> Option<EntityId> {
self.last_focused_terminal_state
@@ -259,6 +317,14 @@ impl ActiveAgentViewsModel {
.map(|state| state.focused_terminal_id)
}
/// Get the most recent focused conversation or ambient task ID, persisted
/// across non-terminal focus changes.
pub fn get_last_focused_conversation(&self) -> Option<ConversationOrTaskId> {
self.last_focused_terminal_state
.as_ref()
.and_then(|state| state.active_conversation_id)
}
/// Returns the focused conversation ID if it's a new/empty conversation view.
/// Only returns Some if the focused agent view was just created to start a new
/// conversation (i.e. has no exchanges yet).
@@ -307,6 +373,8 @@ impl ActiveAgentViewsModel {
task_id: AmbientAgentTaskId,
ctx: &mut ModelContext<Self>,
) {
self.ambient_sessions
.retain(|view_id, id| *view_id == terminal_view_id || *id != task_id);
let existing = self.ambient_sessions.insert(terminal_view_id, task_id);
if existing != Some(task_id) {
self.last_opened_times
@@ -323,9 +391,11 @@ impl ActiveAgentViewsModel {
ctx: &mut ModelContext<Self>,
) {
if let Some(task_id) = self.ambient_sessions.remove(&terminal_view_id) {
self.last_opened_times
.remove(&ConversationOrTaskId::TaskId(task_id));
ctx.emit(ActiveAgentViewsEvent::AmbientSessionClosed { task_id });
if !self.ambient_sessions.values().any(|id| *id == task_id) {
self.last_opened_times
.remove(&ConversationOrTaskId::TaskId(task_id));
ctx.emit(ActiveAgentViewsEvent::AmbientSessionClosed { task_id });
}
}
}
@@ -443,6 +513,23 @@ impl ActiveAgentViewsModel {
None
}
pub fn get_terminal_view_id_for_entry(
&self,
entry: &AgentConversationEntry,
ctx: &AppContext,
) -> Option<EntityId> {
if let Some(task_id) = entry.identity.ambient_agent_task_id {
if let Some(terminal_view_id) = self.get_terminal_view_id_for_ambient_task(task_id) {
return Some(terminal_view_id);
}
}
if let Some(conversation_id) = entry.identity.local_conversation_id {
return self.get_terminal_view_id_for_conversation(conversation_id, ctx);
}
None
}
/// Get all currently active conversation IDs.
/// A conversation is active if it is open and a query has been sent since it was last opened.
/// New (empty) conversations and ambient sessions are always considered active when open.
@@ -12,6 +12,46 @@ fn new_task_id() -> AmbientAgentTaskId {
AmbientAgentTaskId::from_str(&uuid::Uuid::new_v4().to_string()).unwrap()
}
#[test]
fn conversation_switch_updates_last_focused_terminal_state() {
App::test((), |mut app| async move {
let model = setup_model(&mut app);
let window = WindowId::new();
let terminal = EntityId::new();
let conversation_1 = AIConversationId::new();
let conversation_2 = AIConversationId::new();
model.update(&mut app, |model, ctx| {
model.handle_pane_focus_change(window, Some(terminal), None, ctx);
model.update_focused_conversation_for_terminal(
terminal,
Some(ConversationOrTaskId::ConversationId(conversation_1)),
);
});
model.read(&app, |model, _| {
assert_eq!(model.get_last_focused_terminal_id(), Some(terminal));
assert_eq!(
model.get_last_focused_conversation(),
Some(ConversationOrTaskId::ConversationId(conversation_1))
);
});
model.update(&mut app, |model, _| {
model.update_focused_conversation_for_terminal(
terminal,
Some(ConversationOrTaskId::ConversationId(conversation_2)),
);
});
model.read(&app, |model, _| {
assert_eq!(model.get_last_focused_terminal_id(), Some(terminal));
assert_eq!(
model.get_last_focused_conversation(),
Some(ConversationOrTaskId::ConversationId(conversation_2))
);
});
});
}
#[test]
fn per_window_focused_state_is_independent() {
App::test((), |mut app| async move {
@@ -88,6 +128,10 @@ fn last_focused_terminal_tracks_most_recent_globally() {
});
model.read(&app, |model, _| {
assert_eq!(model.get_last_focused_terminal_id(), Some(terminal_a));
assert_eq!(
model.get_last_focused_conversation(),
Some(ConversationOrTaskId::TaskId(task_a))
);
});
model.update(&mut app, |model, ctx| {
@@ -95,6 +139,10 @@ fn last_focused_terminal_tracks_most_recent_globally() {
});
model.read(&app, |model, _| {
assert_eq!(model.get_last_focused_terminal_id(), Some(terminal_b));
assert_eq!(
model.get_last_focused_conversation(),
Some(ConversationOrTaskId::TaskId(task_b))
);
});
// Clearing window B's focus should NOT clear last_focused (it persists).
@@ -103,6 +151,10 @@ fn last_focused_terminal_tracks_most_recent_globally() {
});
model.read(&app, |model, _| {
assert_eq!(model.get_last_focused_terminal_id(), Some(terminal_b));
assert_eq!(
model.get_last_focused_conversation(),
Some(ConversationOrTaskId::TaskId(task_b))
);
});
});
}
@@ -144,6 +196,72 @@ fn focus_change_without_task_id_has_no_conversation() {
});
}
#[test]
fn ambient_session_registration_replaces_stale_terminal_for_same_task() {
App::test((), |mut app| async move {
let model = setup_model(&mut app);
let terminal_a = EntityId::new();
let terminal_b = EntityId::new();
let task = new_task_id();
model.update(&mut app, |model, ctx| {
model.register_ambient_session(terminal_a, task, ctx);
model.register_ambient_session(terminal_b, task, ctx);
});
model.read(&app, |model, _| {
assert_eq!(
model.get_terminal_view_id_for_ambient_task(task),
Some(terminal_b)
);
assert_eq!(model.ambient_sessions.len(), 1);
});
});
}
#[test]
fn ambient_session_unregister_keeps_task_open_until_last_terminal_is_removed() {
App::test((), |mut app| async move {
let model = setup_model(&mut app);
let terminal_a = EntityId::new();
let terminal_b = EntityId::new();
let task = new_task_id();
model.update(&mut app, |model, _| {
model.ambient_sessions.insert(terminal_a, task);
model.ambient_sessions.insert(terminal_b, task);
model
.last_opened_times
.insert(ConversationOrTaskId::TaskId(task), Utc::now());
});
model.update(&mut app, |model, ctx| {
model.unregister_ambient_session(terminal_a, ctx);
});
model.read(&app, |model, _| {
assert_eq!(
model.get_terminal_view_id_for_ambient_task(task),
Some(terminal_b)
);
assert!(model
.last_opened_times
.contains_key(&ConversationOrTaskId::TaskId(task)));
});
model.update(&mut app, |model, ctx| {
model.unregister_ambient_session(terminal_b, ctx);
});
model.read(&app, |model, _| {
assert_eq!(model.get_terminal_view_id_for_ambient_task(task), None);
assert!(!model
.last_opened_times
.contains_key(&ConversationOrTaskId::TaskId(task)));
});
});
}
#[test]
fn remove_focused_state_for_window_cleans_up() {
App::test((), |mut app| async move {
+105 -25
View File
@@ -3,40 +3,38 @@ mod convert_from;
mod convert_to;
mod r#impl;
use std::path::Path;
use std::pin::Pin;
use std::sync::Arc;
pub use ai::agent::convert::ConvertToAPITypeError;
use ai::api_keys::ApiKeyManager;
pub use convert_from::{
user_inputs_from_messages, ConversionParams, ConvertAPIMessageToClientOutputMessage,
MaybeAIAgentOutputMessage, MessageToAIAgentOutputMessageError,
};
pub use r#impl::generate_multi_agent_output;
use futures_lite::Stream;
use mcp::TemplatableMCPServerInfo;
pub use r#impl::generate_multi_agent_output;
use serde::Serialize;
use galaxy_core::channel::ChannelState;
use galaxy_core::execution_mode::AppExecutionMode;
use galaxy_core::features::FeatureFlag;
use serde::Serialize;
use std::path::Path;
use std::pin::Pin;
use std::sync::Arc;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::{
ai::{blocklist::SessionContext, llms::LLMId},
server::server_api::AIApiError,
};
use galaxy_core::user_preferences::GetUserPreferences;
use galaxyui::{AppContext, EntityId, SingletonEntity as _};
use super::{AIAgentInput, MCPContext, MCPServer, RequestMetadata, Suggestions};
use crate::ai::blocklist::{BlocklistAIPermissions, RequestInput};
use crate::ai::mcp::templatable_manager::TemplatableMCPServerInfo;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::blocklist::{BlocklistAIPermissions, RequestInput, SessionContext};
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
use crate::ai::execution_profiles::AIExecutionProfileAppExt;
use crate::ai::llms::{LLMId, LLMPreferences};
use crate::ai::mcp::TemplatableMCPServerManager;
use crate::server::server_api::AIApiError;
use crate::settings::AISettings;
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
use crate::workspaces::user_workspaces::UserWorkspaces;
use galaxy_core::user_preferences::GetUserPreferences;
use galaxyui::{AppContext, EntityId, SingletonEntity as _};
/// Unique, server-generated conversation-scoped token to be roundtripped to the API when sending
/// requests that follow-up within a given conversation.
@@ -109,13 +107,21 @@ pub struct RequestParams {
pub computer_use_model: LLMId,
pub is_memory_enabled: bool,
pub warp_drive_context_enabled: bool,
pub context_window_limit: Option<u32>,
pub mcp_context: Option<MCPContext>,
pub planning_enabled: bool,
should_redact_secrets: bool,
/// User-provided API keys for AI providers (BYO API Key).
pub api_keys: Option<warp_multi_agent_api::request::settings::ApiKeys>,
pub allow_use_of_warp_credits_with_byok: bool,
/// User-provided custom model providers (BYOK endpoints).
pub custom_model_providers:
Option<warp_multi_agent_api::request::settings::CustomModelProviders>,
/// User-defined custom model routers referenced by the current selection. Mirrors
/// `custom_model_providers`: the selected model's `config_key` indexes into this
/// registry. `None` when no custom router is selected.
pub custom_model_routers: Option<warp_multi_agent_api::request::settings::CustomModelRouters>,
pub allow_use_of_warp_credits: bool,
pub autonomy_level: warp_multi_agent_api::AutonomyLevel,
pub isolation_level: warp_multi_agent_api::IsolationLevel,
pub web_search_enabled: bool,
@@ -170,6 +176,44 @@ pub struct ConversationData {
}
impl RequestParams {
#[cfg(test)]
pub fn new_for_test() -> Self {
Self {
input: vec![],
conversation_token: None,
forked_from_conversation_token: None,
ambient_agent_task_id: None,
tasks: vec![],
existing_suggestions: None,
metadata: None,
session_context: SessionContext::new_for_test(),
model: LLMId::from("test-model"),
coding_model: LLMId::from("test-model"),
cli_agent_model: LLMId::from("test-model"),
computer_use_model: LLMId::from("test-model"),
is_memory_enabled: false,
warp_drive_context_enabled: false,
context_window_limit: None,
mcp_context: None,
planning_enabled: false,
should_redact_secrets: false,
api_keys: None,
custom_model_providers: None,
custom_model_routers: None,
allow_use_of_warp_credits: false,
autonomy_level: Default::default(),
isolation_level: Default::default(),
web_search_enabled: false,
computer_use_enabled: false,
ask_user_question_enabled: false,
research_agent_enabled: false,
orchestration_enabled: false,
supported_tools_override: None,
parent_agent_id: None,
agent_name: None,
}
}
pub fn new(
terminal_view_id: Option<EntityId>,
session_context: SessionContext,
@@ -251,12 +295,31 @@ impl RequestParams {
let should_redact_secrets = get_secret_obfuscation_mode(app).should_redact_secret();
let user_workspaces = UserWorkspaces::as_ref(app);
let api_keys = ApiKeyManager::as_ref(app).api_keys_for_request(
user_workspaces.is_byo_api_key_enabled(),
user_workspaces.is_bedrock_enabled(app),
let api_key_manager = ApiKeyManager::as_ref(app);
let is_byo_enabled = user_workspaces.is_byo_api_key_enabled(app);
#[cfg(not(target_family = "wasm"))]
let geap_binding = crate::ai::geap_credentials::current_geap_policy(app).mint_binding();
#[cfg(target_family = "wasm")]
let geap_binding: Option<::ai::api_keys::GeapMintBinding> = None;
let api_keys = api_key_manager.api_keys_for_request(
is_byo_enabled,
user_workspaces.is_aws_bedrock_credentials_enabled(app),
geap_binding,
);
let allow_use_of_warp_credits_with_byok =
*AISettings::as_ref(app).can_use_warp_credits_with_byok;
let is_custom_inference_enabled = user_workspaces.is_custom_inference_enabled(app);
let custom_model_providers = FeatureFlag::CustomInferenceEndpoints
.is_enabled()
.then(|| {
api_key_manager.custom_model_providers_for_request(is_custom_inference_enabled)
})
.flatten();
let custom_model_routers = FeatureFlag::CustomModelRouters.is_enabled().then(|| {
LLMPreferences::as_ref(app).custom_model_routers_for_request(
&request_input.model_id,
&request_input.coding_model_id,
)
});
let allow_use_of_warp_credits = *AISettings::as_ref(app).can_use_warp_credits_for_fallback;
let app_execution_mode = AppExecutionMode::as_ref(app);
let autonomy_level = if app_execution_mode.is_autonomous() {
@@ -292,11 +355,25 @@ impl RequestParams {
!= crate::ai::execution_profiles::AskUserQuestionPermission::Never;
let orchestration_enabled = ai_settings.is_orchestration_enabled(app)
&& BlocklistAIPermissions::as_ref(app)
.get_run_agents_setting(app, terminal_view_id)
.is_enabled()
&& session_context
.session_type()
.as_ref()
.is_none_or(|t| matches!(t, crate::terminal::model::session::SessionType::Local));
// Reconcile the persisted override against the active base model's
// current `LLMContextWindow` instead of trusting whatever was stored
// last. If the active model isn't configurable or has been removed
// server-side, drop the override; otherwise clamp it to the model's
// current `[min, max]` range. This closes the window between an
// in-flight model metadata refresh and the next request.
let context_window_limit = AIExecutionProfilesModel::as_ref(app)
.active_profile(terminal_view_id, app)
.data()
.context_window_limit_for_request(app);
Self {
input: request_input.all_inputs().cloned().collect(),
conversation_token: conversation.server_conversation_token,
@@ -304,6 +381,7 @@ impl RequestParams {
ambient_agent_task_id: conversation.ambient_agent_task_id,
tasks: conversation.tasks,
existing_suggestions: conversation.existing_suggestions,
context_window_limit,
metadata,
session_context,
model: request_input.model_id.clone(),
@@ -316,7 +394,9 @@ impl RequestParams {
planning_enabled: true,
should_redact_secrets,
api_keys,
allow_use_of_warp_credits_with_byok,
custom_model_providers,
custom_model_routers,
allow_use_of_warp_credits,
autonomy_level,
isolation_level,
web_search_enabled,
+208 -62
View File
@@ -4,12 +4,28 @@
//! If some UI state is stored in the client, it needs to also be represented in the proto tasks somehow so it can be restored.
//! Some conversions may be lossy if it's not important to recover that UI state.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use ai::agent::action_result::{
AskUserQuestionAnswerItem, AskUserQuestionResult, FetchConversationResult, ReadSkillResult,
RequestComputerUseResult, SendMessageToAgentResult, StartAgentResult, StartAgentVersion,
UseComputerResult,
};
use ai::skills::{ParsedSkill, SkillPathOrigin};
use chrono::{DateTime, Local, TimeZone};
use persistence::model::AgentConversationData;
use galaxy_core::command::ExitCode;
use warp_multi_agent_api as api;
use warp_multi_agent_api::ask_user_question_result::answer_item::Answer as AskUserQuestionAnswer;
use crate::ai::agent::api::convert_from::{
convert_user_query_mode, ConversionParams, ConvertAPIMessageToClientOutputMessage,
MaybeAIAgentOutputMessage,
};
use crate::ai::agent::conversation::update_todo_list_from_todo_op;
use crate::ai::agent::conversation::{AIConversation, AIConversationId};
use crate::ai::agent::conversation::{
update_todo_list_from_todo_op, AIConversation, AIConversationId, ServerAIConversationMetadata,
};
use crate::ai::agent::task::TaskId;
use crate::ai::agent::todos::AIAgentTodoList;
use crate::ai::agent::{
@@ -24,7 +40,7 @@ use crate::ai::agent::{
RequestFileEditsResult, SearchCodebaseFailureReason, SearchCodebaseResult, ServerOutputId,
Shared, ShellCommandCompletedTrigger, ShellCommandError, SuggestNewConversationResult,
SuggestPromptResult, TransferShellCommandControlToUserResult, UpdatedFileContext,
UploadArtifactResult, WriteToLongRunningShellCommandResult,
UploadArtifactResult, UserQueryMode, WriteToLongRunningShellCommandResult,
};
use crate::ai::block_context::BlockContext;
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
@@ -32,22 +48,6 @@ use crate::ai::llms::LLMId;
use crate::ai_assistant::execution_context::{WarpAiExecutionContext, WarpAiOsContext};
use crate::terminal::model::block::BlockId;
use crate::terminal::model::terminal_model::BlockIndex;
use ai::agent::action_result::{
AskUserQuestionAnswerItem, AskUserQuestionResult, FetchConversationResult, ReadSkillResult,
RequestComputerUseResult, SendMessageToAgentResult, StartAgentResult, StartAgentVersion,
UseComputerResult,
};
use ai::skills::ParsedSkill;
use chrono::{DateTime, Local, TimeZone};
use galaxy_core::command::ExitCode;
use persistence::model::AgentConversationData;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use warp_multi_agent_api as api;
use warp_multi_agent_api::ask_user_question_result::answer_item::Answer as AskUserQuestionAnswer;
use crate::ai::agent::conversation::ServerAIConversationMetadata;
use crate::ai::agent::UserQueryMode;
/// How to restore a conversation from the cloud.
pub enum RestorationMode {
@@ -81,12 +81,14 @@ pub fn convert_conversation_data_to_ai_conversation(
artifacts_json: None,
parent_agent_id: None,
agent_name: None,
orchestration_harness_type: None,
parent_conversation_id: None,
is_remote_child: false,
root_task_is_optimistic: None,
run_id: None,
autoexecute_override: None,
last_event_sequence: None,
progressive_summary: None,
messages_summarized_up_to: 0,
pinned: false,
},
RestorationMode::Continue => AgentConversationData {
server_conversation_token: Some(
@@ -98,16 +100,16 @@ pub fn convert_conversation_data_to_ai_conversation(
artifacts_json: serde_json::to_string(&metadata.artifacts).ok(),
parent_agent_id: None,
agent_name: None,
orchestration_harness_type: None,
parent_conversation_id: None,
// TODO: Populate run_id from server metadata once it is exposed
// in ServerAIConversationMetadata. For cloud conversations that
// were spawned via the server API, the run_id is created at task
// dispatch time; adding it here would avoid a round-trip to StreamInit.
run_id: None,
is_remote_child: false,
root_task_is_optimistic: None,
run_id: metadata
.ambient_agent_task_id
.map(|task_id| task_id.to_string()),
autoexecute_override: None,
last_event_sequence: None,
progressive_summary: None,
messages_summarized_up_to: 0,
pinned: false,
},
};
@@ -239,7 +241,8 @@ pub(crate) fn convert_input_context(context: Option<&api::InputContext>) -> Arc<
};
// Convert binary data to base64
use base64::{engine::general_purpose, Engine};
use base64::engine::general_purpose;
use base64::Engine;
let data = general_purpose::STANDARD.encode(&image.data);
result.push(AIAgentContext::Image(ImageContext {
@@ -436,7 +439,10 @@ impl ConvertToExchanges for &api::Task {
api::message::system_query::Type::ResumeConversation(_)
| api::message::system_query::Type::GeneratePassiveSuggestions(_)
// TODO: Implement this for real. ZB adding this to bump proto version for unrelated API changes.
| api::message::system_query::Type::SummarizeConversation(_)=> false,
| api::message::system_query::Type::SummarizeConversation(_)
// HandoffRehydration is injected by the server for agent-only
// context; the client must never render it as user input.
| api::message::system_query::Type::HandoffRehydration(_) => false,
}
}
api::message::Message::ToolCallResult(tool_call_result) => {
@@ -462,7 +468,10 @@ impl ConvertToExchanges for &api::Task {
}
api::message::Message::InvokeSkill(invoke_skill) => {
if let Some(api_skill) = invoke_skill.skill.clone() {
if let Ok(parsed_skill) = ParsedSkill::try_from(api_skill) {
if let Ok(parsed_skill) = ParsedSkill::try_from_api_with_origin(
api_skill,
&SkillPathOrigin::RestoredDisplayOnly,
) {
let user_query = invoke_skill
.user_query
.clone()
@@ -513,7 +522,8 @@ impl ConvertToExchanges for &api::Task {
| api::message::Message::DebugOutput(_)
| api::message::Message::ArtifactEvent(_)
| api::message::Message::MessagesReceivedFromAgents(_)
| api::message::Message::ModelUsed(_) => false,
| api::message::Message::ModelUsed(_)
| api::message::Message::OrchestrationConfigSnapshot(_) => false,
};
if !added_message_as_exchange_input {
@@ -524,6 +534,7 @@ impl ConvertToExchanges for &api::Task {
// TODO(alokedesai): Support persistence for the code review state.
active_code_review: None,
task_id: &TaskId::new(api_message.task_id.clone()),
skill_path_origin: &SkillPathOrigin::Unavailable,
})
{
current_outputs.push(output_msg);
@@ -581,6 +592,14 @@ pub(crate) fn convert_tool_call_result_to_input(
command: result.command.clone(),
output: finished.output.clone(),
exit_code: ExitCode::from(finished.exit_code),
start_ts: finished
.start_ts
.as_ref()
.map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
completed_ts: finished
.finish_ts
.as_ref()
.map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
}
}
Some(api::run_shell_command_result::Result::LongRunningCommandSnapshot(
@@ -627,6 +646,8 @@ pub(crate) fn convert_tool_call_result_to_input(
block_id: finished.command_id.clone().into(),
output: finished.output.clone(),
exit_code: ExitCode::from(finished.exit_code),
start_ts: finished.start_ts.as_ref().map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
completed_ts: finished.finish_ts.as_ref().map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
},
Some(api::write_to_long_running_shell_command_result::Result::Error(api::ShellCommandError{
r#type: Some(api::shell_command_error::Type::CommandNotFound(()))
@@ -1217,6 +1238,14 @@ pub(crate) fn convert_tool_call_result_to_input(
block_id: finished.command_id.clone().into(),
output: finished.output.clone(),
exit_code: ExitCode::from(finished.exit_code),
start_ts: finished
.start_ts
.as_ref()
.map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
completed_ts: finished
.finish_ts
.as_ref()
.map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
}
}
Some(
@@ -1267,6 +1296,8 @@ pub(crate) fn convert_tool_call_result_to_input(
block_id: finished.command_id.clone().into(),
output: finished.output.clone(),
exit_code: ExitCode::from(finished.exit_code),
start_ts: finished.start_ts.as_ref().map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
completed_ts: finished.finish_ts.as_ref().map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
},
Some(api::transfer_shell_command_control_to_user_result::Result::Error(
api::ShellCommandError {
@@ -1315,34 +1346,52 @@ pub(crate) fn convert_tool_call_result_to_input(
})
}
Some(ToolCallResultType::UseComputer(result)) => {
let use_computer_result = match &result.result {
Some(api::use_computer_result::Result::Success(success)) => {
let screenshot = success.screenshot.as_ref().map(|s| {
// The original dimensions are not preserved through the API, so we use
// the current dimensions for both.
computer_use::Screenshot {
width: s.width as usize,
height: s.height as usize,
original_width: s.width as usize,
original_height: s.height as usize,
data: s.data.clone(),
mime_type: s.mime_type.clone().into(),
}
});
let cursor_position = success
.cursor_position
.as_ref()
.map(|c| computer_use::Vector2I::new(c.x, c.y));
UseComputerResult::Success(computer_use::ActionResult {
screenshot,
cursor_position,
})
}
Some(api::use_computer_result::Result::Error(error)) => {
UseComputerResult::Error(error.message.clone())
}
None => UseComputerResult::Cancelled,
};
let use_computer_result =
match &result.result {
Some(api::use_computer_result::Result::Success(success)) => {
let screenshot = success.screenshot.as_ref().map(|s| {
// The original dimensions are not preserved through the API, so we use
// the current dimensions for both.
computer_use::Screenshot {
width: s.width as usize,
height: s.height as usize,
original_width: s.width as usize,
original_height: s.height as usize,
data: s.data.clone(),
mime_type: s.mime_type.clone().into(),
}
});
let cursor_position = success
.cursor_position
.as_ref()
.map(|c| computer_use::Vector2I::new(c.x, c.y));
let windows = success
.windows
.iter()
.map(convert_api_window_info)
.collect();
// A present captured-window message indicates a window screenshot was taken.
// The window id is an opaque string on the wire; on macOS it is a CGWindowID,
// so parse it back to a u32, defaulting to 0 when it is not parseable.
let captured_window = success.captured_window.as_ref().map(|c| {
computer_use::CapturedWindow {
window_id: c.window_id.parse().unwrap_or(0),
width_px: c.width_px,
height_px: c.height_px,
}
});
UseComputerResult::Success(computer_use::ActionResult {
screenshot,
cursor_position,
windows,
captured_window,
})
}
Some(api::use_computer_result::Result::Error(error)) => {
UseComputerResult::Error(error.message.clone())
}
None => UseComputerResult::Cancelled,
};
Some(AIAgentInput::ActionResult {
result: AIAgentActionResult {
@@ -1361,6 +1410,7 @@ pub(crate) fn convert_tool_call_result_to_input(
api::request_computer_use_result::Approved {
screen_dimensions: Some(screen_dimensions),
initial_screenshot: Some(initial_screenshot),
windows,
..
},
Some(platform),
@@ -1374,6 +1424,7 @@ pub(crate) fn convert_tool_call_result_to_input(
mime_type: initial_screenshot.mime_type.clone().into(),
},
platform,
windows: windows.iter().map(convert_api_window_info).collect(),
},
_ => RequestComputerUseResult::Error(
"Missing screen dimensions, initial screenshot, or valid platform"
@@ -1544,6 +1595,76 @@ pub(crate) fn convert_tool_call_result_to_input(
context,
})
}
Some(ToolCallResultType::RunAgentsResult(result)) => {
use ai::agent::action_result::{
RunAgentsAgentOutcome, RunAgentsAgentOutcomeKind, RunAgentsLaunchedExecutionMode,
RunAgentsResult,
};
let run_agents_result = match &result.outcome {
Some(api::run_agents_result::Outcome::Launched(launched)) => {
let execution_mode = match &launched.resolved_execution_mode {
Some(api::run_agents_result::launched::ResolvedExecutionMode::Remote(
remote,
)) => RunAgentsLaunchedExecutionMode::Remote {
environment_id: remote.environment_id.clone(),
worker_host: remote.worker_host.clone(),
computer_use_enabled: remote.computer_use_enabled,
},
Some(api::run_agents_result::launched::ResolvedExecutionMode::Local(_))
| None => RunAgentsLaunchedExecutionMode::Local,
};
let agents = launched
.agents
.iter()
.map(|outcome| RunAgentsAgentOutcome {
name: outcome.name.clone(),
kind: match &outcome.result {
Some(api::run_agents_result::agent_outcome::Result::Launched(
launched_agent,
)) => RunAgentsAgentOutcomeKind::Launched {
agent_id: launched_agent.agent_id.clone(),
},
Some(api::run_agents_result::agent_outcome::Result::Failed(
failed,
)) => RunAgentsAgentOutcomeKind::Failed {
error: failed.error.clone(),
},
None => RunAgentsAgentOutcomeKind::Failed {
error: String::new(),
},
},
})
.collect();
RunAgentsResult::Launched {
model_id: launched.resolved_model_id.clone(),
harness_type:
crate::ai::agent::api::convert_from::convert_run_agents_harness(
launched.resolved_harness.as_ref(),
)
.unwrap_or_default(),
execution_mode,
agents,
}
}
Some(api::run_agents_result::Outcome::Denied(denied)) => RunAgentsResult::Denied {
reason: denied.reason.clone(),
},
Some(api::run_agents_result::Outcome::Failure(failure)) => {
RunAgentsResult::Failure {
error: failure.error.clone(),
}
}
None => RunAgentsResult::Cancelled,
};
Some(AIAgentInput::ActionResult {
result: AIAgentActionResult {
id: tool_call_id.into(),
task_id: task_id.clone(),
result: AIAgentActionResultType::RunAgents(run_agents_result),
},
context,
})
}
// Deprecated/unused result types or absent result.
Some(ToolCallResultType::SuggestCreatePlan(..))
| Some(ToolCallResultType::SuggestPlan(..))
@@ -1551,6 +1672,7 @@ pub(crate) fn convert_tool_call_result_to_input(
log::warn!("No result present for tool call ID: {tool_call_id}");
None
}
Some(ToolCallResultType::WaitForEvents(_)) => None,
}
}
@@ -1678,8 +1800,14 @@ fn create_cancelled_result_for_tool_call(
ToolType::SendMessageToAgent(_) => {
AIAgentActionResultType::SendMessageToAgent(SendMessageToAgentResult::Cancelled)
}
ToolType::RunAgents(_) => {
AIAgentActionResultType::RunAgents(ai::agent::action_result::RunAgentsResult::Cancelled)
}
// These tools are deprecated.
ToolType::SuggestCreatePlan(_) | ToolType::SuggestPlan(_) => return None,
ToolType::WaitForEvents(_) => {
return None;
}
};
Some(AIAgentInput::ActionResult {
@@ -1777,6 +1905,10 @@ fn create_exchange_from_messages(
model_id: model.model_id.clone().into(),
display_name: model.model_display_name.clone(),
is_fallback: model.is_fallback,
prompt_cache_expires_at: model
.prompt_cache_expires_at
.as_ref()
.map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
}),
request_cost: None,
};
@@ -1879,7 +2011,8 @@ where
| api::message::Message::DebugOutput(_)
| api::message::Message::ArtifactEvent(_)
| api::message::Message::InvokeSkill(_)
| api::message::Message::ModelUsed(_) => {
| api::message::Message::ModelUsed(_)
| api::message::Message::OrchestrationConfigSnapshot(_) => {
message.timestamp.as_ref().map(|timestamp| {
proto_timestamp_to_local_datetime(timestamp.seconds, timestamp.nanos)
})
@@ -1985,7 +2118,7 @@ fn convert_passive_suggestion_result_to_input(
context,
})
}
fn proto_timestamp_to_local_datetime(seconds: i64, nanos: i32) -> DateTime<Local> {
pub(crate) fn proto_timestamp_to_local_datetime(seconds: i64, nanos: i32) -> DateTime<Local> {
let nanos = if nanos < 0 { 0 } else { nanos as u32 };
Local
@@ -2014,6 +2147,19 @@ fn convert_api_platform(platform: i32) -> Option<computer_use::Platform> {
}
}
/// Reconstructs the internal computer_use window record from the API `WindowInfo` message.
fn convert_api_window_info(window: &api::WindowInfo) -> computer_use::WindowInfo {
computer_use::WindowInfo {
// The window id arrives as an opaque string; on macOS it is a CGWindowID (u32). Default to
// 0 when it is not parseable.
window_id: window.window_id.parse().unwrap_or(0),
pid: window.pid,
app_name: window.app_name.clone(),
title: window.title.clone(),
layer: window.layer,
}
}
#[cfg(test)]
#[path = "convert_conversation_tests.rs"]
mod tests;
@@ -1,7 +1,56 @@
use crate::ai::agent::api::convert_conversation::*;
use crate::ai::agent::{AIAgentInput, UserQueryMode};
use std::collections::HashMap;
use chrono::Utc;
use warp_multi_agent_api as api;
use crate::ai::agent::api::convert_conversation::*;
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::conversation::{
AIAgentHarness, AIConversationId, ServerAIConversationMetadata,
};
use crate::ai::agent::{AIAgentInput, UserQueryMode};
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::cloud_object::{Revision, ServerMetadata, ServerPermissions};
use crate::persistence::model::ConversationUsageMetadata;
use crate::server::ids::ServerId;
fn test_server_metadata(
server_token: &str,
ambient_agent_task_id: Option<AmbientAgentTaskId>,
) -> ServerAIConversationMetadata {
ServerAIConversationMetadata {
title: "test conversation".to_string(),
working_directory: None,
harness: AIAgentHarness::Oz,
usage: ConversationUsageMetadata {
was_summarized: false,
context_window_usage: 0.0,
credits_spent: 0.0,
platform_credits_spent: 0.0,
credits_spent_for_last_block: None,
token_usage: vec![],
tool_usage_metadata: Default::default(),
context_window_segments: Vec::new(),
},
metadata: ServerMetadata {
uid: ServerId::default(),
revision: Revision::now(),
metadata_last_updated_ts: Utc::now().into(),
trashed_ts: None,
folder_id: None,
is_welcome_object: false,
creator_uid: None,
last_editor_uid: None,
current_editor_uid: None,
},
permissions: ServerPermissions::mock_personal(),
creator: None,
ambient_agent_task_id,
server_conversation_token: ServerConversationToken::new(server_token.to_string()),
artifacts: vec![],
}
}
fn test_skill() -> api::Skill {
api::Skill {
descriptor: Some(api::SkillDescriptor {
@@ -25,6 +74,40 @@ fn test_skill() -> api::Skill {
}
}
#[test]
#[allow(deprecated)]
fn test_convert_conversation_data_to_ai_conversation_sets_restored_run_id() {
let conversation_id = AIConversationId::new();
let ambient_agent_task_id: AmbientAgentTaskId =
"550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
let conversation_data = api::ConversationData {
tasks: vec![api::Task {
id: "root".to_string(),
messages: vec![],
dependencies: None,
description: String::new(),
summary: String::new(),
server_data: String::new(),
}],
ordered_message_ids: vec![],
};
let conversation = convert_conversation_data_to_ai_conversation(
conversation_id,
&conversation_data,
test_server_metadata("server-token", Some(ambient_agent_task_id)),
RestorationMode::Continue,
)
.expect("conversation should restore");
assert_eq!(conversation.id(), conversation_id);
assert_eq!(conversation.task_id(), Some(ambient_agent_task_id));
assert_eq!(
conversation.run_id(),
Some(ambient_agent_task_id.to_string())
);
}
#[test]
fn test_convert_tool_call_result_to_input_transfer_control_snapshot() {
let task_id = crate::ai::agent::task::TaskId::new("task".to_string());
@@ -277,6 +360,7 @@ fn test_into_exchanges_basic() {
// Create minimal test data
let messages = vec![
api::Message {
fetched_memories: vec![],
id: "user_msg".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -292,6 +376,7 @@ fn test_into_exchanges_basic() {
timestamp: None,
},
api::Message {
fetched_memories: vec![],
id: "agent_msg".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -305,6 +390,7 @@ fn test_into_exchanges_basic() {
timestamp: None,
},
api::Message {
fetched_memories: vec![],
id: "user_msg2".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -320,6 +406,7 @@ fn test_into_exchanges_basic() {
timestamp: None,
},
api::Message {
fetched_memories: vec![],
id: "agent_msg2".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -333,6 +420,7 @@ fn test_into_exchanges_basic() {
timestamp: None,
},
api::Message {
fetched_memories: vec![],
id: "user_msg3".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -348,6 +436,7 @@ fn test_into_exchanges_basic() {
timestamp: None,
},
api::Message {
fetched_memories: vec![],
id: "agent_msg3".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -392,6 +481,7 @@ fn test_invoke_skill_arguments_round_trip() {
let query = "arg1 arg2".to_string();
let messages = vec![
api::Message {
fetched_memories: vec![],
id: "invoke_skill_msg".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -412,6 +502,7 @@ fn test_invoke_skill_arguments_round_trip() {
timestamp: None,
},
api::Message {
fetched_memories: vec![],
id: "agent_msg".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -448,7 +539,7 @@ fn test_invoke_skill_arguments_round_trip() {
Some("arg1 arg2")
);
assert_eq!(
exchanges[0].input[0].user_query().as_deref(),
exchanges[0].input[0].display_query().as_deref(),
Some("/test-skill arg1 arg2")
);
}
@@ -459,6 +550,7 @@ fn test_invoke_skill_arguments_round_trip() {
#[test]
fn test_invoke_skill_missing_user_query_maps_to_none() {
let messages = vec![api::Message {
fetched_memories: vec![],
id: "invoke_skill_msg".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -492,7 +584,7 @@ fn test_invoke_skill_missing_user_query_maps_to_none() {
assert_eq!(skill.name, "test-skill");
assert_eq!(user_query, &None);
assert_eq!(
exchanges[0].input[0].user_query().as_deref(),
exchanges[0].input[0].display_query().as_deref(),
Some("/test-skill")
);
}
@@ -505,6 +597,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
let messages = vec![
// User query
api::Message {
fetched_memories: vec![],
id: "user_query".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -521,6 +614,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
},
// Agent response
api::Message {
fetched_memories: vec![],
id: "agent_response".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -535,6 +629,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
},
// Tool call 1
api::Message {
fetched_memories: vec![],
id: "tool_call_1".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -558,6 +653,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
},
// Tool call 2
api::Message {
fetched_memories: vec![],
id: "tool_call_2".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -581,6 +677,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
},
// Tool call 3
api::Message {
fetched_memories: vec![],
id: "tool_call_3".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -604,6 +701,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
},
// Tool call result - cancelled (call_2)
api::Message {
fetched_memories: vec![],
id: "result_cancelled".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -620,6 +718,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
},
// Tool call result - success (call_1)
api::Message {
fetched_memories: vec![],
id: "result_success_1".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -639,6 +738,8 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
command_id: "command_1".to_string(),
output: "1".to_string(),
exit_code: 0,
start_ts: None,
finish_ts: None,
},
)),
},
@@ -650,6 +751,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
},
// Tool call result - success (call_3)
api::Message {
fetched_memories: vec![],
id: "result_success_3".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -669,6 +771,8 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
command_id: "command_2".to_string(),
output: "3".to_string(),
exit_code: 0,
start_ts: None,
finish_ts: None,
},
)),
},
@@ -680,6 +784,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
},
// Final agent response
api::Message {
fetched_memories: vec![],
id: "final_response".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -694,6 +799,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
},
// Follow-up user query
api::Message {
fetched_memories: vec![],
id: "followup_query".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -710,6 +816,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
},
// Final agent response
api::Message {
fetched_memories: vec![],
id: "final_response2".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -811,6 +918,7 @@ fn test_into_exchanges_with_code_diffs() {
let messages = vec![
// User query asking for code changes
api::Message {
fetched_memories: vec![],
id: "user_query".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -827,6 +935,7 @@ fn test_into_exchanges_with_code_diffs() {
},
// Agent response
api::Message {
fetched_memories: vec![],
id: "agent_response".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -841,6 +950,7 @@ fn test_into_exchanges_with_code_diffs() {
},
// File diff tool call
api::Message {
fetched_memories: vec![],
id: "diff_call".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -862,6 +972,7 @@ fn test_into_exchanges_with_code_diffs() {
},
// User cancels the diff
api::Message {
fetched_memories: vec![],
id: "diff_cancelled".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -878,6 +989,7 @@ fn test_into_exchanges_with_code_diffs() {
},
// User provides feedback
api::Message {
fetched_memories: vec![],
id: "user_feedback".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -894,6 +1006,7 @@ fn test_into_exchanges_with_code_diffs() {
},
// Agent response
api::Message {
fetched_memories: vec![],
id: "agent_response_2".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -908,6 +1021,7 @@ fn test_into_exchanges_with_code_diffs() {
},
// Second file diff tool call
api::Message {
fetched_memories: vec![],
id: "diff_call_2".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -929,6 +1043,7 @@ fn test_into_exchanges_with_code_diffs() {
},
// User accepts the diff
api::Message {
fetched_memories: vec![],
id: "diff_accepted".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -956,6 +1071,7 @@ fn test_into_exchanges_with_code_diffs() {
},
// Final agent response
api::Message {
fetched_memories: vec![],
id: "final_response".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -970,6 +1086,7 @@ fn test_into_exchanges_with_code_diffs() {
},
// Follow-up user query
api::Message {
fetched_memories: vec![],
id: "followup".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -986,6 +1103,7 @@ fn test_into_exchanges_with_code_diffs() {
},
// Final agent response
api::Message {
fetched_memories: vec![],
id: "final_response_2".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1083,6 +1201,7 @@ fn test_into_exchanges_with_code_diffs() {
fn test_user_query_mode_conversion() {
// Test conversion with Plan mode
let messages = vec![api::Message {
fetched_memories: vec![],
id: "user_msg".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1129,6 +1248,7 @@ fn test_user_query_mode_conversion() {
// Test conversion with Normal mode (no type set)
let messages_normal = vec![api::Message {
fetched_memories: vec![],
id: "user_msg".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1173,6 +1293,7 @@ fn test_user_query_mode_conversion() {
// Test conversion with no mode field (should default to Normal)
let messages_default = vec![api::Message {
fetched_memories: vec![],
id: "user_msg".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1226,6 +1347,7 @@ fn test_exchanges_grouped_by_request_id() {
let messages = vec![
// Message 0: Server message (should be ignored or handled gracefully)
api::Message {
fetched_memories: vec![],
id: "2512077c-0ede-46b0-8f69-230c8792df07".to_string(),
task_id: "d02463e1-2429-48de-ac8f-552df4acc4d0".to_string(),
request_id: "78e236b8-84a2-45df-876e-ebfb86ceafc4".to_string(),
@@ -1243,6 +1365,7 @@ fn test_exchanges_grouped_by_request_id() {
},
// Message 1: User query with request_id 78e236b8
api::Message {
fetched_memories: vec![],
id: "4d6c450d-3d54-446f-974c-5c414e6083e9".to_string(),
task_id: "d02463e1-2429-48de-ac8f-552df4acc4d0".to_string(),
request_id: "78e236b8-84a2-45df-876e-ebfb86ceafc4".to_string(),
@@ -1259,6 +1382,7 @@ fn test_exchanges_grouped_by_request_id() {
},
// Message 2: Agent output with same request_id
api::Message {
fetched_memories: vec![],
id: "10210d1a-5298-45ef-90ba-df6367805080".to_string(),
task_id: "d02463e1-2429-48de-ac8f-552df4acc4d0".to_string(),
request_id: "78e236b8-84a2-45df-876e-ebfb86ceafc4".to_string(),
@@ -1273,6 +1397,7 @@ fn test_exchanges_grouped_by_request_id() {
},
// Message 3: Tool call with same request_id
api::Message {
fetched_memories: vec![],
id: "936c7c86-eb4a-4edf-97c0-22f5c61b35a6".to_string(),
task_id: "d02463e1-2429-48de-ac8f-552df4acc4d0".to_string(),
request_id: "78e236b8-84a2-45df-876e-ebfb86ceafc4".to_string(),
@@ -1296,6 +1421,7 @@ fn test_exchanges_grouped_by_request_id() {
},
// Message 4: Tool call result with NEW request_id 59a3947f (starts new exchange)
api::Message {
fetched_memories: vec![],
id: "cbebf5fb-4dd8-4aef-be45-bb916eff552c".to_string(),
task_id: "d02463e1-2429-48de-ac8f-552df4acc4d0".to_string(),
request_id: "59a3947f-fc7e-413a-96b5-baecd7e406dc".to_string(),
@@ -1330,6 +1456,7 @@ fn test_exchanges_grouped_by_request_id() {
},
// Message 5: Agent output with same request_id
api::Message {
fetched_memories: vec![],
id: "7a89857d-fa33-4d45-88e3-5fa9cbce3f20".to_string(),
task_id: "d02463e1-2429-48de-ac8f-552df4acc4d0".to_string(),
request_id: "59a3947f-fc7e-413a-96b5-baecd7e406dc".to_string(),
@@ -1344,6 +1471,7 @@ fn test_exchanges_grouped_by_request_id() {
},
// Message 6: Write to long running command with NEW request_id 9f85acb2 (starts new exchange)
api::Message {
fetched_memories: vec![],
id: "dac6d336-9fcb-4e34-bc2b-b06e70f52ec5".to_string(),
task_id: "d02463e1-2429-48de-ac8f-552df4acc4d0".to_string(),
request_id: "9f85acb2-0b1f-41b1-a0de-3623e131758a".to_string(),
@@ -1374,6 +1502,7 @@ fn test_exchanges_grouped_by_request_id() {
},
// Message 7: Final tool call result with same request_id
api::Message {
fetched_memories: vec![],
id: "ad319d66-fac0-4169-8bf1-e6004aca1619".to_string(),
task_id: "d02463e1-2429-48de-ac8f-552df4acc4d0".to_string(),
request_id: "9f85acb2-0b1f-41b1-a0de-3623e131758a".to_string(),
@@ -1395,6 +1524,8 @@ fn test_exchanges_grouped_by_request_id() {
command_id: "cmd1".to_string(),
output: "Done".to_string(),
exit_code: 0,
start_ts: None,
finish_ts: None,
},
)),
},
@@ -1404,6 +1535,7 @@ fn test_exchanges_grouped_by_request_id() {
},
// Message 8: Final agent output with same request_id
api::Message {
fetched_memories: vec![],
id: "f15f8a59-2e9c-416e-b216-83b3bd52d6be".to_string(),
task_id: "d02463e1-2429-48de-ac8f-552df4acc4d0".to_string(),
request_id: "9f85acb2-0b1f-41b1-a0de-3623e131758a".to_string(),
@@ -1492,6 +1624,7 @@ fn test_multiple_create_documents_get_default_version() {
let messages = vec![
// User query
api::Message {
fetched_memories: vec![],
id: "user_msg".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1508,6 +1641,7 @@ fn test_multiple_create_documents_get_default_version() {
},
// Agent output
api::Message {
fetched_memories: vec![],
id: "agent_text".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1522,6 +1656,7 @@ fn test_multiple_create_documents_get_default_version() {
},
// First CreateDocuments tool call
api::Message {
fetched_memories: vec![],
id: "tool_call_create_a".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1544,6 +1679,7 @@ fn test_multiple_create_documents_get_default_version() {
},
// First CreateDocuments result
api::Message {
fetched_memories: vec![],
id: "result_create_a".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1572,6 +1708,7 @@ fn test_multiple_create_documents_get_default_version() {
},
// Agent output before second plan
api::Message {
fetched_memories: vec![],
id: "agent_text_2".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1586,6 +1723,7 @@ fn test_multiple_create_documents_get_default_version() {
},
// Second CreateDocuments tool call
api::Message {
fetched_memories: vec![],
id: "tool_call_create_b".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1608,6 +1746,7 @@ fn test_multiple_create_documents_get_default_version() {
},
// Second CreateDocuments result
api::Message {
fetched_memories: vec![],
id: "result_create_b".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1694,7 +1833,6 @@ fn test_multiple_create_documents_get_default_version() {
#[test]
fn test_create_then_edit_then_create_version_tracking() {
use crate::ai::agent::{AIAgentActionResultType, CreateDocumentsResult, EditDocumentsResult};
use crate::ai::document::ai_document_model::AIDocumentVersion;
let doc_id_a = uuid::Uuid::new_v4().to_string();
let doc_id_b = uuid::Uuid::new_v4().to_string();
@@ -1702,6 +1840,7 @@ fn test_create_then_edit_then_create_version_tracking() {
let messages = vec![
// User query
api::Message {
fetched_memories: vec![],
id: "user_msg".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1718,6 +1857,7 @@ fn test_create_then_edit_then_create_version_tracking() {
},
// Agent output
api::Message {
fetched_memories: vec![],
id: "agent_text".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1732,6 +1872,7 @@ fn test_create_then_edit_then_create_version_tracking() {
},
// Create doc A tool call
api::Message {
fetched_memories: vec![],
id: "tool_call_create_a".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1754,6 +1895,7 @@ fn test_create_then_edit_then_create_version_tracking() {
},
// Create doc A result
api::Message {
fetched_memories: vec![],
id: "result_create_a".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1782,6 +1924,7 @@ fn test_create_then_edit_then_create_version_tracking() {
},
// Agent output before edit
api::Message {
fetched_memories: vec![],
id: "agent_text_2".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1796,6 +1939,7 @@ fn test_create_then_edit_then_create_version_tracking() {
},
// Edit doc A tool call
api::Message {
fetched_memories: vec![],
id: "tool_call_edit_a".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1817,6 +1961,7 @@ fn test_create_then_edit_then_create_version_tracking() {
},
// Edit doc A result
api::Message {
fetched_memories: vec![],
id: "result_edit_a".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1845,6 +1990,7 @@ fn test_create_then_edit_then_create_version_tracking() {
},
// Agent output before second create
api::Message {
fetched_memories: vec![],
id: "agent_text_3".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1859,6 +2005,7 @@ fn test_create_then_edit_then_create_version_tracking() {
},
// Create doc B tool call
api::Message {
fetched_memories: vec![],
id: "tool_call_create_b".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1881,6 +2028,7 @@ fn test_create_then_edit_then_create_version_tracking() {
},
// Create doc B result
api::Message {
fetched_memories: vec![],
id: "result_create_b".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1979,3 +2127,74 @@ fn test_create_then_edit_then_create_version_tracking() {
"Created doc B should have default version (v1), independent of doc A"
);
}
/// Verify that a `SystemQuery::HandoffRehydration` message does not produce
/// a displayed input when restoring a conversation. It must be treated as
/// hidden, so the exchange should have zero user-visible inputs.
#[test]
fn test_handoff_rehydration_system_query_is_hidden() {
let messages = vec![
// HandoffRehydration system query should be hidden
api::Message {
fetched_memories: vec![],
id: "msg_handoff".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
citations: vec![],
message: Some(api::message::Message::SystemQuery(
api::message::SystemQuery {
r#type: Some(api::message::system_query::Type::HandoffRehydration(
api::message::HandoffRehydration {
instructions: "restore handoff state".to_string(),
},
)),
context: None,
},
)),
request_id: "req1".to_string(),
timestamp: None,
},
// Agent output that follows the hidden system query
api::Message {
fetched_memories: vec![],
id: "msg_output".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
citations: vec![],
message: Some(api::message::Message::AgentOutput(
api::message::AgentOutput {
text: "I have restored the handoff state.".to_string(),
},
)),
request_id: "req1".to_string(),
timestamp: None,
},
];
let task = api::Task {
id: "task1".to_string(),
messages,
dependencies: None,
description: "".to_string(),
summary: "".to_string(),
server_data: "".to_string(),
};
let exchanges = task.into_exchanges();
assert_eq!(exchanges.len(), 1, "Should produce exactly one exchange");
let exchange = &exchanges[0];
// The HandoffRehydration should NOT appear as input
assert!(
exchange.input.is_empty(),
"HandoffRehydration must not produce a displayed input, got: {:?}",
exchange.input
);
// The agent output should still be present
let output = exchange.output_status.output().expect("should have output");
assert!(
!output.get().messages.is_empty(),
"Agent output should still be rendered"
);
}
+137 -36
View File
@@ -2,34 +2,33 @@
use std::collections::HashMap;
use std::time::Duration;
use ai::agent::action::{LifecycleEventType as StartAgentLifecycleEventType, ReadSkillRequest};
use ai::agent::action_result::StartAgentVersion;
use ai::agent::convert::ToolToAIAgentActionError;
use ai::agent::UnknownCitationTypeError;
use ai::skills::{
skill_reference_from_api_skill_ref, skill_reference_from_read_skill_ref, SkillPathOrigin,
};
use api::ask_user_question::question::QuestionType;
use galaxy_core::channel::ChannelState;
use warp_multi_agent_api as api;
use crate::ai::agent::api::convert_conversation::{
convert_input_context, convert_tool_call_result_to_input,
};
use crate::ai::agent::comment::CodeReview;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::todos::AIAgentTodoList;
use crate::ai::agent::util::parse_markdown_into_text_and_code_sections;
use crate::ai::agent::{
util::parse_markdown_into_text_and_code_sections, AIAgentAction, AIAgentActionType,
AIAgentCitation, AIAgentInput, AIAgentOutputMessage, AIAgentText, AIAgentTodo,
ArtifactCreatedData, MessageId, StartAgentExecutionMode, SuggestedAgentModeWorkflow,
SuggestedRule, Suggestions, TodoOperation,
};
use crate::ai::agent::{
CloneRepositoryURL, SubagentCall, SubagentType, SummarizationType, WebFetchStatus,
WebSearchStatus,
AIAgentAction, AIAgentActionType, AIAgentAttachment, AIAgentCitation, AIAgentInput,
AIAgentOutputMessage, AIAgentText, AIAgentTodo, ArtifactCreatedData, CloneRepositoryURL,
MessageId, RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest,
StartAgentExecutionMode, SubagentCall, SubagentType, SuggestedAgentModeWorkflow, SuggestedRule,
Suggestions, SummarizationType, TodoOperation, UserQueryMode, WebFetchStatus, WebSearchStatus,
};
use crate::ai::artifact_download::sanitized_basename;
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
use ai::agent::action::LifecycleEventType as StartAgentLifecycleEventType;
use ai::agent::action_result::StartAgentVersion;
use ai::agent::convert::ToolToAIAgentActionError;
use ai::agent::UnknownCitationTypeError;
use ai::skills::SkillReference;
use api::ask_user_question::question::QuestionType;
use galaxy_core::channel::ChannelState;
use warp_multi_agent_api as api;
use crate::ai::agent::{AIAgentAttachment, UserQueryMode};
impl TryFrom<api::Attachment> for AIAgentAttachment {
type Error = anyhow::Error;
@@ -53,6 +52,18 @@ impl TryFrom<api::Attachment> for AIAgentAttachment {
}
}
fn convert_read_skill(
read_skill: api::message::tool_call::ReadSkill,
skill_path_origin: &SkillPathOrigin,
) -> Result<AIAgentActionType, ToolToAIAgentActionError> {
let Some(reference) = read_skill.skill_reference else {
return Err(ToolToAIAgentActionError::MissingSkillReference);
};
let skill = skill_reference_from_read_skill_ref(reference, skill_path_origin)
.map_err(|_| ToolToAIAgentActionError::MissingSkillReference)?;
Ok(AIAgentActionType::ReadSkill(ReadSkillRequest { skill }))
}
/// Converts proto UserQueryMode to the internal UserQueryMode type
pub(crate) fn convert_user_query_mode(mode: Option<&api::UserQueryMode>) -> UserQueryMode {
let Some(mode) = mode else {
@@ -81,6 +92,22 @@ fn convert_start_agent_v2_harness_type(
.filter(|harness_type| !harness_type.trim().is_empty())
}
/// Maps the proto `Harness` oneof to a client-side string identifier
/// (e.g. "oz", "claude"). Returns `None` for an unset variant.
pub(crate) fn convert_run_agents_harness(harness: Option<&api::Harness>) -> Option<String> {
let variant = harness?.variant.as_ref()?;
Some(
match variant {
api::harness::Variant::Oz(_) => "oz",
api::harness::Variant::ClaudeCode(_) => "claude",
api::harness::Variant::OpenCode(_) => "opencode",
api::harness::Variant::Gemini(_) => "gemini",
api::harness::Variant::Codex(_) => "codex",
}
.to_string(),
)
}
fn convert_start_agent_execution_mode(
execution_mode: Option<api::start_agent::ExecutionMode>,
) -> StartAgentExecutionMode {
@@ -94,8 +121,62 @@ fn convert_start_agent_execution_mode(
}
}
fn convert_run_agents_execution_mode(
execution_mode: Option<api::run_agents::ExecutionMode>,
) -> RunAgentsExecutionMode {
match execution_mode {
Some(api::run_agents::ExecutionMode::Remote(remote)) => RunAgentsExecutionMode::Remote {
environment_id: remote.environment_id,
worker_host: remote.worker_host,
computer_use_enabled: remote.computer_use_enabled,
},
Some(api::run_agents::ExecutionMode::Local(_)) | None => RunAgentsExecutionMode::Local,
}
}
fn convert_run_agents(
run_agents: api::RunAgents,
skill_path_origin: &SkillPathOrigin,
) -> AIAgentActionType {
let api::RunAgents {
summary,
base_prompt,
skills,
model_id,
harness,
agent_run_configs,
execution_mode,
plan_id,
} = run_agents;
AIAgentActionType::RunAgents(RunAgentsRequest {
summary,
base_prompt,
skills: skills
.into_iter()
.filter_map(|skill| skill_reference_from_api_skill_ref(skill, skill_path_origin))
.collect(),
model_id,
harness_type: convert_run_agents_harness(harness.as_ref()).unwrap_or_default(),
execution_mode: convert_run_agents_execution_mode(execution_mode),
agent_run_configs: agent_run_configs
.into_iter()
.map(|config| RunAgentsAgentRunConfig {
name: config.name,
prompt: config.prompt,
title: config.title,
})
.collect(),
plan_id,
// Auth secret is a client-side dispatch concern populated by the
// confirmation card from `CloudAgentSettings.last_selected_auth_secret`
// before Accept. The proto does not carry it.
harness_auth_secret_name: None,
})
}
fn convert_start_agent_v2_execution_mode(
execution_mode: Option<api::start_agent_v2::ExecutionMode>,
skill_path_origin: &SkillPathOrigin,
) -> StartAgentExecutionMode {
match execution_mode.and_then(|execution_mode| execution_mode.mode) {
Some(api::start_agent_v2::execution_mode::Mode::Remote(remote)) => {
@@ -104,7 +185,9 @@ fn convert_start_agent_v2_execution_mode(
skill_references: remote
.skills
.into_iter()
.filter_map(convert_skill_reference)
.filter_map(|skill| {
skill_reference_from_api_skill_ref(skill, skill_path_origin)
})
.collect(),
model_id: remote.model_id,
computer_use_enabled: remote.computer_use_enabled,
@@ -112,6 +195,9 @@ fn convert_start_agent_v2_execution_mode(
harness_type: convert_start_agent_v2_harness_type(remote.harness)
.unwrap_or_default(),
title: remote.title,
// Auth secret is plumbed client-side via `RunAgentsRequest`;
// StartAgentV2 from the server never carries it.
auth_secret_name: None,
}
}
Some(api::start_agent_v2::execution_mode::Mode::Local(local)) => {
@@ -123,16 +209,6 @@ fn convert_start_agent_v2_execution_mode(
}
}
fn convert_skill_reference(skill_ref: api::SkillRef) -> Option<SkillReference> {
match skill_ref.skill_reference {
Some(api::skill_ref::SkillReference::Path(path)) => Some(SkillReference::Path(path.into())),
Some(api::skill_ref::SkillReference::BundledSkillId(id)) => {
Some(SkillReference::BundledSkillId(id))
}
None => None,
}
}
/// Unexpected errors when trying to convert an [`api::Message`] to an [`AIAgentOutputMessage`].
#[derive(Debug, thiserror::Error)]
pub enum MessageToAIAgentOutputMessageError {
@@ -167,6 +243,7 @@ pub struct ConversionParams<'a> {
pub task_id: &'a TaskId,
pub current_todo_list: Option<&'a AIAgentTodoList>,
pub active_code_review: Option<&'a CodeReview>,
pub skill_path_origin: &'a SkillPathOrigin,
}
/// Trait for converting an [`api::Message`] to an [`AIAgentOutputMessage`].
@@ -569,7 +646,11 @@ impl ConvertAPIMessageToClientOutputMessage for api::Message {
| api::message::Message::CodeReview(_)
| api::message::Message::ServerEvent(_)
| api::message::Message::InvokeSkill(_)
| api::message::Message::PassiveSuggestionResult(_) => {
| api::message::Message::PassiveSuggestionResult(_)
// Stage 2 plan-card config snapshot: hydrated separately by the
// plan card's `AIDocumentModel` subscription, not via the
// exchange/output stream. No client output message representation.
| api::message::Message::OrchestrationConfigSnapshot(_) => {
Ok(MaybeAIAgentOutputMessage::NoClientRepresentation)
}
}
@@ -600,7 +681,7 @@ trait ConvertAPIToolCallToAIAgentAction {
) -> Result<MaybeAIAgentAction, ToolToAIAgentActionError>;
}
/// Trys to convert an [`api::message::ToolCall`] to an [`AIAgentAction`].
/// Tries to convert an [`api::message::ToolCall`] to an [`AIAgentAction`].
///
/// A [`Result::Error`] indicates an unexpected problem, while [`Ok(None)`]
/// indicates a tool call that we aren't expected to parse.
@@ -700,6 +781,7 @@ impl ConvertAPIToolCallToAIAgentAction for api::message::ToolCall {
create_standard_action(request_computer_use.into())
}
api::message::tool_call::Tool::Subagent(subagent) => {
use api::message::tool_call::subagent::conversation_search_metadata::Target;
use api::message::tool_call::subagent::Metadata;
let subagent_type = match subagent.metadata {
Some(Metadata::Cli(_)) => SubagentType::Cli,
@@ -713,14 +795,23 @@ impl ConvertAPIToolCallToAIAgentAction for api::message::ToolCall {
} else {
Some(cs_meta.query)
};
let conversation_id = if cs_meta.conversation_id.is_empty() {
None
} else {
Some(cs_meta.conversation_id)
let (conversation_id, agent_run_id) = match cs_meta.target {
Some(Target::ConversationId(conversation_id))
if !conversation_id.is_empty() =>
{
(Some(conversation_id), None)
}
Some(Target::AgentRunId(agent_run_id)) if !agent_run_id.is_empty() => {
(None, Some(agent_run_id))
}
Some(Target::ConversationId(_))
| Some(Target::AgentRunId(_))
| None => (None, None),
};
SubagentType::ConversationSearch {
query,
conversation_id,
agent_run_id,
}
}
Some(Metadata::WarpDocumentationSearch(_)) => {
@@ -757,6 +848,7 @@ impl ConvertAPIToolCallToAIAgentAction for api::message::ToolCall {
prompt: start_agent.prompt,
execution_mode: convert_start_agent_v2_execution_mode(
start_agent.execution_mode,
params.skill_path_origin,
),
lifecycle_subscription: start_agent.lifecycle_subscription.map(
|subscription| {
@@ -769,6 +861,9 @@ impl ConvertAPIToolCallToAIAgentAction for api::message::ToolCall {
),
})
}
api::message::tool_call::Tool::RunAgents(orchestrate) => {
create_standard_action(convert_run_agents(orchestrate, params.skill_path_origin))
}
api::message::tool_call::Tool::SendMessageToAgent(send_message) => {
create_standard_action(AIAgentActionType::SendMessageToAgent {
addresses: send_message.addresses,
@@ -780,7 +875,7 @@ impl ConvertAPIToolCallToAIAgentAction for api::message::ToolCall {
create_standard_action(insert_review_comments.into())
}
api::message::tool_call::Tool::ReadSkill(read_skill) => {
create_standard_action(read_skill.try_into()?)
create_standard_action(convert_read_skill(read_skill, params.skill_path_origin)?)
}
api::message::tool_call::Tool::FetchConversation(fetch_conversation) => {
create_standard_action(fetch_conversation.into())
@@ -798,6 +893,12 @@ impl ConvertAPIToolCallToAIAgentAction for api::message::ToolCall {
api::message::tool_call::Tool::Server(_) => {
Ok(MaybeAIAgentAction::NoClientRepresentation)
}
api::message::tool_call::Tool::WaitForEvents(payload) => {
create_standard_action(AIAgentActionType::WaitForEvents {
tool_call_id: self.tool_call_id.clone(),
idle_timeout_seconds: payload.idle_timeout_seconds,
})
}
_ => Err(ToolToAIAgentActionError::UnexpectedTool),
}
}
+27 -4
View File
@@ -1,3 +1,10 @@
use std::path::PathBuf;
use ai::agent::action::AskUserQuestionType;
use ai::skills::{SkillPathOrigin, SkillReference};
use warp_multi_agent_api as api;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use super::{
convert_api_question, ConversionParams, ConvertAPIMessageToClientOutputMessage,
MaybeAIAgentOutputMessage,
@@ -6,9 +13,6 @@ use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentActionType, AIAgentOutputMessageType, LifecycleEventType, StartAgentExecutionMode,
};
use ai::agent::action::AskUserQuestionType;
use ai::skills::SkillReference;
use warp_multi_agent_api as api;
fn start_agent_tool_call_message(
name: &str,
@@ -17,6 +21,7 @@ fn start_agent_tool_call_message(
lifecycle_subscription_event_types: Option<Vec<i32>>,
) -> api::Message {
api::Message {
fetched_memories: vec![],
id: "message-id".to_string(),
task_id: "task-id".to_string(),
server_message_data: String::new(),
@@ -63,6 +68,7 @@ fn start_agent_v2_tool_call_message(
lifecycle_subscription_event_types: Option<Vec<i32>>,
) -> api::Message {
api::Message {
fetched_memories: vec![],
id: "message-id".to_string(),
task_id: "task-id".to_string(),
server_message_data: String::new(),
@@ -87,6 +93,7 @@ fn start_agent_v2_tool_call_message(
fn upload_artifact_tool_call_message(path: &str, description: &str) -> api::Message {
api::Message {
fetched_memories: vec![],
id: "message-id".to_string(),
task_id: "task-id".to_string(),
server_message_data: String::new(),
@@ -140,6 +147,7 @@ fn remote_start_agent_v2_execution_mode(
fn file_artifact_created_message(filepath: &str, description: &str) -> api::Message {
api::Message {
fetched_memories: vec![],
id: "message-id".to_string(),
task_id: "task-id".to_string(),
server_message_data: String::new(),
@@ -298,6 +306,7 @@ fn converts_start_agent_tool_call_to_action_with_prompt() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion should succeed");
@@ -327,6 +336,7 @@ fn converts_local_start_agent_v2_without_harness_type_to_defaults() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion should succeed");
@@ -354,6 +364,7 @@ fn converts_upload_artifact_tool_call_to_action() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion should succeed");
@@ -377,6 +388,7 @@ fn converts_file_artifact_created_message_with_filename() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion should succeed");
@@ -407,6 +419,7 @@ fn converts_start_agent_tool_calls_with_different_prompt_lengths() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("partial conversion should succeed");
let updated_output = updated_message
@@ -414,6 +427,7 @@ fn converts_start_agent_tool_calls_with_different_prompt_lengths() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("updated conversion should succeed");
@@ -442,6 +456,7 @@ fn converts_start_agent_with_explicit_empty_lifecycle_subscription() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion should succeed");
@@ -471,6 +486,7 @@ fn converts_start_agent_with_cancelled_and_blocked_lifecycle_subscription() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion should succeed");
@@ -503,6 +519,7 @@ fn converts_remote_start_agent_with_environment_id() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion should succeed");
@@ -520,6 +537,7 @@ fn converts_remote_start_agent_with_environment_id() {
worker_host: String::new(),
harness_type: String::new(),
title: String::new(),
auth_secret_name: None,
}
);
assert_eq!(lifecycle_subscription, None);
@@ -540,6 +558,7 @@ fn converts_remote_start_agent_v2_with_skill_references() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion should succeed");
@@ -552,7 +571,7 @@ fn converts_remote_start_agent_v2_with_skill_references() {
StartAgentExecutionMode::Remote {
environment_id: "env-123".to_string(),
skill_references: vec![
SkillReference::Path("/tmp/SKILL.md".into()),
SkillReference::Path(LocalOrRemotePath::Local(PathBuf::from("/tmp/SKILL.md",))),
SkillReference::BundledSkillId("review-comments".to_string()),
],
model_id: "gpt-test".to_string(),
@@ -560,6 +579,7 @@ fn converts_remote_start_agent_v2_with_skill_references() {
worker_host: "worker-host".to_string(),
harness_type: "claude-code".to_string(),
title: "Remote child".to_string(),
auth_secret_name: None,
}
);
assert_eq!(lifecycle_subscription, None);
@@ -580,6 +600,7 @@ fn converts_local_start_agent_v2_with_harness_type() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion should succeed");
@@ -599,6 +620,7 @@ fn transfer_control_tool_call_converts_to_action_message() {
let task_id = TaskId::new("task".to_string());
let reason = "Please finish the interactive flow".to_string();
let message = api::Message {
fetched_memories: vec![],
id: "message".to_string(),
task_id: "task".to_string(),
server_message_data: String::new(),
@@ -622,6 +644,7 @@ fn transfer_control_tool_call_converts_to_action_message() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("transfer-control conversion should succeed");
+87 -19
View File
@@ -5,14 +5,12 @@ use anyhow::anyhow;
use chrono::{DateTime, Local, Timelike};
use warp_multi_agent_api as api;
use crate::ai::{
agent::{
AIAgentActionResult, AIAgentActionResultType, AIAgentAttachment, AIAgentContext,
AIAgentInput, DriveObjectPayload, MCPContext, PassiveSuggestionResultType,
PassiveSuggestionTrigger, RunningCommand, StaticQueryType, Suggestions, UserQueryMode,
},
block_context::BlockContext,
use crate::ai::agent::{
AIAgentActionResult, AIAgentActionResultType, AIAgentAttachment, AIAgentContext, AIAgentInput,
DriveObjectPayload, MCPContext, PassiveSuggestionResultType, PassiveSuggestionTrigger,
RunningCommand, StaticQueryType, Suggestions, UserQueryMode,
};
use crate::ai::block_context::BlockContext;
fn local_datetime_to_timestamp(timestamp: DateTime<Local>) -> prost_types::Timestamp {
prost_types::Timestamp {
@@ -46,11 +44,6 @@ impl TryFrom<StaticQueryType> for api::request::input::query_with_canned_respons
api::request::input::query_with_canned_response::SomethingElse {},
),
),
StaticQueryType::CustomOnboardingRequest => Ok(
api::request::input::query_with_canned_response::Type::CustomOnboardingRequest(
api::request::input::query_with_canned_response::CustomOnboardingRequest {},
),
),
StaticQueryType::EvaluationSuite => {
Err(anyhow::anyhow!("EvaluationSuite StaticQueryType not yet supported").into())
}
@@ -209,9 +202,9 @@ pub(super) fn convert_input(
)),
});
}
AIAgentInput::SummarizeConversation { prompt } => {
AIAgentInput::SummarizeConversation { prompt, context } => {
return Ok(api::request::Input {
context: None,
context: Some(convert_context(context.as_ref())),
r#type: Some(api::request::input::Type::SummarizeConversation(
api::request::input::SummarizeConversation {
prompt: prompt.unwrap_or_default(),
@@ -435,6 +428,19 @@ fn convert_input_to_user_input(
),
)
}
AIAgentInput::OrchestrationConfigUpdate {
plan_id,
config,
status,
} => Ok(
api::request::input::user_inputs::user_input::Input::OrchestrationConfigUpdate(
api::OrchestrationConfigUpdate {
plan_id,
config: Some(config.to_proto()),
status: status.to_proto(),
},
),
),
AIAgentInput::ResumeConversation { .. } => Err(ConvertToAPITypeError::Ignore),
AIAgentInput::InitProjectRules { .. } => Err(ConvertToAPITypeError::Ignore),
AIAgentInput::CodeReview { .. } => Err(ConvertToAPITypeError::Ignore),
@@ -692,6 +698,12 @@ impl TryFrom<AIAgentActionResult> for api::request::input::user_inputs::user_inp
AIAgentActionResultType::AskUserQuestion(ask_user_question_result) => {
Some(ask_user_question_result.into())
}
AIAgentActionResultType::RunAgents(orchestrate_result) => {
Some(orchestrate_result.try_into()?)
}
AIAgentActionResultType::WaitForEvents(wait_for_events_result) => {
Some(wait_for_events_result.try_into()?)
}
};
Ok(
api::request::input::user_inputs::user_input::Input::ToolCallResult(
@@ -706,6 +718,7 @@ impl TryFrom<AIAgentActionResult> for api::request::input::user_inputs::user_inp
fn convert_context(context: &[AIAgentContext]) -> api::InputContext {
let mut api_context = api::InputContext::default();
let mut git_context = None;
for context in context.iter().cloned() {
match context {
AIAgentContext::Block(block) => {
@@ -789,11 +802,40 @@ fn convert_context(context: &[AIAgentContext]) -> api::InputContext {
}
}
AIAgentContext::Git { head, branch } => {
api_context.git = Some(api::input_context::Git {
head,
branch: branch.unwrap_or_default(),
let api_git_context =
git_context.get_or_insert_with(api::input_context::Git::default);
api_git_context.head = head;
api_git_context.branch = branch.unwrap_or_default();
}
AIAgentContext::Repository { name, owner } => {
let api_git_context =
git_context.get_or_insert_with(api::input_context::Git::default);
api_git_context.repository = Some(api::input_context::git::Repository {
name,
owner: owner.unwrap_or_default(),
});
}
AIAgentContext::PullRequest {
number,
state,
draft,
base_branch,
} => {
if number <= 0 {
continue;
}
let Some(state) = api_pull_request_state(&state, draft) else {
continue;
};
let pull_request = api::input_context::git::PullRequest {
number,
state: state as i32,
base_branch,
};
let api_git_context =
git_context.get_or_insert_with(api::input_context::Git::default);
api_git_context.pull_request = Some(pull_request);
}
AIAgentContext::Skills { skills } => {
api_context.updated_skills_context = Some(api::input_context::SkillsContext {
available_skills: skills
@@ -810,9 +852,34 @@ fn convert_context(context: &[AIAgentContext]) -> api::InputContext {
}
}
}
api_context.git = git_context;
api_context
}
/// Maps a GitHub PR state plus draft flag to the proto `State` enum.
///
/// Returns `None` for unknown states so the caller can skip emitting a
/// `pull_request` sub-message rather than sending `STATE_UNSPECIFIED` to the
/// server.
fn api_pull_request_state(
state: &str,
draft: bool,
) -> Option<api::input_context::git::pull_request::State> {
use api::input_context::git::pull_request::State;
match state.to_ascii_uppercase().as_str() {
"OPEN" => {
if draft {
Some(State::OpenDraft)
} else {
Some(State::Open)
}
}
"CLOSED" => Some(State::Closed),
"MERGED" => Some(State::Merged),
_ => None,
}
}
impl From<Suggestions> for api::Suggestions {
fn from(value: Suggestions) -> Self {
Self {
@@ -940,12 +1007,13 @@ impl From<BlockContext> for api::ExecutedShellCommand {
}
}
/// Trys to convert a [`serde_json::Value`] to a [`prost_types::Value`].
/// Tries to convert a [`serde_json::Value`] to a [`prost_types::Value`].
#[cfg_attr(target_family = "wasm", allow(dead_code))]
fn serde_json_to_prost(value: serde_json::Value) -> Result<prost_types::Value, String> {
use std::collections::BTreeMap;
use prost_types::value::Kind::*;
use serde_json::Value::*;
use std::collections::BTreeMap;
Ok(prost_types::Value {
kind: Some(match value {
+111 -5
View File
@@ -1,11 +1,111 @@
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentActionResult, AIAgentActionResultType, TransferShellCommandControlToUserResult,
};
use crate::terminal::model::block::BlockId;
use chrono::{DateTime, Utc};
use galaxy_core::command::ExitCode;
use warp_multi_agent_api as api;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentActionResult, AIAgentActionResultType, AIAgentContext,
TransferShellCommandControlToUserResult,
};
use crate::terminal::model::block::BlockId;
#[test]
fn git_context_converts_repository_and_pull_request_metadata() {
let context = vec![
AIAgentContext::Git {
head: "abc123".to_string(),
branch: Some("feature/repo-pr".to_string()),
},
AIAgentContext::Repository {
name: "warp-internal".to_string(),
owner: Some("warpdotdev".to_string()),
},
AIAgentContext::PullRequest {
number: 42,
state: "OPEN".to_string(),
draft: true,
base_branch: "main".to_string(),
},
];
let api_context = super::convert_context(&context);
let git = api_context.git.expect("expected git context");
assert_eq!(git.head, "abc123");
assert_eq!(git.branch, "feature/repo-pr");
let repository = git.repository.expect("expected repository context");
assert_eq!(repository.name, "warp-internal");
assert_eq!(repository.owner, "warpdotdev");
let pull_request = git.pull_request.expect("expected pull request context");
assert_eq!(pull_request.number, 42);
assert_eq!(
pull_request.state,
api::input_context::git::pull_request::State::OpenDraft as i32
);
assert_eq!(pull_request.base_branch, "main");
}
#[test]
fn git_context_skips_pull_request_metadata_with_invalid_number() {
for number in [0, -1] {
let context = vec![
AIAgentContext::Git {
head: "abc123".to_string(),
branch: Some("feature/repo-pr".to_string()),
},
AIAgentContext::PullRequest {
number,
state: "OPEN".to_string(),
draft: false,
base_branch: "main".to_string(),
},
];
let api_context = super::convert_context(&context);
let git = api_context.git.expect("expected git context");
assert_eq!(git.head, "abc123");
assert_eq!(git.branch, "feature/repo-pr");
assert_eq!(git.pull_request, None);
}
}
#[test]
fn git_context_skips_pull_request_metadata_with_unknown_state() {
let context = vec![
AIAgentContext::Git {
head: "abc123".to_string(),
branch: Some("feature/repo-pr".to_string()),
},
AIAgentContext::PullRequest {
number: 42,
state: "SOMETHING_ELSE".to_string(),
draft: false,
base_branch: "main".to_string(),
},
];
let api_context = super::convert_context(&context);
let git = api_context.git.expect("expected git context");
assert_eq!(git.pull_request, None);
}
#[test]
fn git_context_deserializes_legacy_string_pull_request_number() {
let pull_request = serde_json::from_str::<AIAgentContext>(
r#"{"PullRequest":{"number":"42","state":"OPEN","draft":false,"base_branch":"main"}}"#,
)
.expect("expected legacy serialized pull request context");
let api_context = super::convert_context(&[pull_request]);
let pull_request = api_context
.git
.expect("expected git context")
.pull_request
.expect("expected pull request context");
assert_eq!(pull_request.number, 42);
}
#[test]
fn transfer_control_snapshot_result_converts_to_tool_call_result_input() {
let block_id = BlockId::default();
@@ -51,6 +151,8 @@ fn transfer_control_snapshot_result_converts_to_tool_call_result_input() {
#[test]
fn transfer_control_finished_result_converts_to_tool_call_result_input() {
let block_id = BlockId::default();
let start_ts = DateTime::from(Utc::now());
let completed_ts = DateTime::from(Utc::now());
let input =
api::request::input::user_inputs::user_input::Input::try_from(AIAgentActionResult {
id: "tool_call".to_string().into(),
@@ -60,6 +162,8 @@ fn transfer_control_finished_result_converts_to_tool_call_result_input() {
block_id: block_id.clone(),
output: "done".to_string(),
exit_code: ExitCode::from(17),
start_ts: Some(start_ts),
completed_ts: Some(completed_ts),
},
),
})
@@ -78,6 +182,8 @@ fn transfer_control_finished_result_converts_to_tool_call_result_input() {
assert_eq!(finished.command_id, block_id.to_string());
assert_eq!(finished.output, "done");
assert_eq!(finished.exit_code, 17);
assert_eq!(finished.start_ts, Some(super::local_datetime_to_timestamp(start_ts)));
assert_eq!(finished.finish_ts, Some(super::local_datetime_to_timestamp(completed_ts)));
}
other => panic!("Expected command-finished result, got {other:?}"),
},
+88 -54
View File
@@ -1,15 +1,15 @@
use std::{collections::HashMap, sync::Arc};
use std::collections::HashMap;
use std::sync::Arc;
use crate::{ai::agent::redaction, terminal::model::session::SessionType};
use futures_util::StreamExt;
use galaxy_core::features::FeatureFlag;
use warp_multi_agent_api as api;
use crate::ai::bedrock::translator::{self, TranslatorRequest};
use crate::ai::openai::translator as openai_translator;
use crate::ai::provider::ProviderConfig;
use super::{convert_to::convert_input, ConvertToAPITypeError, RequestParams, ResponseStream};
use super::convert_to::convert_input;
use super::{ConvertToAPITypeError, RequestParams, ResponseStream};
use crate::ai::agent::redaction;
use crate::server::server_api::{AIApiError, ServerApi};
use crate::terminal::model::session::SessionType;
pub async fn generate_multi_agent_output(
provider_config: ProviderConfig,
@@ -53,10 +53,10 @@ pub async fn generate_multi_agent_output(
redaction::redact_inputs(&mut params.input);
}
let mut api_keys = params.api_keys;
if let Some(api_keys) = &mut api_keys {
api_keys.allow_use_of_warp_credits = params.allow_use_of_warp_credits_with_byok;
}
let api_keys = api_keys_with_warp_credit_fallback_setting(
params.api_keys,
params.allow_use_of_warp_credits,
);
let mut request = api::Request {
task_context: Some(api::request::TaskContext {
@@ -68,6 +68,7 @@ pub async fn generate_multi_agent_output(
base: params.model.into(),
cli_agent: params.cli_agent_model.into(),
computer_use_agent: params.computer_use_model.into(),
base_model_context_window_limit: params.context_window_limit.unwrap_or(0),
..Default::default()
}),
rules_enabled: params.is_memory_enabled,
@@ -99,7 +100,11 @@ pub async fn generate_multi_agent_output(
FeatureFlag::SummarizationViaMessageReplacement.is_enabled(),
supports_bundled_skills: FeatureFlag::BundledSkills.is_enabled(),
supports_research_agent: params.research_agent_enabled,
supports_orchestration_v2: FeatureFlag::OrchestrationV2.is_enabled(),
supports_orchestration_v2: supports_orchestration_v2(params.orchestration_enabled),
supports_background_computer_use: FeatureFlag::BackgroundComputerUse.is_enabled()
&& computer_use::background_supported(),
custom_model_providers: params.custom_model_providers,
custom_model_routers: params.custom_model_routers,
}),
metadata: Some(api::request::Metadata {
logging: logging_metadata,
@@ -113,6 +118,8 @@ pub async fn generate_multi_agent_output(
.map(|id| id.to_string())
.unwrap_or_default(),
forked_from_conversation_id: if params.conversation_token.is_none() {
// We only include this param on our initial request to the server
// (when the forked conversation has not been assigned a new id yet).
params
.forked_from_conversation_token
.map(|token| token.as_str().to_string())
@@ -129,41 +136,19 @@ pub async fn generate_multi_agent_output(
mcp_context: params.mcp_context.map(Into::into),
};
let model_id = request
.settings
.as_ref()
.and_then(|s| s.model_config.as_ref())
.map(|mc| mc.base.clone())
.unwrap_or_default();
match provider_config {
ProviderConfig::Bedrock(config) => {
let translator_request = TranslatorRequest {
config,
model_id,
root_task_id: params.root_task_id.clone(),
bedrock_message_history: params.bedrock_message_history.clone(),
bedrock_tool_result_archive: params.bedrock_tool_result_archive.clone(),
bedrock_progressive_summary: params.bedrock_progressive_summary.clone(),
bedrock_messages_sent: params.bedrock_messages_sent.clone(),
};
match translator::execute(translator_request, &mut request).await {
Ok(stream) => {
let output_stream = stream.take_until(cancellation_rx);
Ok(Box::pin(output_stream))
}
Err(e) => {
log::error!("[bedrock] Translator error: {e}");
let err = Arc::new(crate::server::server_api::AIApiError::Stream {
stream_type: "bedrock_converse",
source: anyhow::anyhow!("{e}"),
});
let (tx, rx) = async_channel::unbounded();
let _ = tx.send(Err(err)).await;
Ok(Box::pin(rx))
}
}
let response_stream =
warp_multi_agent_client::generate_multi_agent_output(server_api.as_ref(), &request).await;
match response_stream {
Ok(stream) => {
let output_stream = stream
.then(|result| async {
match result {
Ok(event) => Ok(event),
Err(error) => Err(convert_multi_agent_client_error(error).await),
}
})
.take_until(cancellation_rx);
Ok(Box::pin(output_stream))
}
ProviderConfig::OpenAI(config) => {
let translator_request = openai_translator::TranslatorRequest {
@@ -202,12 +187,53 @@ pub async fn generate_multi_agent_output(
),
});
let (tx, rx) = async_channel::unbounded();
let _ = tx.send(Err(err)).await;
let _ = tx
.send(Err(convert_multi_agent_client_error(e).await))
.await;
Ok(Box::pin(rx))
}
}
}
async fn convert_multi_agent_client_error(
error: warp_multi_agent_client::Error,
) -> Arc<AIApiError> {
let error = match error {
warp_multi_agent_client::Error::Authentication(error)
| warp_multi_agent_client::Error::AmbientHeaders(error) => AIApiError::Other(error),
warp_multi_agent_client::Error::Base64Decode(error) => {
AIApiError::Other(anyhow::Error::from(error))
}
warp_multi_agent_client::Error::ProtobufDecode(error) => {
AIApiError::Other(anyhow::Error::from(error))
}
warp_multi_agent_client::Error::EventSource(error) => {
AIApiError::from_stream_error("GenerateMultiAgentOutput", *error).await
}
};
Arc::new(error)
}
fn api_keys_with_warp_credit_fallback_setting(
api_keys: Option<api::request::settings::ApiKeys>,
allow_use_of_warp_credits: bool,
) -> Option<api::request::settings::ApiKeys> {
match api_keys {
Some(mut api_keys) => {
api_keys.allow_use_of_warp_credits = allow_use_of_warp_credits;
Some(api_keys)
}
None if allow_use_of_warp_credits => Some(api::request::settings::ApiKeys {
allow_use_of_warp_credits: true,
..Default::default()
}),
None => None,
}
}
fn supports_orchestration_v2(orchestration_enabled: bool) -> bool {
orchestration_enabled
}
fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
let mut supported_tools = vec![
api::ToolType::Grep,
@@ -245,7 +271,14 @@ fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
}
}
Some(SessionType::WarpifiedRemote { host_id: Some(_) }) => {
// Remote session with a known host — enable tools that route
// through RemoteServerClient. The host_id is only populated
// after a successful connection handshake, so its presence is a
// sufficient proxy for client availability.
supported_tools.extend(&[api::ToolType::ReadFiles, api::ToolType::ApplyFileDiffs]);
if FeatureFlag::RemoteCodebaseIndexing.is_enabled() {
supported_tools.push(api::ToolType::SearchCodebase);
}
}
Some(SessionType::WarpifiedRemote { host_id: None }) => {}
}
@@ -264,12 +297,10 @@ fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
}
if params.orchestration_enabled {
supported_tools.push(if FeatureFlag::OrchestrationV2.is_enabled() {
api::ToolType::StartAgentV2
} else {
api::ToolType::StartAgent
});
supported_tools.push(api::ToolType::SendMessageToAgent);
supported_tools.extend([api::ToolType::RunAgents, api::ToolType::SendMessageToAgent]);
// Declare client-handled wait_for_events so the server doesn't
// fall back to the legacy server-handled form.
supported_tools.push(api::ToolType::WaitForEvents);
}
if FeatureFlag::AskUserQuestion.is_enabled() && params.ask_user_question_enabled {
@@ -299,6 +330,9 @@ fn get_supported_cli_agent_tools(params: &RequestParams) -> Vec<api::ToolType> {
}
Some(SessionType::WarpifiedRemote { host_id: Some(_) }) => {
supported_cli_agent_tools.push(api::ToolType::ReadFiles);
if FeatureFlag::RemoteCodebaseIndexing.is_enabled() {
supported_cli_agent_tools.push(api::ToolType::SearchCodebase);
}
}
Some(SessionType::WarpifiedRemote { host_id: None }) => {}
}
+124 -5
View File
@@ -1,10 +1,15 @@
use galaxy_core::features::FeatureFlag;
use galaxy_core::HostId;
use warp_multi_agent_api as api;
use super::{
api_keys_with_warp_credit_fallback_setting, get_supported_cli_agent_tools, get_supported_tools,
supports_orchestration_v2,
};
use crate::ai::agent::api::RequestParams;
use crate::ai::blocklist::SessionContext;
use crate::ai::llms::LLMId;
use galaxy_core::features::FeatureFlag;
use warp_multi_agent_api as api;
use super::get_supported_tools;
use crate::terminal::model::session::SessionType;
fn request_params_with_ask_user_question_enabled(ask_user_question_enabled: bool) -> RequestParams {
let model = LLMId::from("test-model");
@@ -24,11 +29,14 @@ fn request_params_with_ask_user_question_enabled(ask_user_question_enabled: bool
computer_use_model: model,
is_memory_enabled: false,
warp_drive_context_enabled: false,
context_window_limit: None,
mcp_context: None,
planning_enabled: true,
should_redact_secrets: false,
api_keys: None,
allow_use_of_warp_credits_with_byok: false,
custom_model_providers: None,
custom_model_routers: None,
allow_use_of_warp_credits: false,
autonomy_level: api::AutonomyLevel::Supervised,
isolation_level: api::IsolationLevel::None,
web_search_enabled: false,
@@ -47,6 +55,85 @@ fn request_params_with_ask_user_question_enabled(ask_user_question_enabled: bool
}
}
fn request_params_for_remote(host_id: Option<HostId>) -> RequestParams {
let mut params = request_params_with_ask_user_question_enabled(false);
params.session_context =
SessionContext::new_with_session_type_for_test(Some(SessionType::WarpifiedRemote {
host_id,
}));
params
}
#[test]
fn api_keys_with_warp_credit_fallback_setting_returns_none_without_keys_or_fallback() {
let api_keys = api_keys_with_warp_credit_fallback_setting(None, false);
assert!(api_keys.is_none());
}
#[test]
fn api_keys_with_warp_credit_fallback_setting_creates_fallback_only_api_keys() {
let api_keys = api_keys_with_warp_credit_fallback_setting(None, true)
.expect("fallback setting should create ApiKeys");
assert!(api_keys.allow_use_of_warp_credits);
assert!(api_keys.anthropic.is_empty());
assert!(api_keys.openai.is_empty());
assert!(api_keys.google.is_empty());
assert!(api_keys.open_router.is_empty());
assert!(api_keys.aws_credentials.is_none());
}
#[test]
fn api_keys_with_warp_credit_fallback_setting_preserves_existing_keys() {
let api_keys = api_keys_with_warp_credit_fallback_setting(
Some(api::request::settings::ApiKeys {
anthropic: "anthropic-key".to_string(),
openai: String::new(),
google: String::new(),
open_router: String::new(),
grok_oauth_access_token: String::new(),
allow_use_of_warp_credits: false,
aws_credentials: None,
google_cloud_credentials: None,
}),
true,
)
.expect("existing ApiKeys should be preserved");
assert_eq!(api_keys.anthropic, "anthropic-key");
assert!(api_keys.allow_use_of_warp_credits);
}
#[test]
fn supports_orchestration_v2_matches_request_orchestration_setting() {
assert!(supports_orchestration_v2(true));
assert!(!supports_orchestration_v2(false));
}
#[test]
fn supported_tools_include_orchestration_tools_when_orchestration_enabled() {
let mut params = request_params_with_ask_user_question_enabled(false);
params.orchestration_enabled = true;
let supported_tools = get_supported_tools(&params);
assert!(supported_tools.contains(&api::ToolType::RunAgents));
assert!(supported_tools.contains(&api::ToolType::SendMessageToAgent));
assert!(!supported_tools.contains(&api::ToolType::StartAgent));
assert!(!supported_tools.contains(&api::ToolType::StartAgentV2));
}
#[test]
fn supported_tools_omit_orchestration_tools_when_orchestration_disabled() {
let params = request_params_with_ask_user_question_enabled(false);
let supported_tools = get_supported_tools(&params);
assert!(!supported_tools.contains(&api::ToolType::RunAgents));
assert!(!supported_tools.contains(&api::ToolType::SendMessageToAgent));
assert!(!supported_tools.contains(&api::ToolType::StartAgent));
assert!(!supported_tools.contains(&api::ToolType::StartAgentV2));
}
#[test]
fn supported_tools_omits_ask_user_question_when_disabled() {
let params = request_params_with_ask_user_question_enabled(false);
@@ -84,3 +171,35 @@ fn supported_tools_omit_upload_artifact_when_feature_flag_is_disabled() {
assert!(!supported_tools.contains(&api::ToolType::UploadFileArtifact));
}
#[test]
fn remote_supported_tools_include_search_codebase_when_connected_and_feature_flag_is_enabled() {
let _flag = FeatureFlag::RemoteCodebaseIndexing.override_enabled(true);
let params = request_params_for_remote(Some(HostId::new("host".to_string())));
let supported_tools = get_supported_tools(&params);
let supported_cli_agent_tools = get_supported_cli_agent_tools(&params);
assert!(supported_tools.contains(&api::ToolType::SearchCodebase));
assert!(supported_cli_agent_tools.contains(&api::ToolType::SearchCodebase));
}
#[test]
fn remote_supported_tools_omit_search_codebase_when_feature_flag_is_disabled() {
let _flag = FeatureFlag::RemoteCodebaseIndexing.override_enabled(false);
let params = request_params_for_remote(Some(HostId::new("host".to_string())));
let supported_tools = get_supported_tools(&params);
let supported_cli_agent_tools = get_supported_cli_agent_tools(&params);
assert!(!supported_tools.contains(&api::ToolType::SearchCodebase));
assert!(!supported_cli_agent_tools.contains(&api::ToolType::SearchCodebase));
}
#[test]
fn remote_supported_tools_omit_search_codebase_when_remote_is_not_connected() {
let _flag = FeatureFlag::RemoteCodebaseIndexing.override_enabled(true);
let params = request_params_for_remote(None);
let supported_tools = get_supported_tools(&params);
let supported_cli_agent_tools = get_supported_cli_agent_tools(&params);
assert!(!supported_tools.contains(&api::ToolType::SearchCodebase));
assert!(!supported_cli_agent_tools.contains(&api::ToolType::SearchCodebase));
}
+6 -10
View File
@@ -1,5 +1,5 @@
use crate::code::buffer_location::LocalOrRemotePath;
use crate::code_review::comments::CommentId;
use std::path::PathBuf;
/// The current state of a code review.
#[derive(Debug, Clone, Default)]
@@ -31,18 +31,14 @@ impl ReviewComment {
pub fn title(&self) -> String {
match (&self.diff.file_path, self.diff.line_number) {
(Some(file_path), Some(line_number)) => {
let file_name = file_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("Invalid File Name");
let path_component = file_path.path_component();
let file_name = path_component.file_name().unwrap_or("Invalid File Name");
let display_line = line_number + 1;
format!("{file_name}:{display_line}")
}
(Some(file_path), None) => {
let file_name = file_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("Invalid File Name");
let path_component = file_path.path_component();
let file_name = path_component.file_name().unwrap_or("Invalid File Name");
file_name.to_string()
}
(None, _) => self
@@ -101,6 +97,6 @@ impl From<crate::code_review::comments::AttachedReviewCommentTarget> for ReviewD
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct ReviewDiff {
pub file_path: Option<PathBuf>,
pub file_path: Option<LocalOrRemotePath>,
pub line_number: Option<usize>,
}
File diff suppressed because it is too large Load Diff
+692 -5
View File
@@ -1,12 +1,23 @@
use std::collections::HashMap;
use super::{
artifact_from_fork_proto, AIConversation, AIConversationAutoexecuteMode, AIConversationId,
};
use crate::ai::artifacts::Artifact;
use crate::persistence::model::AgentConversationData;
use ai::api_keys::ApiKeyManager;
use galaxy_core::features::FeatureFlag;
use warp_multi_agent_api as api;
use warpui::{App, SingletonEntity};
use super::{
artifact_from_fork_proto, footer_model_token_usage, AIConversation,
AIConversationAutoexecuteMode, AIConversationId, ConversationStatus, RestoreConversationError,
};
use crate::ai::artifacts::Artifact;
use crate::ai::llms::LLMPreferences;
use crate::auth::auth_manager::AuthManager;
use crate::auth::AuthStateProvider;
use crate::network::NetworkStatus;
use crate::persistence::model::AgentConversationData;
use crate::server::server_api::ServerApiProvider;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspaces::user_workspaces::UserWorkspaces;
fn restored_conversation(conversation_data: Option<AgentConversationData>) -> AIConversation {
AIConversation::new_restored(
@@ -24,8 +35,25 @@ fn restored_conversation(conversation_data: Option<AgentConversationData>) -> AI
.unwrap()
}
fn restored_conversation_with_root_description(description: &str) -> AIConversation {
AIConversation::new_restored(
AIConversationId::new(),
vec![api::Task {
id: "root-task".to_string(),
messages: vec![],
dependencies: None,
description: description.to_string(),
summary: String::new(),
server_data: String::new(),
}],
None,
)
.unwrap()
}
fn user_query_message(id: &str, request_id: &str, query: &str) -> api::Message {
api::Message {
fetched_memories: vec![],
id: id.to_string(),
task_id: "root-task".to_string(),
server_message_data: String::new(),
@@ -44,6 +72,7 @@ fn user_query_message(id: &str, request_id: &str, query: &str) -> api::Message {
fn agent_output_message(id: &str, request_id: &str) -> api::Message {
api::Message {
fetched_memories: vec![],
id: id.to_string(),
task_id: "root-task".to_string(),
server_message_data: String::new(),
@@ -86,6 +115,43 @@ fn restored_conversation_with_queries(queries: &[&str]) -> AIConversation {
.unwrap()
}
fn initialize_custom_endpoint_usage_test_app(app: &mut App) {
initialize_settings_for_tests(app);
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
app.add_singleton_model(|_| NetworkStatus::new());
app.add_singleton_model(UserWorkspaces::default_mock);
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(AuthManager::new_for_test);
}
#[allow(deprecated)]
fn custom_endpoint_usage_metadata(
config_key: &str,
total_tokens: u32,
) -> api::response_event::stream_finished::ConversationUsageMetadata {
let category = "primary_agent".to_string();
api::response_event::stream_finished::ConversationUsageMetadata {
context_window_usage: 0.0,
credits_spent: 0.0,
platform_credits_spent: 0.0,
summarized: false,
token_usage: vec![],
tool_usage_metadata: None,
total_input_tokens: 0,
warp_token_usage: HashMap::new(),
byok_token_usage: HashMap::new(),
context_window_segments: Vec::new(),
custom_endpoint_token_usage: HashMap::from([(
config_key.to_string(),
api::response_event::stream_finished::ModelTokenUsage {
model_id: config_key.to_string(),
total_tokens,
token_usage_by_category: HashMap::from([(category, total_tokens)]),
},
)]),
}
}
#[test]
fn latest_user_query_returns_latest_non_empty_user_query() {
let conversation =
@@ -107,6 +173,49 @@ fn latest_user_query_trims_and_skips_empty_queries() {
);
}
#[test]
fn title_uses_root_task_description() {
let conversation = restored_conversation_with_root_description("Root task title");
assert_eq!(conversation.title().as_deref(), Some("Root task title"));
}
#[test]
fn title_falls_back_to_initial_query_when_root_description_is_empty() {
let conversation = restored_conversation_with_queries(&["Initial query"]);
assert_eq!(conversation.title().as_deref(), Some("Initial query"));
}
#[test]
fn reassign_exchange_ids_keeps_exchange_lookup_consistent() {
let mut conversation = restored_conversation_with_queries(&["one", "two"]);
let old_ids: Vec<_> = conversation.all_exchanges().iter().map(|e| e.id).collect();
assert!(!old_ids.is_empty());
// Pre-condition: every original id resolves via the exchange-id index.
for id in &old_ids {
assert!(conversation.exchange_with_id(*id).is_some());
}
conversation.reassign_exchange_ids();
// Reassigning regenerates ids without changing the exchange count, so
// `modify_task` does not rebuild the index; correctness relies on the
// explicit `rebuild_exchange_id_index()` call. The stale ids must be gone.
for id in &old_ids {
assert!(conversation.exchange_with_id(*id).is_none());
}
// Every current id resolves via the rebuilt index.
let new_ids: Vec<_> = conversation.all_exchanges().iter().map(|e| e.id).collect();
assert_eq!(new_ids.len(), old_ids.len());
for id in &new_ids {
assert!(conversation.exchange_with_id(*id).is_some());
}
}
#[test]
fn restored_conversation_defaults_autoexecute_override_when_not_persisted() {
let _flag = FeatureFlag::RememberFastForwardState.override_enabled(true);
@@ -121,6 +230,416 @@ fn restored_conversation_defaults_autoexecute_override_when_not_persisted() {
);
}
#[test]
fn restored_conversation_uses_persisted_last_event_sequence() {
let conversation_data: AgentConversationData =
serde_json::from_str(r#"{"server_conversation_token":null,"last_event_sequence":42}"#)
.unwrap();
let conversation = restored_conversation(Some(conversation_data));
assert_eq!(conversation.last_event_sequence(), Some(42));
}
#[test]
fn restored_conversation_uses_persisted_remote_child_marker() {
let conversation_data: AgentConversationData =
serde_json::from_str(r#"{"server_conversation_token":null,"is_remote_child":true}"#)
.unwrap();
let conversation = restored_conversation(Some(conversation_data));
assert!(conversation.is_remote_child());
}
#[test]
fn child_conversation_detection_uses_parent_agent_id() {
let conversation_data: AgentConversationData = serde_json::from_str(
r#"{"server_conversation_token":null,"parent_agent_id":"parent-run-id"}"#,
)
.unwrap();
let conversation = restored_conversation(Some(conversation_data));
assert!(conversation.is_child_agent_conversation());
assert_eq!(conversation.parent_conversation_id(), None);
}
/// When the persisted task list is empty (e.g. a child conversation persisted
/// before any server response), restoring via `new_restored_synthesizing_on_empty`
/// must produce a fresh in-progress optimistic root, mirroring
/// `AIConversation::new()`.
#[test]
fn restored_conversation_with_empty_task_list_creates_in_progress_optimistic_root() {
let conversation =
AIConversation::new_restored_synthesizing_on_empty(AIConversationId::new(), vec![], None)
.expect("empty task list must synthesize an optimistic root");
let root_task = conversation
.get_root_task()
.expect("synthesized root task should exist");
assert!(root_task.is_root_task());
assert!(
root_task.source().is_none(),
"synthesized root is optimistic and has no api::Task source"
);
assert!(
!root_task.id().to_string().is_empty(),
"synthesized optimistic root must have a non-empty UUID id"
);
assert_eq!(conversation.status(), &ConversationStatus::InProgress);
assert!(conversation.status_error_message().is_none());
}
#[test]
fn update_cost_and_usage_resolves_custom_endpoint_alias_for_footer_usage() {
App::test((), |mut app| async move {
initialize_custom_endpoint_usage_test_app(&mut app);
ApiKeyManager::handle(&app).update(&mut app, |manager, ctx| {
manager.add_custom_endpoint(
"Endpoint".to_string(),
"https://custom.example".to_string(),
"key".to_string(),
vec![(
"raw-model".to_string(),
Some("Friendly alias".to_string()),
Some("config-key".to_string()),
)],
ctx,
);
});
app.add_singleton_model(LLMPreferences::new);
let mut conversation = AIConversation::new(false, false);
app.read(|ctx| {
conversation
.update_cost_and_usage_for_request(
None,
vec![],
Some(custom_endpoint_usage_metadata("config-key", 6)),
false,
ctx,
)
.expect("custom endpoint usage should update");
});
let usage = conversation
.token_usage()
.iter()
.find(|usage| usage.model_id == "Friendly alias")
.expect("custom endpoint alias should resolve into footer usage");
assert_eq!(usage.custom_endpoint_tokens, 6);
assert_eq!(usage.byok_tokens, 0);
assert_eq!(
usage
.custom_endpoint_token_usage_by_category
.get("primary_agent"),
Some(&6)
);
});
}
#[test]
fn update_cost_and_usage_uses_fallback_label_for_unknown_custom_endpoint() {
App::test((), |mut app| async move {
initialize_custom_endpoint_usage_test_app(&mut app);
app.add_singleton_model(LLMPreferences::new);
let mut conversation = AIConversation::new(false, false);
app.read(|ctx| {
conversation
.update_cost_and_usage_for_request(
None,
vec![],
Some(custom_endpoint_usage_metadata("missing-config-key", 9)),
false,
ctx,
)
.expect("fallback custom endpoint usage should update");
});
let usage = conversation
.token_usage()
.iter()
.find(|usage| usage.model_id == "Custom endpoint")
.expect("unknown custom endpoint usage should use the fallback label");
assert_eq!(usage.custom_endpoint_tokens, 9);
assert_eq!(usage.byok_tokens, 0);
assert_eq!(
usage
.custom_endpoint_token_usage_by_category
.get("primary_agent"),
Some(&9)
);
});
}
#[allow(deprecated)]
#[test]
fn footer_model_token_usage_keeps_custom_endpoint_usage_distinct_from_same_labeled_models() {
App::test((), |mut app| async move {
initialize_custom_endpoint_usage_test_app(&mut app);
ApiKeyManager::handle(&app).update(&mut app, |manager, ctx| {
manager.add_custom_endpoint(
"Endpoint".to_string(),
"https://custom.example".to_string(),
"key".to_string(),
vec![(
"raw-model".to_string(),
Some("Resolved custom".to_string()),
Some("config-key".to_string()),
)],
ctx,
);
});
app.add_singleton_model(LLMPreferences::new);
let category = "primary_agent".to_string();
let usage_metadata = api::response_event::stream_finished::ConversationUsageMetadata {
context_window_usage: 0.0,
credits_spent: 0.0,
platform_credits_spent: 0.0,
summarized: false,
#[allow(deprecated)]
token_usage: vec![],
tool_usage_metadata: None,
total_input_tokens: 0,
warp_token_usage: HashMap::new(),
byok_token_usage: HashMap::from([(
"Resolved custom".to_string(),
api::response_event::stream_finished::ModelTokenUsage {
model_id: "Resolved custom".to_string(),
total_tokens: 4,
token_usage_by_category: HashMap::from([(category.clone(), 4)]),
},
)]),
custom_endpoint_token_usage: HashMap::from([(
"config-key".to_string(),
api::response_event::stream_finished::ModelTokenUsage {
model_id: "config-key".to_string(),
total_tokens: 6,
token_usage_by_category: HashMap::from([(category.clone(), 6)]),
},
)]),
context_window_segments: Vec::new(),
};
let model_usage =
app.read(|ctx| footer_model_token_usage(&usage_metadata, LLMPreferences::as_ref(ctx)));
let byok_usage = model_usage
.iter()
.find(|usage| usage.model_id == "Resolved custom" && usage.byok_tokens == 4)
.expect("existing model usage should be present");
let custom_usage = model_usage
.iter()
.find(|usage| usage.model_id == "Resolved custom" && usage.custom_endpoint_tokens == 6)
.expect("custom endpoint usage should remain distinct");
assert_eq!(model_usage.len(), 2);
assert_eq!(
byok_usage.byok_token_usage_by_category.get(&category),
Some(&4)
);
assert_eq!(
custom_usage
.custom_endpoint_token_usage_by_category
.get(&category),
Some(&6)
);
assert_eq!(byok_usage.warp_tokens, 0);
assert_eq!(custom_usage.warp_tokens, 0);
assert_eq!(custom_usage.byok_tokens, 0);
});
}
#[allow(deprecated)]
#[test]
fn footer_model_token_usage_preserves_unresolved_custom_endpoint_usage_with_fallback_label() {
App::test((), |mut app| async move {
initialize_custom_endpoint_usage_test_app(&mut app);
app.add_singleton_model(LLMPreferences::new);
let category = "primary_agent".to_string();
let usage_metadata = api::response_event::stream_finished::ConversationUsageMetadata {
context_window_usage: 0.0,
credits_spent: 0.0,
platform_credits_spent: 0.0,
summarized: false,
#[allow(deprecated)]
token_usage: vec![],
tool_usage_metadata: None,
total_input_tokens: 0,
warp_token_usage: HashMap::new(),
byok_token_usage: HashMap::new(),
custom_endpoint_token_usage: HashMap::from([(
"missing-config-key".to_string(),
api::response_event::stream_finished::ModelTokenUsage {
model_id: "missing-config-key".to_string(),
total_tokens: 9,
token_usage_by_category: HashMap::from([(category.clone(), 9)]),
},
)]),
context_window_segments: Vec::new(),
};
let model_usage =
app.read(|ctx| footer_model_token_usage(&usage_metadata, LLMPreferences::as_ref(ctx)));
let custom_usage = model_usage
.iter()
.find(|usage| usage.model_id == "Custom endpoint")
.expect("fallback custom endpoint usage should be present");
assert_eq!(model_usage.len(), 1);
assert_eq!(custom_usage.custom_endpoint_tokens, 9);
assert_eq!(custom_usage.byok_tokens, 0);
assert_eq!(
custom_usage
.custom_endpoint_token_usage_by_category
.get(&category),
Some(&9)
);
assert_eq!(custom_usage.warp_tokens, 0);
});
}
/// The legacy `AgentConversationData.root_task_is_optimistic` flag must be
/// ignored on restore. A non-empty task list always produces a real
/// server-backed root regardless of whether the flag is set.
#[test]
fn restored_conversation_ignores_legacy_root_task_is_optimistic_flag_with_non_empty_tasks() {
let conversation_data: AgentConversationData = serde_json::from_str(
r#"{"server_conversation_token":null,"root_task_is_optimistic":true}"#,
)
.unwrap();
let conversation = restored_conversation(Some(conversation_data));
let root_task = conversation
.get_root_task()
.expect("root task should exist");
assert_eq!(root_task.id().to_string(), "root-task");
assert!(root_task.is_root_task());
assert!(
root_task.source().is_some(),
"with a real task list, the legacy optimistic flag must be ignored",
);
}
/// The legacy `root_task_is_optimistic` flag is ignored when restoring an
/// empty task list via `new_restored_synthesizing_on_empty`.
#[test]
fn restored_conversation_ignores_legacy_root_task_is_optimistic_flag_with_empty_tasks() {
let conversation_data: AgentConversationData = serde_json::from_str(
r#"{"server_conversation_token":null,"root_task_is_optimistic":true}"#,
)
.unwrap();
let conversation = AIConversation::new_restored_synthesizing_on_empty(
AIConversationId::new(),
vec![],
Some(conversation_data),
)
.expect("empty task list must synthesize an optimistic root regardless of legacy flag");
let root_task = conversation
.get_root_task()
.expect("synthesized root task should exist");
assert!(root_task.is_root_task());
assert!(root_task.source().is_none());
assert_eq!(conversation.status(), &ConversationStatus::InProgress);
}
/// Strict `new_restored` returns `NoRootTask` for an empty task list.
#[test]
fn new_restored_with_empty_task_list_returns_no_root_task_error() {
let result = AIConversation::new_restored(AIConversationId::new(), vec![], None);
assert!(
matches!(result, Err(RestoreConversationError::NoRootTask)),
"empty task list via strict new_restored must return NoRootTask; got {result:?}",
);
}
/// When multiple parentless tasks exist (e.g. a legacy orphan optimistic
/// stub alongside the real server root), `new_restored` must prefer the
/// candidate whose `messages` is non-empty. Each ordering runs in a loop to
/// surface any nondeterminism in candidate selection.
#[test]
fn test_new_restored_prefers_parentless_task_with_messages_over_empty_stub() {
let stub = api::Task {
id: "optimistic-stub-uuid".to_string(),
messages: vec![],
dependencies: None,
description: String::new(),
summary: String::new(),
server_data: String::new(),
};
let real = api::Task {
id: "server-root-id".to_string(),
messages: vec![user_query_message("user-msg", "request-1", "real query")],
dependencies: None,
description: String::new(),
summary: String::new(),
server_data: String::new(),
};
// Stub appears first in the vec.
for _ in 0..50 {
let conversation = AIConversation::new_restored(
AIConversationId::new(),
vec![stub.clone(), real.clone()],
None,
)
.expect("restore with stub + real parentless tasks must succeed");
let root_task = conversation
.get_root_task()
.expect("restored conversation must have a root task");
assert_eq!(
root_task.id().to_string(),
"server-root-id",
"expected the real (non-empty) parentless task to win when stub is first",
);
let source = root_task
.source()
.expect("chosen root must have api::Task source");
assert!(
!source.messages.is_empty(),
"chosen root must have non-empty messages",
);
}
// Real appears first in the vec.
for _ in 0..50 {
let conversation = AIConversation::new_restored(
AIConversationId::new(),
vec![real.clone(), stub.clone()],
None,
)
.expect("restore with real + stub parentless tasks must succeed");
let root_task = conversation
.get_root_task()
.expect("restored conversation must have a root task");
assert_eq!(
root_task.id().to_string(),
"server-root-id",
"expected the real (non-empty) parentless task to win when real is first",
);
let source = root_task
.source()
.expect("chosen root must have api::Task source");
assert!(
!source.messages.is_empty(),
"chosen root must have non-empty messages",
);
}
}
#[test]
fn cli_agent_transcript_vehicle_is_excluded_from_navigation() {
let conversation = AIConversation::new(false, true);
assert!(conversation.should_exclude_from_navigation());
}
#[test]
fn restored_conversation_defaults_unknown_persisted_autoexecute_override() {
let _flag = FeatureFlag::RememberFastForwardState.override_enabled(true);
@@ -197,3 +716,171 @@ fn fork_artifacts_adds_file_artifacts_to_conversation() {
})
);
}
#[test]
fn waiting_for_events_display_label_is_waiting() {
assert_eq!(
format!("{}", ConversationStatus::WaitingForEvents),
"Waiting"
);
}
/// `is_done` returns true only for `Success | Error | Cancelled`;
/// `WaitingForEvents` and `Blocked` are not done because the run can still
/// resume on its own.
#[test]
fn is_done_only_includes_success_error_cancelled() {
assert!(ConversationStatus::Success.is_done());
assert!(ConversationStatus::Error.is_done());
assert!(ConversationStatus::Cancelled.is_done());
assert!(!ConversationStatus::InProgress.is_done());
assert!(!ConversationStatus::Blocked {
blocked_action: "approve".to_string()
}
.is_done());
assert!(!ConversationStatus::WaitingForEvents.is_done());
}
/// `is_waiting_for_events` is true only for the new variant.
#[test]
fn is_waiting_for_events_returns_true_only_for_waiting_for_events_variant() {
assert!(ConversationStatus::WaitingForEvents.is_waiting_for_events());
assert!(!ConversationStatus::InProgress.is_waiting_for_events());
assert!(!ConversationStatus::Success.is_waiting_for_events());
assert!(!ConversationStatus::Error.is_waiting_for_events());
assert!(!ConversationStatus::Cancelled.is_waiting_for_events());
assert!(!ConversationStatus::Blocked {
blocked_action: "approve".to_string()
}
.is_waiting_for_events());
}
/// A conversation that was yielded via `wait_for_events` at shutdown
/// restores as whatever `derive_status_from_root_task` returns (Success
/// for a cleanly-streamed last exchange). The unresolved tool call stays
/// in the transcript as an orphan; the next outbound request triggers
/// the server's existing supersede mechanism to synthesize the matching
/// `Cancel`. The waiting state itself is not durable across restart.
#[test]
fn restored_conversation_does_not_re_enter_waiting_for_events() {
let conversation_data: AgentConversationData =
serde_json::from_str(r#"{"server_conversation_token":null}"#).unwrap();
let conversation = restored_conversation(Some(conversation_data));
assert_eq!(conversation.status(), &ConversationStatus::Success);
}
fn fetched_memory(
memory_id: &str,
content: &str,
memory_store_id: &str,
source: Option<api::message::fetched_memory::Source>,
) -> api::message::FetchedMemory {
api::message::FetchedMemory {
memory_id: memory_id.to_string(),
content: content.to_string(),
memory_store_id: memory_store_id.to_string(),
source,
}
}
fn conversation_source(conversation_id: &str) -> Option<api::message::fetched_memory::Source> {
Some(api::message::fetched_memory::Source::Conversation(
api::message::fetched_memory::Conversation {
conversation_id: conversation_id.to_string(),
},
))
}
fn restored_conversation_with_memories_per_query(
memories_per_query: Vec<Vec<api::message::FetchedMemory>>,
) -> AIConversation {
let messages = memories_per_query
.into_iter()
.enumerate()
.flat_map(|(index, memories)| {
let request_id = format!("request-{index}");
let query = api::Message {
fetched_memories: memories,
..user_query_message(&format!("user-{index}"), &request_id, "query")
};
[
query,
agent_output_message(&format!("agent-{index}"), &request_id),
]
})
.collect();
AIConversation::new_restored(
AIConversationId::new(),
vec![api::Task {
id: "root-task".to_string(),
messages,
..Default::default()
}],
None,
)
.unwrap()
}
#[test]
fn fetched_memories_is_empty_when_no_message_has_memories() {
let conversation = restored_conversation_with_memories_per_query(vec![vec![]]);
assert_eq!(conversation.fetched_memories(), vec![]);
}
#[test]
fn fetched_memories_preserves_order_across_and_within_messages() {
let conversation = restored_conversation_with_memories_per_query(vec![
vec![
fetched_memory("m1", "first", "store-1", None),
fetched_memory("m2", "second", "store-1", None),
],
vec![fetched_memory("m3", "third", "store-2", None)],
]);
let ids: Vec<String> = conversation
.fetched_memories()
.into_iter()
.map(|memory| memory.memory_id)
.collect();
assert_eq!(ids, vec!["m1", "m2", "m3"]);
}
#[test]
fn fetched_memories_dedupes_keeping_first_position_and_latest_data() {
let conversation = restored_conversation_with_memories_per_query(vec![
vec![
fetched_memory("m1", "old content", "store-1", None),
fetched_memory("m2", "other", "store-1", None),
],
vec![
fetched_memory(
"m1",
"new content",
"store-1",
conversation_source("conversation-1"),
),
fetched_memory("m1", "same memory id different store", "store-2", None),
],
]);
let memories = conversation.fetched_memories();
assert_eq!(
memories,
vec![
fetched_memory(
"m1",
"new content",
"store-1",
conversation_source("conversation-1"),
),
fetched_memory("m2", "other", "store-1", None),
fetched_memory("m1", "same memory id different store", "store-2", None),
]
);
}
+70 -4
View File
@@ -9,11 +9,10 @@ use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use warp_multi_agent_api as api;
use api::message::tool_call::Tool;
use api::message::tool_call_result::Result as ToolCallResultType;
use api::message::Message;
use warp_multi_agent_api as api;
use super::task::helper::{SubagentExt, ToolExt};
@@ -243,7 +242,8 @@ fn write_task_messages(
| Message::SystemQuery(_)
| Message::CodeReview(_)
| Message::ServerEvent(_)
| Message::InvokeSkill(_) => {}
| Message::InvokeSkill(_)
| Message::OrchestrationConfigSnapshot(_) => {}
}
}
Ok(())
@@ -507,6 +507,19 @@ fn write_tool_call_args(out: &mut String, tool: &Tool) {
out.push_str(&format!(" - deleted: {}\n", df.file_path));
}
}
Tool::RunAgents(o) => {
out.push_str(&format!(
"summary: \"{}\"\n",
escape_yaml_string(&o.summary)
));
out.push_str("agents:\n");
for cfg in &o.agent_run_configs {
out.push_str(&format!(
" - name: \"{}\"\n",
escape_yaml_string(&cfg.name)
));
}
}
// No additional args worth serializing.
Tool::ReadShellCommandOutput(_)
| Tool::UseComputer(_)
@@ -519,13 +532,65 @@ fn write_tool_call_args(out: &mut String, tool: &Tool) {
| Tool::InitProject(_)
| Tool::Server(_)
| Tool::Subagent(_)
| Tool::TransferShellCommandControlToUser(_) => {}
| Tool::TransferShellCommandControlToUser(_)
| Tool::WaitForEvents(_) => {}
}
}
/// Writes content from structured tool call results.
fn write_tool_call_result_content(out: &mut String, result: &ToolCallResultType) {
match result {
ToolCallResultType::RunAgentsResult(r) => match &r.outcome {
Some(api::run_agents_result::Outcome::Launched(launched)) => {
out.push_str("status: launched\n");
out.push_str(&format!("agent_count: {}\n", launched.agents.len()));
if !launched.agents.is_empty() {
out.push_str("agents:\n");
for agent in &launched.agents {
out.push_str(&format!(
" - name: \"{}\"\n",
escape_yaml_string(&agent.name)
));
match &agent.result {
Some(api::run_agents_result::agent_outcome::Result::Launched(
launched,
)) => {
out.push_str(" status: launched\n");
out.push_str(&format!(" agent_id: {}\n", launched.agent_id));
}
Some(api::run_agents_result::agent_outcome::Result::Failed(failed)) => {
out.push_str(" status: failed\n");
out.push_str(&format!(
" error: \"{}\"\n",
escape_yaml_string(&failed.error)
));
}
None => {
out.push_str(" status: unknown\n");
}
}
}
out.push_str(
"next_step: \"Use send_message_to_agent with the existing agent_id instead of running agents again.\"\n",
);
}
}
Some(api::run_agents_result::Outcome::Denied(denied)) => {
out.push_str("status: launch_denied\n");
out.push_str(&format!(
"reason: \"{}\"\n",
escape_yaml_string(&denied.reason)
));
}
Some(api::run_agents_result::Outcome::Failure(failure)) => {
out.push_str("status: failure\n");
out.push_str(&format!(
"error: \"{}\"\n",
escape_yaml_string(&failure.error)
));
}
None => {}
},
ToolCallResultType::StartAgentV2(r) => match &r.result {
Some(api::start_agent_v2_result::Result::Success(s)) => {
out.push_str(&format!("agent_id: {}\n", s.agent_id));
@@ -535,6 +600,7 @@ fn write_tool_call_result_content(out: &mut String, result: &ToolCallResultType)
}
None => {}
},
ToolCallResultType::WaitForEvents(_) => {}
ToolCallResultType::RunShellCommand(r) => {
if let Some(res) = &r.result {
use api::run_shell_command_result::Result;
+68 -2
View File
@@ -3,12 +3,11 @@ use std::path::Path;
use warp_multi_agent_api as api;
use super::{base_dir, materialize_tasks_to_yaml};
use crate::test_util::ai_agent_tasks::{
create_api_subtask, create_api_task, create_message, create_subagent_tool_call_message,
};
use super::{base_dir, materialize_tasks_to_yaml};
/// Lists filenames (not full paths) in a directory, sorted.
fn list_dir_sorted(dir: &Path) -> Vec<String> {
let mut entries: Vec<String> = fs::read_dir(dir)
@@ -22,6 +21,7 @@ fn list_dir_sorted(dir: &Path) -> Vec<String> {
fn make_user_query_message(id: &str, task_id: &str, query: &str) -> api::Message {
api::Message {
fetched_memories: vec![],
id: id.to_string(),
task_id: task_id.to_string(),
server_message_data: String::new(),
@@ -45,6 +45,7 @@ fn make_tool_call_message(
tool: api::message::tool_call::Tool,
) -> api::Message {
api::Message {
fetched_memories: vec![],
id: id.to_string(),
task_id: task_id.to_string(),
server_message_data: String::new(),
@@ -65,6 +66,7 @@ fn make_tool_call_result_message(
result: api::message::tool_call_result::Result,
) -> api::Message {
api::Message {
fetched_memories: vec![],
id: id.to_string(),
task_id: task_id.to_string(),
server_message_data: String::new(),
@@ -133,6 +135,70 @@ fn mixed_message_types_produce_sequentially_indexed_files() {
cleanup_dir(&dir);
}
#[test]
fn run_agents_result_serializes_agent_ids() {
let task_id = "root";
let tasks = vec![create_api_task(
task_id,
vec![make_tool_call_result_message(
"m1",
task_id,
"tc_run_agents",
api::message::tool_call_result::Result::RunAgentsResult(api::RunAgentsResult {
outcome: Some(api::run_agents_result::Outcome::Launched(
api::run_agents_result::Launched {
resolved_model_id: "auto".to_string(),
resolved_harness: Some(api::Harness {
variant: Some(api::harness::Variant::Oz(api::harness::Oz {})),
}),
resolved_execution_mode: Some(
api::run_agents_result::launched::ResolvedExecutionMode::Local(
api::run_agents::Local {},
),
),
agents: vec![
api::run_agents_result::AgentOutcome {
name: "child".to_string(),
result: Some(
api::run_agents_result::agent_outcome::Result::Launched(
api::run_agents_result::LaunchedAgent {
agent_id: "agent-123".to_string(),
},
),
),
},
api::run_agents_result::AgentOutcome {
name: "other".to_string(),
result: Some(
api::run_agents_result::agent_outcome::Result::Failed(
api::run_agents_result::FailedAgent {
error: "failed to start".to_string(),
},
),
),
},
],
},
)),
}),
)],
)];
let dir = materialize_tasks_to_yaml(&tasks).unwrap();
let files = list_dir_sorted(Path::new(&dir));
let content = fs::read_to_string(Path::new(&dir).join(&files[0])).unwrap();
assert!(content.contains("status: launched"));
assert!(content.contains("agent_count: 2"));
assert!(content.contains("name: \"child\""));
assert!(content.contains("agent_id: agent-123"));
assert!(content.contains("name: \"other\""));
assert!(content.contains("error: \"failed to start\""));
assert!(content.contains("Use send_message_to_agent with the existing agent_id"));
cleanup_dir(&dir);
}
#[test]
fn subagent_file_and_subdirectory_share_same_index() {
let root_id = "root";
+4 -2
View File
@@ -1,6 +1,8 @@
use galaxy_core::ui::{appearance::Appearance, theme::AnsiColorIdentifier};
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::AnsiColorIdentifier;
use crate::ui_components::{blended_colors, icons::Icon};
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
pub fn todo_list_icon(appearance: &Appearance) -> galaxyui::elements::Icon {
galaxyui::elements::Icon::new(
+5 -1
View File
@@ -1,11 +1,13 @@
use std::collections::HashMap;
use super::*;
use warp_multi_agent_api as api;
use super::*;
// Helper function to create a basic message
fn create_message(id: &str, task_id: &str) -> api::Message {
api::Message {
fetched_memories: vec![],
id: id.to_string(),
task_id: task_id.to_string(),
server_message_data: "server_data".to_string(),
@@ -22,6 +24,7 @@ fn create_message(id: &str, task_id: &str) -> api::Message {
fn create_subagent_tool_call_message(id: &str, task_id: &str, subtask_id: &str) -> api::Message {
api::Message {
fetched_memories: vec![],
id: id.to_string(),
task_id: task_id.to_string(),
server_message_data: "server_data".to_string(),
@@ -44,6 +47,7 @@ fn create_subagent_tool_call_message(id: &str, task_id: &str, subtask_id: &str)
// Helper function to create a tool call result message.
fn create_tool_call_result_message(id: &str, task_id: &str, tool_call_id: &str) -> api::Message {
api::Message {
fetched_memories: vec![],
id: id.to_string(),
task_id: task_id.to_string(),
server_message_data: "server_data".to_string(),
+397 -96
View File
@@ -12,50 +12,48 @@ mod task_store;
pub(super) mod telemetry;
pub(super) mod util;
// Re-export types that were moved to the ai crate.
pub use ai::agent::{action::*, action_result::*, AIAgentCitation, FileLocations};
use galaxy_core::features::FeatureFlag;
#[cfg(test)]
mod suggestion_test;
use crate::ai::block_context::BlockContext;
use crate::ai::blocklist::block::view_impl::output::are_all_text_sections_empty;
use crate::ai::skills::SkillDescriptor;
use crate::code::editor_management::CodeSource;
use crate::code_review::comments::{
AttachedReviewComment as CodeReviewComment, ReviewCommentBatch,
};
use crate::search::slash_command_menu::static_commands::commands;
use crate::server::server_api::AIApiError;
use ai::skills::ParsedSkill;
use chrono::{DateTime, Local, TimeDelta};
use comment::ReviewComment;
use task::TaskId;
pub use telemetry::AIIdentifiers;
use galaxy_editor::render::model::LineCount;
use parking_lot::RwLock;
use std::collections::{HashMap, HashSet};
use std::fmt::Display;
use std::ops::{AddAssign, Deref, DerefMut, Range};
use std::sync::Arc;
use std::time::Duration;
// Re-export types that were moved to the ai crate.
pub use ai::agent::action::*;
pub use ai::agent::action_result::*;
use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus};
pub use ai::agent::{AIAgentCitation, FileLocations};
use ai::skills::ParsedSkill;
use chrono::{DateTime, Local, TimeDelta};
use comment::ReviewComment;
use derivative::Derivative;
use markdown_parser::{parse_markdown, FormattedTable, FormattedText, FormattedTextInline};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use session_sharing_protocol::common::ParticipantId;
use task::TaskId;
pub use telemetry::AIIdentifiers;
use uuid::Uuid;
use galaxy_core::channel::ChannelState;
use galaxy_core::features::FeatureFlag;
use warp_editor::render::model::LineCount;
use warp_multi_agent_api::{diff_hunk as diff_hunk_api, AgentEvent, AgentType};
pub use self::api::{MaybeAIAgentOutputMessage, MessageToAIAgentOutputMessageError};
use super::llms::LLMId;
use crate::ai::block_context::BlockContext;
use crate::ai::blocklist::block::view_impl::output::are_all_text_sections_empty;
use crate::ai::skills::SkillDescriptor;
use crate::ai_assistant::execution_context::WarpAiExecutionContext;
use crate::code::editor_management::CodeSource;
use crate::code_review::comments::{
AttachedReviewComment as CodeReviewComment, ReviewCommentBatch,
};
use crate::search::slash_command_menu::static_commands::commands;
use crate::server::server_api::{AIApiError, DeserializationError};
use crate::terminal::model::block::BlockId;
use crate::terminal::shell::ShellType;
use crate::terminal::view::block_onboarding::onboarding_agentic_suggestions_block::OnboardingChipType;
use crate::TelemetryEvent;
use derivative::Derivative;
use markdown_parser::{parse_markdown, FormattedTable, FormattedText, FormattedTextInline};
use serde::{Deserialize, Serialize};
use session_sharing_protocol::common::ParticipantId;
use super::llms::LLMId;
/// A server supplied ID for a specific AI generated output.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
@@ -84,6 +82,8 @@ impl ServerOutputId {
pub enum CancellationReason {
/// The user explicitly cancelled without providing a follow-up.
ManuallyCancelled,
/// Warp automatically cancelled the local run so it could continue in Cloud Mode.
AutomaticCloudHandoff,
/// The user submitted a follow-up query during streaming which implicitly cancelled the current one.
FollowUpSubmitted {
@@ -99,22 +99,65 @@ pub enum CancellationReason {
// The user deleted the conversation while it was in progress.
Deleted,
/// The long-running command completed while the agent was still streaming.
/// The long-running command completed while the agent was still streaming a response started via inline agent view.
/// This should be treated as a successful completion, not a cancellation.
OptimisticCLISubagentCompletion,
/// Note this is only used for inline agent view (user starting an agent to monitor an already running command),
/// not when CLI subagent monitors a requested command.
CommandFinishedDuringInlineAgentView,
/// The user manually took control of a long-running command away from the agent.
/// The agent conversation is still in progress — it will resume after the command
/// finishes or once the user hands control back. The stream is cancelled only to
/// stop the CLI subagent monitoring loop, not to end the conversation.
CLISubagentUserTakeover,
/// An agent-issued command caused the shell process to exit (e.g. it ran
/// `exit`, or ran a failing command after enabling `set -e`). The in-flight
/// stream/actions are cancelled to stop work, but the conversation is
/// finalized as a terminal `Error` (with a shell-exit message) by the
/// controller rather than reported as a user cancellation.
AgentExitedShell,
}
/// How a [`CancellationReason`] maps to the conversation's resulting status.
/// This is the single source of truth consumed by the stream- and
/// action-cancellation machinery; see [`CancellationReason::conversation_outcome`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CancellationOutcome {
/// Leave the conversation `InProgress`; it will continue on its own (a
/// follow-up request or a resumed long-running command) without further user
/// input.
KeepInProgress,
/// Finalize the conversation as a successful completion (`Success`).
Succeeded,
/// Finalize the conversation as a user cancellation (`Cancelled`).
Cancelled,
/// Terminal, but a dedicated path (not the cancellation machinery) writes the
/// status — the cancellation is only a stop signal and must not stamp a status.
/// Currently used for shell exit, which is finalized as `Error` by
/// `fail_conversation_due_to_shell_exit`. Unlike `KeepInProgress`, the
/// conversation is ending; only the status write is suppressed.
FinalizedExternally,
}
impl Display for CancellationReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CancellationReason::ManuallyCancelled => write!(f, "manual cancellation"),
CancellationReason::AutomaticCloudHandoff => write!(f, "automatic cloud handoff"),
CancellationReason::FollowUpSubmitted { .. } => write!(f, "follow-up submission"),
CancellationReason::UserCommandExecuted => write!(f, "user command execution"),
CancellationReason::Reverted => write!(f, "revert"),
CancellationReason::Deleted => write!(f, "deleted"),
CancellationReason::OptimisticCLISubagentCompletion => {
CancellationReason::CommandFinishedDuringInlineAgentView => {
write!(f, "LRC command completed")
}
CancellationReason::CLISubagentUserTakeover => {
write!(f, "CLI subagent user takeover")
}
CancellationReason::AgentExitedShell => {
write!(f, "agent command exited the shell")
}
}
}
}
@@ -139,8 +182,36 @@ impl CancellationReason {
matches!(self, CancellationReason::Reverted)
}
pub fn is_lrc_command_completed(&self) -> bool {
matches!(self, CancellationReason::OptimisticCLISubagentCompletion)
/// How a cancellation reason maps to the
/// conversation's resulting status. Every site that finalizes a cancelled
/// stream or action consults this instead of re-deriving the disposition,
/// so the reason -> status mapping lives in one exhaustive place.
/// Note that sometimes the action result is treated as authoritative for determining
/// conversation status even when there is a cancellation reason (taking priority over this)
pub fn conversation_outcome(&self) -> CancellationOutcome {
match self {
// The conversation continues without further user input (a follow-up
// request or a resumed long-running command drives it forward), so
// its status must stay InProgress.
CancellationReason::FollowUpSubmitted {
is_for_same_conversation: true,
}
| CancellationReason::CLISubagentUserTakeover => CancellationOutcome::KeepInProgress,
// A long-running command finishing (optimistically) or a revert are
// successful completions rather than cancellations.
CancellationReason::CommandFinishedDuringInlineAgentView
| CancellationReason::Reverted => CancellationOutcome::Succeeded,
// The shell died under the agent; a dedicated path finalizes this as a
// terminal `Error`, so the cancellation machinery must not stamp a status.
CancellationReason::AgentExitedShell => CancellationOutcome::FinalizedExternally,
CancellationReason::ManuallyCancelled
| CancellationReason::AutomaticCloudHandoff
| CancellationReason::UserCommandExecuted
| CancellationReason::Deleted
| CancellationReason::FollowUpSubmitted {
is_for_same_conversation: false,
} => CancellationOutcome::Cancelled,
}
}
}
@@ -401,6 +472,9 @@ pub struct OutputModelInfo {
pub model_id: LLMId,
pub display_name: String,
pub is_fallback: bool,
/// When the provider-side prompt cache for this request is expected to
/// expire. `None` means unknown / no cache-expiry info.
pub prompt_cache_expires_at: Option<DateTime<Local>>,
}
impl Display for AIAgentOutput {
@@ -615,9 +689,11 @@ impl AIAgentOutput {
}
/// Represents user visible errors.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[derive(Clone, Debug)]
pub enum RenderableAIError {
QuotaLimit,
QuotaLimit {
user_display_message: Option<String>,
},
ServerOverloaded,
InternalWarpError,
ContextWindowExceeded(String),
@@ -628,16 +704,59 @@ pub enum RenderableAIError {
AwsBedrockCredentialsExpiredOrInvalid {
model_name: String,
},
/// A transient network failure (lost connection or truncated response stream). Carries its
/// own complete user-facing copy; `kind` preserves the structured cause (including the raw
/// API error) so user reports can disambiguate the different causes behind the shared message.
TransientNetworkError {
kind: TransientNetworkErrorKind,
will_attempt_resume: bool,
/// When `will_attempt_resume` is true, this indicates whether we're waiting for network
/// connectivity before attempting the resume.
waiting_for_network: bool,
},
Other {
error_message: String,
will_attempt_resume: bool,
/// When `will_attempt_resume` is true, this indicates whether we're waiting for network
/// connectivity before attempting the resume.
waiting_for_network: bool,
/// True when the error originates from a user-side issue (e.g., model not allowed,
/// blocked due to fraud, plan restriction). Maps the task to FAILED state instead of ERROR.
is_user_error: bool,
},
/// An agent-issued command caused the shell process to exit, so the run
/// cannot continue. Surfaced as a terminal failure (FAILED) rather than a
/// user cancellation.
AgentExitedShell,
}
impl RenderableAIError {
const TRANSIENT_NETWORK_ERROR_MESSAGE: &'static str =
"Warp lost connection while receiving the agent response. This is usually temporary.";
/// User-facing message shown when an agent-issued command exits the shell.
pub const AGENT_EXITED_SHELL_MESSAGE: &'static str =
"The shell exited while the agent was running a command, so the run could not continue. Ensure the agent is not asked to run commands or source scripts that can exit the shell.";
/// Creates a transient network error. `kind` is the structured cause (including the raw API
/// error where one exists), preserved so user reports can disambiguate the different causes
/// behind the shared user-facing copy.
pub fn transient_network_error(
will_attempt_resume: bool,
waiting_for_network: bool,
kind: TransientNetworkErrorKind,
) -> Self {
Self::TransientNetworkError {
kind,
will_attempt_resume,
waiting_for_network,
}
}
fn is_transient_network_transport_error(error: &reqwest::Error) -> bool {
// If reqwest has an HTTP status, the server responded. Preserve the existing generic
// rendering for those failures rather than calling them lost connections.
error.status().is_none()
}
pub fn is_invalid_api_key(&self) -> bool {
matches!(self, Self::InvalidApiKey { .. })
}
@@ -653,20 +772,99 @@ impl RenderableAIError {
Self::Other {
will_attempt_resume: true,
..
} | Self::TransientNetworkError {
will_attempt_resume: true,
..
}
)
}
/// Whether the failed-output UI should be suppressed while an automatic resume is in
/// flight. Release builds stay quiet so transient blips that recover on their own
/// don't surface an alarming error; dogfood builds (Local/Dev) keep the old, more
/// aggressive behavior so developers still see every transport failure.
pub fn should_suppress_during_recovery(&self) -> bool {
self.will_attempt_resume() && !ChannelState::channel().is_dogfood()
}
/// Constructs a generic [`RenderableAIError::Other`] from a message.
/// `is_user_error` selects the task classification (true → FAILED, false →
/// ERROR). The resume/network flags are false: this is for terminal,
/// out-of-band errors that are not auto-resumed.
pub fn other(error_message: impl Into<String>, is_user_error: bool) -> Self {
Self::Other {
error_message: error_message.into(),
will_attempt_resume: false,
waiting_for_network: false,
is_user_error,
}
}
}
impl From<&AIApiError> for RenderableAIError {
fn from(value: &AIApiError) -> Self {
match value {
AIApiError::QuotaLimit => Self::QuotaLimit,
/// The cause behind a [`RenderableAIError::TransientNetworkError`]. Kept structured (rather than
/// collapsed to a free-form string) so user reports preserve the raw error; rendered to text only
/// at display time.
#[derive(Clone, Debug, thiserror::Error)]
pub enum TransientNetworkErrorKind {
/// A lost connection or truncated response stream — the raw underlying API error. Rendered via
/// `Debug` so reports preserve the full structured error rather than its terse `Display`.
#[error("{0:?}")]
Api(Arc<AIApiError>),
/// The response stream completed with an unfinished exchange and no error event.
#[error("stream completed with an unfinished exchange and no error event")]
UnfinishedExchange,
/// The conversation was left in a transient-error state but the last exchange carried no
/// structured error to surface.
#[error("no structured error on the last exchange")]
MissingExchangeError,
}
impl From<&Arc<AIApiError>> for RenderableAIError {
fn from(value: &Arc<AIApiError>) -> Self {
// Non-retryable 4xx errors (403 fraud block, 400 model/plan restriction, etc.)
// are user-originating — map them to a user error so the task reaches FAILED
// state rather than ERROR state.
let is_user_error = !value.is_recoverable();
match value.as_ref() {
AIApiError::QuotaLimit {
user_display_message,
} => Self::QuotaLimit {
user_display_message: user_display_message.clone(),
},
AIApiError::ServerOverloaded => Self::ServerOverloaded,
_ => Self::Other {
AIApiError::Transport(error)
| AIApiError::Deserialization(DeserializationError::Transport(error)) => {
// A transport error with no HTTP status is a lost-connection failure; one that
// carries a status means the server responded, so it gets generic rendering.
if Self::is_transient_network_transport_error(error) {
Self::transient_network_error(
false,
false,
TransientNetworkErrorKind::Api(value.clone()),
)
} else {
Self::Other {
error_message: format!("Request failed with error: {value:?}"),
will_attempt_resume: false,
waiting_for_network: false,
is_user_error,
}
}
}
AIApiError::UnexpectedEof => Self::transient_network_error(
false,
false,
TransientNetworkErrorKind::Api(value.clone()),
),
AIApiError::Deserialization(DeserializationError::Json(_))
| AIApiError::NoContextFound
| AIApiError::ErrorStatus(_, _)
| AIApiError::Other(_)
| AIApiError::Stream { .. } => Self::Other {
error_message: format!("Request failed with error: {value:?}"),
will_attempt_resume: false,
waiting_for_network: false,
is_user_error,
},
}
}
@@ -675,7 +873,15 @@ impl From<&AIApiError> for RenderableAIError {
impl Display for RenderableAIError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::QuotaLimit => write!(f, "Quota limit reached."),
Self::QuotaLimit {
user_display_message,
} => {
if let Some(message) = user_display_message {
write!(f, "{message}")
} else {
write!(f, "Quota limit reached.")
}
}
Self::ServerOverloaded => {
write!(f, "Warp is currently overloaded. Please try again later.")
}
@@ -692,7 +898,15 @@ impl Display for RenderableAIError {
"AWS Bedrock credentials expired or invalid for {model_name}"
)
}
Self::TransientNetworkError { kind, .. } => {
write!(
f,
"{}\n\nDebug info: {kind}",
Self::TRANSIENT_NETWORK_ERROR_MESSAGE
)
}
Self::Other { error_message, .. } => write!(f, "{error_message}"),
Self::AgentExitedShell => write!(f, "{}", Self::AGENT_EXITED_SHELL_MESSAGE),
}
}
}
@@ -717,13 +931,19 @@ impl ProgrammingLanguage {
#[cfg_attr(target_family = "wasm", allow(unused))]
pub fn to_extension(&self) -> Option<&str> {
match self {
// The arms below cover both canonical language names emitted by the agent (e.g.
// "rust", "kotlin") and common markdown code-fence aliases (e.g. "rs", "kt") to keep
// syntax highlighting working when the model uses either. The set of recognized
// languages here is kept in sync with `SUPPORTED_LANGUAGES` in the `languages` crate.
Self::Other(language) => match language.to_lowercase().as_str() {
"rust" => Some("rs"),
"go" => Some("go"),
"python" => Some("py"),
"javascript" => Some("js"),
"typescript" => Some("ts"),
"yaml" => Some("yaml"),
"rust" | "rs" => Some("rs"),
"go" | "golang" => Some("go"),
"python" | "py" => Some("py"),
"javascript" | "js" => Some("js"),
"typescript" | "ts" => Some("ts"),
"jsx" => Some("jsx"),
"tsx" => Some("tsx"),
"yaml" | "yml" => Some("yaml"),
"cpp" | "c++" => Some("cpp"),
"java" => Some("java"),
"groovy" => Some("java"),
@@ -733,17 +953,23 @@ impl ProgrammingLanguage {
"css" => Some("css"),
"c" => Some("c"),
"json" => Some("json"),
"hcl" => Some("hcl"),
"jq" => Some("jq"),
"hcl" | "terraform" | "tf" => Some("hcl"),
"lua" => Some("lua"),
"ruby" => Some("rb"),
"ruby" | "rb" => Some("rb"),
"php" => Some("php"),
"toml" => Some("toml"),
"swift" => Some("swift"),
"kotlin" => Some("kt"),
"kotlin" | "kt" => Some("kt"),
"powershell" => Some("ps1"),
"elixir" => Some("exs"),
"scala" => Some("scala"),
"sql" => Some("sql"),
"objective-c" | "objc" => Some("m"),
"starlark" => Some("bzl"),
"xml" => Some("xml"),
"vue" => Some("vue"),
"dockerfile" | "docker" | "containerfile" => Some("dockerfile"),
_ => None,
},
Self::Shell(ShellType::PowerShell) => Some("ps1"),
@@ -1513,9 +1739,12 @@ pub enum SubagentType {
Summarization,
ConversationSearch {
query: Option<String>,
/// The ID of the conversation being searched. None when searching the
/// current conversation.
/// Search targets are mutually exclusive; at most one of `conversation_id` or
/// `agent_run_id` should be populated for a single conversation search subagent.
/// The ID of the conversation being searched.
conversation_id: Option<String>,
/// The ID of the agent run being searched.
agent_run_id: Option<String>,
},
WarpDocumentationSearch,
Unknown,
@@ -2010,6 +2239,30 @@ pub enum AIAgentContext {
branch: Option<String>,
},
/// Information about the git repository in the current working directory.
Repository {
/// The repository name (e.g. "warp-internal").
name: String,
/// The repository owner/organization (e.g. "warpdotdev"), if determinable from the remote URL.
owner: Option<String>,
},
/// Information about the GitHub pull request associated with the current branch.
PullRequest {
/// The pull request number.
#[serde(default, deserialize_with = "deserialize_pull_request_number")]
number: i32,
/// The pull request state (for example, `OPEN`, `MERGED`, or `CLOSED`).
#[serde(default)]
state: String,
/// Whether the pull request is marked as draft.
#[serde(default)]
draft: bool,
/// The pull request's base branch.
#[serde(default)]
base_branch: String,
},
/// List of available skills is provided to the agent during initialization
/// or when updated.
Skills {
@@ -2020,6 +2273,37 @@ pub enum AIAgentContext {
Block(Box<BlockContext>),
}
fn deserialize_pull_request_number<'de, D>(deserializer: D) -> Result<i32, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
match value {
serde_json::Value::Null => Ok(0),
serde_json::Value::String(s) => {
if !s.chars().all(|c| c.is_ascii_digit()) {
return Ok(0);
}
Ok(s.parse()
.ok()
.filter(|number| *number > 0)
.unwrap_or_default())
}
serde_json::Value::Number(n) => {
let Some(number) = n.as_i64() else {
return Ok(0);
};
Ok(i32::try_from(number)
.ok()
.filter(|number| *number > 0)
.unwrap_or_default())
}
value => Err(serde::de::Error::custom(format!(
"expected string or number for pull request number, got {value}"
))),
}
}
#[derive(Clone, Serialize, Deserialize, Eq, PartialEq)]
pub struct ImageContext {
/// Base64-encoded image data.
@@ -2229,16 +2513,12 @@ pub enum StaticQueryType {
Code,
Deploy,
SomethingElse,
CustomOnboardingRequest,
EvaluationSuite,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[allow(clippy::enum_variant_names)]
pub enum EntrypointType {
Onboarding {
chip_type: OnboardingChipType,
},
PromptSuggestion {
is_static: bool,
is_coding: bool,
@@ -2327,6 +2607,31 @@ pub enum UserQueryMode {
Orchestrate,
}
pub fn extract_user_query_mode(query: String) -> (String, UserQueryMode) {
if let Some(query) = commands::strip_command_prefix(&query, commands::PLAN_NAME) {
(query, UserQueryMode::Plan)
} else if let Some(query) = commands::strip_command_prefix(&query, commands::ORCHESTRATE_NAME) {
(query, UserQueryMode::Orchestrate)
} else {
(query, UserQueryMode::Normal)
}
}
/// Reconstructs the display form of a user query that has been stripped via
/// [`extract_user_query_mode`], by re-prepending the slash-command prefix
/// associated with [`UserQueryMode`].
///
/// This is the inverse of [`extract_user_query_mode`] and the canonical way
/// for UI to render a stored `(mode, query)` pair so the displayed prompt
/// always matches what the user originally submitted.
pub fn display_user_query_with_mode(mode: UserQueryMode, query: &str) -> String {
match mode {
UserQueryMode::Normal => query.to_owned(),
UserQueryMode::Plan => format!("{} {query}", commands::PLAN.name),
UserQueryMode::Orchestrate => format!("{} {query}", commands::ORCHESTRATE.name),
}
}
// TODO(zachbai): Refactor this to consolidate with `LongRunningCommandSnapshot` and `Snapshot`
// variants of `ReadShellCommandOutputResult` and `WriteToLongRunningShellCommandResult`.
#[derive(Clone, Debug, PartialEq)]
@@ -2422,6 +2727,7 @@ pub enum AIAgentInput {
SummarizeConversation {
prompt: Option<String>,
context: Arc<[AIAgentContext]>,
},
/// Invoke a skill. The skill content is passed as instructions to the agent.
@@ -2468,6 +2774,15 @@ pub enum AIAgentInput {
suggestion: PassiveSuggestionResultType,
context: Arc<[AIAgentContext]>,
},
/// Piggybacked orchestration config update from the plan card.
/// Sent on the next outbound request after the user edits the
/// config block or toggles approval.
OrchestrationConfigUpdate {
plan_id: String,
config: OrchestrationConfig,
status: OrchestrationConfigStatus,
},
}
/// Data for a single message received by an agent from another agent.
@@ -2525,7 +2840,7 @@ impl Display for AIAgentInput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UserQuery { .. } => {
write!(f, "UserQuery: {}", self.user_query().unwrap_or_default())
write!(f, "UserQuery: {}", self.display_query().unwrap_or_default())
}
Self::AutoCodeDiffQuery { query, .. } => {
write!(f, "AutoCodeDiffQuery: {query}")
@@ -2561,24 +2876,23 @@ impl Display for AIAgentInput {
write!(f, "EventsFromAgents({} events)", events.len())
}
Self::PassiveSuggestionResult { .. } => write!(f, "PassiveSuggestionResult"),
Self::OrchestrationConfigUpdate { .. } => write!(f, "OrchestrationConfigUpdate"),
}
}
}
impl AIAgentInput {
pub fn user_query(&self) -> Option<String> {
/// Display text for any input that surfaces a prompt-like query in the UI
/// (typed queries, slash commands, skill invocations, etc.). Unlike
/// [`Self::is_user_query`], which strictly matches the `UserQuery` variant,
/// this returns `Some` for several input variants.
pub fn display_query(&self) -> Option<String> {
match self {
Self::UserQuery {
query,
user_query_mode,
..
} => match user_query_mode {
UserQueryMode::Plan => Some(format!("{} {query}", commands::PLAN.name)),
UserQueryMode::Orchestrate => {
Some(format!("{} {query}", commands::ORCHESTRATE.name))
}
UserQueryMode::Normal => Some(query.clone()),
},
} => Some(display_user_query_with_mode(*user_query_mode, query)),
Self::CreateNewProject { query, .. } => Some(query.clone()),
Self::CloneRepository {
clone_repo_url: url,
@@ -2624,7 +2938,8 @@ impl AIAgentInput {
| Self::StartFromAmbientRunPrompt { .. }
| Self::MessagesReceivedFromAgents { .. }
| Self::EventsFromAgents { .. }
| Self::PassiveSuggestionResult { .. } => None,
| Self::PassiveSuggestionResult { .. }
| Self::OrchestrationConfigUpdate { .. } => None,
}
}
@@ -2634,7 +2949,7 @@ impl AIAgentInput {
&self,
initial_conversation_query: Option<&String>,
) -> Option<String> {
let mut query = self.user_query()?;
let mut query = self.display_query()?;
if self
.user_query_mode()
.is_none_or(|mode| matches!(mode, UserQueryMode::Normal))
@@ -2719,9 +3034,10 @@ impl AIAgentInput {
| Self::InvokeSkill { context, .. }
| Self::StartFromAmbientRunPrompt { context, .. }
| Self::PassiveSuggestionResult { context, .. } => Some(context),
Self::SummarizeConversation { .. }
| Self::MessagesReceivedFromAgents { .. }
| Self::EventsFromAgents { .. } => None,
Self::SummarizeConversation { context, .. } => Some(context),
Self::MessagesReceivedFromAgents { .. }
| Self::EventsFromAgents { .. }
| Self::OrchestrationConfigUpdate { .. } => None,
}
}
@@ -2752,7 +3068,8 @@ impl AIAgentInput {
| Self::StartFromAmbientRunPrompt { .. }
| Self::MessagesReceivedFromAgents { .. }
| Self::EventsFromAgents { .. }
| Self::PassiveSuggestionResult { .. } => None,
| Self::PassiveSuggestionResult { .. }
| Self::OrchestrationConfigUpdate { .. } => None,
}
}
@@ -2859,7 +3176,7 @@ impl AIAgentExchange {
let user_queries: Vec<String> = self
.input
.iter()
.filter_map(|input| input.user_query())
.filter_map(|input| input.display_query())
.collect();
user_queries.join("\n")
}
@@ -2909,7 +3226,9 @@ impl AIAgentExchange {
}
pub fn has_user_query(&self) -> bool {
self.input.iter().any(|input| input.user_query().is_some())
self.input
.iter()
.any(|input| input.display_query().is_some())
}
pub fn has_accepted_file_edit(&self) -> bool {
@@ -2970,25 +3289,7 @@ pub struct RequestMetadata {
pub is_auto_resume_after_error: bool,
}
/// A globally unique ID for a suggested objects.
///
/// This is used for telemetry purposes to track and connect both:
/// - Suggested objects generated by the AI agent
/// - The corresponding objects stored in the cloud (if the suggestion was accepted)
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
pub struct SuggestedLoggingId(String);
impl Display for SuggestedLoggingId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<String> for SuggestedLoggingId {
fn from(value: String) -> Self {
Self(value)
}
}
pub use cloud_object_models::SuggestedLoggingId;
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct SuggestedRule {
@@ -3034,5 +3335,5 @@ impl Suggestions {
}
#[cfg(test)]
#[path = "mod_test.rs"]
#[path = "mod_tests.rs"]
mod tests;
@@ -1,15 +1,18 @@
use std::ops::Range;
use std::sync::Arc;
use anyhow::anyhow;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use warp_multi_agent_api::{FileContent, FileContentLineRange};
use crate::ai::agent::{
AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentText, AIAgentTextSection,
AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram, AnyFileContent,
FileContext, FormattedTextWrapper, MessageId, ProgrammingLanguage,
AIAgentContext, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentText,
AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram,
AnyFileContent, FileContext, FormattedTextWrapper, MessageId, ProgrammingLanguage,
RenderableAIError, TransientNetworkErrorKind,
};
use crate::server::server_api::AIApiError;
use crate::terminal::shell::ShellType;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
fn to_range(range: Range<u32>) -> Option<FileContentLineRange> {
Some(FileContentLineRange {
@@ -46,6 +49,80 @@ fn formatted_text_wrapper_preserves_content() {
assert_eq!(ft.lines.len(), 2);
}
fn deserialize_pull_request_number_from_json(number_json: &str) -> serde_json::Result<i32> {
let context = serde_json::from_str::<AIAgentContext>(&format!(
r#"{{"PullRequest":{{"number":{number_json}}}}}"#
))?;
match context {
AIAgentContext::PullRequest { number, .. } => Ok(number),
other => panic!("expected pull request context, got {other:?}"),
}
}
#[test]
fn pull_request_number_deserializer_accepts_positive_number_and_string() {
assert_eq!(deserialize_pull_request_number_from_json("42").unwrap(), 42);
assert_eq!(
deserialize_pull_request_number_from_json(r#""42""#).unwrap(),
42
);
}
#[test]
fn pull_request_number_deserializer_defaults_invalid_numbers() {
for number_json in ["null", "0", "-1", "1.5", "2147483648", r#""""#, r#""abc""#] {
assert_eq!(
deserialize_pull_request_number_from_json(number_json).unwrap(),
0,
"expected {number_json} to deserialize to default pull request number",
);
}
}
#[test]
fn pull_request_number_deserializer_rejects_unsupported_json_types() {
for number_json in ["true", "[]", "{}"] {
assert!(
deserialize_pull_request_number_from_json(number_json).is_err(),
"expected {number_json} to fail deserialization",
);
}
}
#[test]
fn transient_network_error_includes_user_facing_message_and_debug_details() {
let error = RenderableAIError::transient_network_error(
false,
false,
TransientNetworkErrorKind::Api(Arc::new(AIApiError::Other(anyhow!("connection reset")))),
);
let rendered = error.to_string();
assert!(
rendered.starts_with(
"Warp lost connection while receiving the agent response. This is usually temporary.\n\nDebug info: "
),
"unexpected rendering: {rendered}"
);
// The raw underlying API error must survive into the debug section.
assert!(
rendered.contains("connection reset"),
"raw error detail should surface in debug info: {rendered}"
);
assert!(!error.will_attempt_resume());
}
#[test]
fn transient_network_error_reports_pending_resume() {
let error = RenderableAIError::transient_network_error(
true,
false,
TransientNetworkErrorKind::Api(Arc::new(AIApiError::Other(anyhow!("connection reset")))),
);
assert!(error.will_attempt_resume());
}
#[test]
fn test_convert_files() {
let a = FileContext::new(
@@ -152,6 +229,86 @@ fn test_programming_language_from_string() {
);
}
#[test]
fn test_programming_language_to_extension() {
// Each entry is (markdown language token, expected extension). The expected extension
// must resolve back to a recognized language via `languages::language_by_filename` so that
// syntax highlighting is applied to the AI block.
let cases: &[(&str, &str)] = &[
// Canonical names.
("rust", "rs"),
("go", "go"),
("python", "py"),
("javascript", "js"),
("typescript", "ts"),
("yaml", "yaml"),
("cpp", "cpp"),
("java", "java"),
("c#", "cs"),
("csharp", "cs"),
("html", "html"),
("css", "css"),
("c", "c"),
("json", "json"),
("hcl", "hcl"),
("lua", "lua"),
("ruby", "rb"),
("php", "php"),
("toml", "toml"),
("swift", "swift"),
("kotlin", "kt"),
("powershell", "ps1"),
("elixir", "exs"),
("scala", "scala"),
("sql", "sql"),
// Languages newly covered by this fix — previously fell through to None and rendered
// without syntax highlighting in AI blocks even though the `languages` crate supports them.
("jsx", "jsx"),
("tsx", "tsx"),
("xml", "xml"),
("vue", "vue"),
("dockerfile", "dockerfile"),
("starlark", "bzl"),
("objective-c", "m"),
("objc", "m"),
// Common markdown code-fence aliases.
("rs", "rs"),
("golang", "go"),
("py", "py"),
("js", "js"),
("ts", "ts"),
("yml", "yaml"),
("c++", "cpp"),
("rb", "rb"),
("kt", "kt"),
("terraform", "hcl"),
("tf", "hcl"),
("docker", "dockerfile"),
("containerfile", "dockerfile"),
];
for (token, expected_extension) in cases {
let language = ProgrammingLanguage::from((*token).to_string());
assert_eq!(
language.to_extension(),
Some(*expected_extension),
"expected to_extension({token:?}) to be Some({expected_extension:?})",
);
}
// PowerShell remains the only Shell variant whose extension is exposed; this preserves
// existing behavior for the other Shell variants which are intentionally not extended here.
assert_eq!(
ProgrammingLanguage::Shell(ShellType::PowerShell).to_extension(),
Some("ps1"),
);
// Unrecognized tokens still return None.
assert_eq!(
ProgrammingLanguage::Other("definitely-not-a-language".to_string()).to_extension(),
None,
);
}
#[test]
fn format_for_copy_preserves_visual_markdown_sections() {
let output = AIAgentOutput {
@@ -189,3 +346,6 @@ fn format_for_copy_preserves_visual_markdown_sections() {
"Intro\n![Diagram](./diagram.png)\n```mermaid\ngraph TD\nA --> B\n```"
);
}
#[path = "suggestions_tests.rs"]
mod suggestions;
+13 -6
View File
@@ -1,15 +1,14 @@
use std::sync::Arc;
use super::super::blocklist::block::secret_redaction::{
find_secrets_in_text, SECRET_REDACTION_REPLACEMENT_CHARACTER,
};
use crate::ai::agent::{
AIAgentActionResultType, AIAgentAttachment, AIAgentContext, AIAgentInput, AnyFileContent,
AskUserQuestionAnswerItem, AskUserQuestionResult, BlockContext, PassiveSuggestionResultType,
PassiveSuggestionTrigger, RequestCommandOutputResult, TransferShellCommandControlToUserResult,
};
use super::super::blocklist::block::secret_redaction::{
find_secrets_in_text, SECRET_REDACTION_REPLACEMENT_CHARACTER,
};
/// Redact all detected secrets in-place within the given string.
pub(crate) fn redact_secrets(input: &mut String) {
let mut secrets: Vec<_> = find_secrets_in_text(input)
@@ -53,10 +52,11 @@ pub(crate) fn redact_inputs(inputs: &mut [AIAgentInput]) {
| AIAgentInput::StartFromAmbientRunPrompt { context, .. } => {
redact_context(Arc::make_mut(context));
}
AIAgentInput::SummarizeConversation { prompt } => {
AIAgentInput::SummarizeConversation { prompt, context } => {
if let Some(p) = prompt {
redact_secrets(p);
}
redact_context(Arc::make_mut(context));
}
AIAgentInput::CreateEnvironment { context, .. } => {
redact_context(Arc::make_mut(context));
@@ -105,7 +105,8 @@ pub(crate) fn redact_inputs(inputs: &mut [AIAgentInput]) {
}
// No user-provided text to redact in inter-agent relay inputs.
AIAgentInput::MessagesReceivedFromAgents { .. }
| AIAgentInput::EventsFromAgents { .. } => {}
| AIAgentInput::EventsFromAgents { .. }
| AIAgentInput::OrchestrationConfigUpdate { .. } => {}
AIAgentInput::ActionResult { result, context } => {
redact_context(Arc::make_mut(context));
match &mut result.result {
@@ -260,6 +261,10 @@ pub(crate) fn redact_inputs(inputs: &mut [AIAgentInput]) {
AIAgentActionResultType::AskUserQuestion(result) => {
redact_ask_user_question_result(result);
}
// Orchestrate results contain agent IDs / canonical error
// strings only; no user-provided text to redact.
AIAgentActionResultType::RunAgents(_)
| AIAgentActionResultType::WaitForEvents(_) => {}
}
}
AIAgentInput::FetchReviewComments { repo_path, context } => {
@@ -344,6 +349,8 @@ fn redact_context(context: &mut [AIAgentContext]) {
| AIAgentContext::Codebase { .. }
| AIAgentContext::ProjectRules { .. }
| AIAgentContext::Git { .. }
| AIAgentContext::Repository { .. }
| AIAgentContext::PullRequest { .. }
| AIAgentContext::File(_)
| AIAgentContext::Skills { .. } => {}
}
+73 -60
View File
@@ -1,44 +1,35 @@
pub mod helper;
pub mod transaction;
use std::{
collections::{HashMap, HashSet},
fmt::Display,
ops::Deref,
};
use std::collections::{HashMap, HashSet};
use std::fmt::Display;
use std::ops::Deref;
use chrono::DateTime;
use ai::skills::SkillPathOrigin;
use field_mask::{FieldMaskError, FieldMaskOperation};
use helper::{MessageExt, SubagentExt, ToolCallExt};
use itertools::Itertools;
use prost_types::FieldMask;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use warp_multi_agent_api::{
self as api,
message::{tool_call::subagent::Metadata, Message},
};
use warp_multi_agent_api::message::tool_call::subagent::Metadata;
use warp_multi_agent_api::message::Message;
use warp_multi_agent_api::{self as api};
use crate::{
ai::{
agent::comment::CodeReview,
document::ai_document_model::{AIDocumentId, AIDocumentVersion},
},
server::datetime_ext::DateTimeExt,
terminal::model::block::BlockId,
AIAgentTodoList,
use super::api::convert_conversation::convert_tool_call_result_to_input;
use super::api::{
user_inputs_from_messages, ConversionParams, ConvertAPIMessageToClientOutputMessage,
};
use super::comment::CodeReview;
use super::conversation::{context_in_exchanges, update_todo_list_from_todo_op};
use super::{
api::{
convert_conversation::convert_tool_call_result_to_input, user_inputs_from_messages,
ConversionParams, ConvertAPIMessageToClientOutputMessage,
},
conversation::{context_in_exchanges, update_todo_list_from_todo_op},
AIAgentContext, AIAgentExchange, AIAgentExchangeId, AIAgentOutput, AIAgentOutputMessage,
AIAgentOutputStatus, MaybeAIAgentOutputMessage, MessageId, MessageToAIAgentOutputMessageError,
Shared,
};
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
use crate::terminal::model::block::BlockId;
use crate::AIAgentTodoList;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct TaskId(String);
@@ -128,7 +119,6 @@ struct ServerTask {
}
mod optimistic {
use crate::terminal::model::block::BlockId;
#[derive(Debug, Clone)]
pub(super) struct CLIAgentSubtask {
@@ -176,6 +166,12 @@ pub struct Task {
/// List of `AIAgentExchange`s corresponding to messages contained in this task.
exchanges: Vec<AIAgentExchange>,
}
#[derive(Clone, Copy)]
pub(super) struct TaskMessageContext<'a> {
pub(super) current_todo_list: Option<&'a AIAgentTodoList>,
pub(super) active_code_review: Option<&'a CodeReview>,
pub(super) skill_path_origin: &'a SkillPathOrigin,
}
impl Task {
pub(super) fn new_optimistic_root() -> Self {
@@ -236,6 +232,7 @@ impl Task {
parent_task: Option<&api::Task>,
current_todo_list: Option<&AIAgentTodoList>,
active_code_review: Option<&CodeReview>,
skill_path_origin: &SkillPathOrigin,
) -> Result<Self, UpgradeOptimisticTaskError> {
match self.data {
TaskImpl::Optimistic(optimistic::Task::Root) => {
@@ -281,8 +278,11 @@ impl Task {
if let Err(e) = self.update_exchange_from_messages(
messages,
exchange_id,
current_todo_list,
active_code_review,
TaskMessageContext {
current_todo_list,
active_code_review,
skill_path_origin,
},
false,
) {
log::error!(
@@ -315,7 +315,8 @@ impl Task {
parent_task: &api::Task,
existing_exchange: &AIAgentExchange,
current_todo_list: Option<&AIAgentTodoList>,
current_comment_state: Option<&CodeReview>,
active_code_review: Option<&CodeReview>,
skill_path_origin: &SkillPathOrigin,
should_convert_input_messages: bool,
) -> Self {
let subagent_call_and_id = parent_task.messages.iter().find_map(|message| {
@@ -330,7 +331,7 @@ impl Task {
input: vec![],
output_status: AIAgentOutputStatus::Streaming { output: None },
added_message_ids: Default::default(),
start_time: DateTime::now().into(),
start_time: chrono::Local::now(),
finish_time: None,
time_to_first_token_ms: None,
working_directory: existing_exchange.working_directory.clone(),
@@ -368,8 +369,11 @@ impl Task {
me.update_exchange_from_messages(
messages_clone,
new_exchange_id,
current_todo_list,
current_comment_state,
TaskMessageContext {
current_todo_list,
active_code_review,
skill_path_origin,
},
should_convert_input_messages,
)
.expect("Exchange exists and output is in 'streaming' state.");
@@ -462,7 +466,7 @@ impl Task {
input: vec![],
output_status: AIAgentOutputStatus::Streaming { output: None },
added_message_ids: Default::default(),
start_time: DateTime::now().into(),
start_time: chrono::Local::now(),
finish_time: None,
time_to_first_token_ms: None,
working_directory: existing_exchange.working_directory.clone(),
@@ -603,6 +607,19 @@ impl Task {
self.try_get_source().ok()
}
pub(super) fn source_for_persistence(&self) -> Option<api::Task> {
match &self.data {
TaskImpl::Server(server_data) => Some(server_data.source.clone()),
// Optimistic root tasks have a client-generated UUID and no
// server-side identity yet. Persisting a stub `api::Task` for them
// produces an orphan row in `agent_tasks` that survives the later
// server-side upgrade and breaks restore by competing with the
// real server root for parentless-task selection. See QUALITY-774.
TaskImpl::Optimistic(optimistic::Task::Root) => None,
TaskImpl::Optimistic(optimistic::Task::CLIAgent(_)) => None,
}
}
pub fn messages(&self) -> impl Iterator<Item = &api::Message> {
self.source()
.into_iter()
@@ -677,8 +694,7 @@ impl Task {
&mut self,
messages: Vec<api::Message>,
exchange_id: AIAgentExchangeId,
current_todo_list: Option<&AIAgentTodoList>,
current_comments: Option<&CodeReview>,
message_context: TaskMessageContext<'_>,
should_convert_input_messages: bool,
) -> Result<(), UpdateTaskError> {
if self.source().is_none() {
@@ -687,8 +703,7 @@ impl Task {
self.update_exchange_from_messages(
messages.clone(),
exchange_id,
current_todo_list,
current_comments,
message_context,
should_convert_input_messages,
)?;
self.try_get_source_mut()?.messages.extend(messages);
@@ -699,8 +714,7 @@ impl Task {
&mut self,
message: api::Message,
exchange_id: AIAgentExchangeId,
current_todo_list: Option<&AIAgentTodoList>,
current_comments: Option<&CodeReview>,
message_context: TaskMessageContext<'_>,
mask: FieldMask,
should_convert_input_messages: bool,
) -> Result<&api::Message, UpdateTaskError> {
@@ -714,8 +728,7 @@ impl Task {
self.add_messages(
vec![message.clone()],
exchange_id,
current_todo_list,
current_comments,
message_context,
should_convert_input_messages,
)?;
return self
@@ -734,10 +747,13 @@ impl Task {
.exchange_mut(exchange_id)
.ok_or(UpdateTaskError::ExchangeNotFound)?;
exchange_to_update.upsert_output_for_message(
&id,
&updated_message,
current_todo_list,
current_comments,
ConversionParams {
task_id: &id,
current_todo_list: message_context.current_todo_list,
active_code_review: message_context.active_code_review,
skill_path_origin: message_context.skill_path_origin,
},
)?;
// Task message updates can carry tool call result updates with them,
@@ -782,8 +798,7 @@ impl Task {
&mut self,
message: api::Message,
exchange_id: AIAgentExchangeId,
current_todo_list: Option<&AIAgentTodoList>,
current_comments: Option<&CodeReview>,
message_context: TaskMessageContext<'_>,
mask: FieldMask,
) -> Result<&api::Message, UpdateTaskError> {
let Some((idx, existing_message)) = self
@@ -836,10 +851,13 @@ impl Task {
.exchange_mut(exchange_id)
.ok_or(UpdateTaskError::ExchangeNotFound)?;
exchange_to_update.upsert_output_for_message(
&id,
&updated_message,
current_todo_list,
current_comments,
ConversionParams {
task_id: &id,
current_todo_list: message_context.current_todo_list,
active_code_review: message_context.active_code_review,
skill_path_origin: message_context.skill_path_origin,
},
)?;
let source = self.try_get_source_mut()?;
@@ -964,8 +982,7 @@ impl Task {
&mut self,
messages: Vec<api::Message>,
exchange_id: AIAgentExchangeId,
current_todo_list: Option<&AIAgentTodoList>,
active_code_review: Option<&CodeReview>,
message_context: TaskMessageContext<'_>,
should_convert_input_messages: bool,
) -> Result<(), UpdateTaskError> {
let exchange = self
@@ -1007,8 +1024,9 @@ impl Task {
.filter_map(|m| {
match m.to_client_output_message(ConversionParams {
task_id: &self.id,
current_todo_list,
active_code_review,
current_todo_list: message_context.current_todo_list,
active_code_review: message_context.active_code_review,
skill_path_origin: message_context.skill_path_origin,
}) {
Ok(MaybeAIAgentOutputMessage::Message(m)) => Some(Ok(m)),
Ok(MaybeAIAgentOutputMessage::NoClientRepresentation) => None,
@@ -1043,10 +1061,8 @@ impl AIAgentExchange {
/// Note: this means updates will insert a new entry after previously added entries.
fn upsert_output_for_message(
&self,
task_id: &TaskId,
task_message: &api::Message,
todo_list: Option<&AIAgentTodoList>,
comments: Option<&CodeReview>,
conversion_params: super::api::ConversionParams<'_>,
) -> Result<(), UpdateTaskError> {
if let AIAgentOutputStatus::Streaming {
output: Some(output),
@@ -1079,11 +1095,8 @@ impl AIAgentExchange {
match task_message
.clone()
.to_client_output_message(ConversionParams {
current_todo_list: todo_list,
active_code_review: comments,
task_id,
})? {
.to_client_output_message(conversion_params)?
{
MaybeAIAgentOutputMessage::Message(m) => {
log::info!(
"[bedrock-debug] upsert_output_for_message: client_message_type={:?}",
+4
View File
@@ -139,6 +139,10 @@ impl ToolExt for api::message::tool_call::Tool {
Tool::AskUserQuestion(_) => "ask_user_question",
Tool::SendMessageToAgent(_) => "send_message_to_agent",
Tool::TransferShellCommandControlToUser(_) => "transfer_shell_command_control",
Tool::RunAgents(_) => "orchestrate",
// Matches the legacy server-handled name so analytics don't
// double-count the rollout.
Tool::WaitForEvents(_) => "wait_for_events",
}
}
}
+1 -2
View File
@@ -1,8 +1,7 @@
use std::collections::HashMap;
use crate::ai::agent::task::TaskId;
use super::Task;
use crate::ai::agent::task::TaskId;
/// Keeps track of the state of tasks before they are modified.
/// Messages are assumed to be only updated during the same transaction
+54 -19
View File
@@ -2,18 +2,11 @@ use std::collections::HashMap;
use warp_multi_agent_api as api;
use crate::ai::{
agent::{AIAgentContext, AIAgentInput},
skills::SkillDescriptor,
};
use super::{
task::{
helper::{MessageExt, ToolCallExt},
Task, TaskId,
},
AIAgentExchange, AIAgentExchangeId, AIAgentOutputMessageType,
};
use super::task::helper::{MessageExt, ToolCallExt};
use super::task::{Task, TaskId};
use super::{AIAgentExchange, AIAgentExchangeId, AIAgentOutputMessageType};
use crate::ai::agent::{AIAgentContext, AIAgentInput};
use crate::ai::skills::SkillDescriptor;
#[derive(Debug, Clone)]
struct ExchangeRef {
@@ -27,6 +20,12 @@ pub struct TaskStore {
root_task_id: TaskId,
tasks: HashMap<TaskId, Task>,
linearized_refs: Vec<ExchangeRef>,
exchange_id_index: HashMap<AIAgentExchangeId, ExchangeRef>,
/// If the root task was upgraded from an optimistic (client-generated) ID
/// to a server-assigned ID, stores the original optimistic ID so that
/// deferred event handlers referencing the stale ID can still resolve
/// the task via `root_task_id`.
optimistic_root_task_id: Option<TaskId>,
}
impl TaskStore {
@@ -35,7 +34,9 @@ impl TaskStore {
let mut store = Self {
tasks: HashMap::new(),
linearized_refs: Vec::new(),
exchange_id_index: HashMap::new(),
root_task_id: root_task_id.clone(),
optimistic_root_task_id: None,
};
store.tasks.insert(root_task_id, root_task);
store.rebuild_linearized_refs_index();
@@ -48,7 +49,9 @@ impl TaskStore {
let mut store = Self {
tasks,
linearized_refs: Vec::new(),
exchange_id_index: HashMap::new(),
root_task_id,
optimistic_root_task_id: None,
};
store.rebuild_linearized_refs_index();
store
@@ -59,7 +62,10 @@ impl TaskStore {
}
pub fn get(&self, task_id: &TaskId) -> Option<&Task> {
self.tasks.get(task_id)
self.tasks.get(task_id).or_else(|| {
let old_id = self.optimistic_root_task_id.as_ref()?;
(old_id == task_id).then(|| self.tasks.get(&self.root_task_id))?
})
}
pub fn tasks(&self) -> impl Iterator<Item = &Task> {
@@ -104,15 +110,15 @@ impl TaskStore {
None
}
/// Modifies a task via the provided closure and rebuilds the exchange index
/// if exchanges changed.
/// Modifies a task via the provided closure and rebuilds the exchange index if the exchange
/// count changes.
pub fn modify_task<R>(
&mut self,
task_id: &TaskId,
f: impl FnOnce(&mut Task) -> R,
) -> Option<R> {
let exchange_count_before = self.tasks.get(task_id)?.exchanges_len();
let task = self.tasks.get_mut(task_id)?;
let exchange_count_before = task.exchanges_len();
let result = f(task);
let exchange_count_after = self
.tasks
@@ -142,10 +148,39 @@ impl TaskStore {
self.remove(&old_root_id);
let new_root_id = root_task.id().clone();
if old_root_id != new_root_id {
self.optimistic_root_task_id = Some(old_root_id);
}
self.root_task_id = new_root_id;
self.insert(root_task);
}
pub fn exchange_by_id(&self, exchange_id: AIAgentExchangeId) -> Option<&AIAgentExchange> {
let exchange_ref = self.exchange_id_index.get(&exchange_id)?;
self.lookup_exchange(exchange_ref)
}
pub(super) fn rebuild_exchange_id_index(&mut self) {
self.exchange_id_index = self
.tasks
.values()
.flat_map(|task| {
let task_id = task.id().clone();
task.exchanges()
.enumerate()
.map(move |(exchange_index, exchange)| {
(
exchange.id,
ExchangeRef {
task_id: task_id.clone(),
exchange_index,
},
)
})
})
.collect();
}
pub fn first_exchange(&self) -> Option<&AIAgentExchange> {
self.linearized_refs
.first()
@@ -266,7 +301,7 @@ impl TaskStore {
pub fn remove(&mut self, task_id: &TaskId) -> Option<Task> {
let task = self.tasks.remove(task_id)?;
self.linearized_refs.retain(|r| &r.task_id != task_id);
self.rebuild_linearized_refs_index();
Some(task)
}
@@ -280,6 +315,7 @@ impl TaskStore {
/// Rebuilds the linearized index from scratch using DFS traversal.
fn rebuild_linearized_refs_index(&mut self) {
self.linearized_refs = Self::build_linearized_refs(&self.tasks, &self.root_task_id);
self.rebuild_exchange_id_index();
}
/// Builds linearized exchange refs via DFS traversal without mutating self.
@@ -330,9 +366,8 @@ impl TaskStore {
#[cfg(test)]
mod testing {
use crate::ai::agent::task::TaskId;
use super::TaskStore;
use crate::ai::agent::task::TaskId;
impl TaskStore {
pub fn contains(&self, task_id: &TaskId) -> bool {
+125 -12
View File
@@ -3,17 +3,14 @@ use std::collections::HashSet;
use chrono::Local;
use uuid::Uuid;
use crate::ai::{
agent::{
task::{Task, TaskId},
AIAgentExchange, AIAgentExchangeId, AIAgentOutput, AIAgentOutputMessage,
AIAgentOutputMessageType, AIAgentOutputStatus, FinishedAIAgentOutput, MessageId, Shared,
SubagentCall,
},
llms::LLMId,
};
use super::TaskStore;
use crate::ai::agent::task::{Task, TaskId};
use crate::ai::agent::{
AIAgentExchange, AIAgentExchangeId, AIAgentOutput, AIAgentOutputMessage,
AIAgentOutputMessageType, AIAgentOutputStatus, FinishedAIAgentOutput, MessageId, Shared,
SubagentCall,
};
use crate::ai::llms::LLMId;
fn create_test_exchange() -> AIAgentExchange {
AIAgentExchange {
@@ -216,7 +213,8 @@ fn test_set_root_task_replaces_old() {
assert_eq!(store.task_count(), 1);
assert_eq!(store.exchange_count(), 3);
assert!(store.get(&task1_id).is_none());
// task1_id is now aliased to the new root task via optimistic_root_task_id
assert!(store.get(&task1_id).is_some());
assert!(store.get(&task2_id).is_some());
assert_eq!(store.root_task_id(), &task2_id);
@@ -485,7 +483,6 @@ fn test_linearization_nested_subtasks() {
let grandchild_exchange_id = grandchild_subtask.exchanges().next().unwrap().id;
// Create child subtask with a call to grandchild
use crate::terminal::model::block::BlockId;
let mut child_subtask = Task::new_optimistic_cli_agent_subtask(BlockId::new(), None);
let child_id = child_subtask.id().clone();
@@ -623,3 +620,119 @@ fn test_all_exchanges_by_task_with_subtasks() {
assert_eq!(by_task[2].1.len(), 1);
assert_eq!(by_task[2].1[0].id, root_exchange3_id);
}
#[test]
fn test_exchange_by_id_resolves_subtask_exchange() {
// The index spans all tasks, not just the root, so exchanges that live in a
// subtask must be resolvable by id.
let root_task = create_test_task_with_exchanges(1);
let root_task_id = root_task.id().clone();
let mut store = TaskStore::with_root_task(root_task);
let subtask = create_test_subtask_with_exchanges(2);
let subtask_id = subtask.id().clone();
let subtask_exchange_ids: Vec<_> = subtask.exchanges().map(|e| e.id).collect();
let subagent_exchange = create_exchange_with_subagent_call(&subtask_id);
store.append_exchange(&root_task_id, subagent_exchange);
store.insert(subtask);
for id in &subtask_exchange_ids {
assert_eq!(store.exchange_by_id(*id).map(|e| e.id), Some(*id));
}
}
#[test]
fn test_exchange_by_id_after_remove_task() {
let root_task = create_test_task_with_exchanges(1);
let root_task_id = root_task.id().clone();
let root_exchange_id = root_task.exchanges().next().unwrap().id;
let mut store = TaskStore::with_root_task(root_task);
let subtask = create_test_subtask_with_exchanges(2);
let subtask_id = subtask.id().clone();
let subtask_exchange_ids: Vec<_> = subtask.exchanges().map(|e| e.id).collect();
let subagent_exchange = create_exchange_with_subagent_call(&subtask_id);
store.append_exchange(&root_task_id, subagent_exchange);
store.insert(subtask);
// Sanity: the subtask's exchanges resolve before removal.
assert!(store.exchange_by_id(subtask_exchange_ids[0]).is_some());
store.remove(&subtask_id);
// The removed task's exchanges are no longer resolvable.
for id in &subtask_exchange_ids {
assert!(store.exchange_by_id(*id).is_none());
}
// The surviving root exchange still resolves.
assert_eq!(
store.exchange_by_id(root_exchange_id).map(|e| e.id),
Some(root_exchange_id)
);
}
#[test]
fn test_exchange_by_id_after_remove_task_exchange_index_shift() {
let task = create_test_task_with_exchanges(3);
let task_id = task.id().clone();
let exchange_ids: Vec<_> = task.exchanges().map(|e| e.id).collect();
let mut store = TaskStore::with_root_task(task);
// Remove the middle exchange, which shifts the index of everything after it.
store.remove_task_exchange(&task_id, exchange_ids[1]);
// The removed id no longer resolves.
assert!(store.exchange_by_id(exchange_ids[1]).is_none());
// The exchange that followed it (now at a different index) still resolves to itself.
assert_eq!(
store.exchange_by_id(exchange_ids[2]).map(|e| e.id),
Some(exchange_ids[2])
);
// The exchange before it is unaffected.
assert_eq!(
store.exchange_by_id(exchange_ids[0]).map(|e| e.id),
Some(exchange_ids[0])
);
}
#[test]
fn test_exchange_by_id_after_modify_task_append() {
let task = create_test_task_with_exchanges(2);
let task_id = task.id().clone();
let mut store = TaskStore::with_root_task(task);
let new_exchange = create_test_exchange();
let new_exchange_id = new_exchange.id;
store.modify_task(&task_id, |task| {
task.append_exchange(new_exchange);
});
// The newly appended exchange is found via the rebuilt index.
assert_eq!(
store.exchange_by_id(new_exchange_id).map(|e| e.id),
Some(new_exchange_id)
);
}
#[test]
fn test_exchange_by_id_after_set_root_task() {
let task1 = create_test_task_with_exchanges(2);
let task1_exchange_ids: Vec<_> = task1.exchanges().map(|e| e.id).collect();
let mut store = TaskStore::with_root_task(task1);
let task2 = create_test_task_with_exchanges(3);
let task2_exchange_ids: Vec<_> = task2.exchanges().map(|e| e.id).collect();
store.set_root_task(task2);
// The old root's exchanges no longer resolve.
for id in &task1_exchange_ids {
assert!(store.exchange_by_id(*id).is_none());
}
// The new root's exchanges resolve.
for id in &task2_exchange_ids {
assert_eq!(store.exchange_by_id(*id).map(|e| e.id), Some(*id));
}
}
+12 -7
View File
@@ -1,5 +1,11 @@
use std::collections::HashSet;
use ai::skills::SkillPathOrigin;
use chrono::Local;
use prost_types::FieldMask;
use warp_multi_agent_api as api;
use super::{ExtractMessagesError, Task, TaskMessageContext};
use crate::ai::agent::{
AIAgentActionType, AIAgentExchange, AIAgentOutput, AIAgentOutputMessageType,
AIAgentOutputStatus, MessageId, Shared,
@@ -8,11 +14,6 @@ use crate::ai::llms::LLMId;
use crate::test_util::ai_agent_tasks::{
create_api_subtask, create_api_task, create_message, create_subagent_tool_call_message,
};
use chrono::Local;
use prost_types::FieldMask;
use warp_multi_agent_api as api;
use super::{ExtractMessagesError, Task};
/// Creates a Task backed by server data from the given api::Task.
fn create_server_task(api_task: api::Task) -> Task {
@@ -47,6 +48,7 @@ fn create_start_agent_tool_call_message(
prompt: &str,
) -> api::Message {
api::Message {
fetched_memories: vec![],
id: id.to_string(),
task_id: task_id.to_string(),
server_message_data: String::new(),
@@ -113,8 +115,11 @@ fn test_upsert_message_adds_start_agent_prompt_to_output() {
"run tests",
),
exchange_id,
None,
None,
TaskMessageContext {
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
},
FieldMask {
paths: vec!["message.tool_call".to_string()],
},
+11 -21
View File
@@ -1,18 +1,14 @@
use galaxyui::{AppContext, SingletonEntity};
use serde::Serialize;
use crate::ai::llms::LLMId;
use crate::CloudModel;
use crate::{
server::telemetry::AgentModeCitation as CitationForTelemetry,
terminal::view::block_onboarding::onboarding_agentic_suggestions_block::OnboardingChipType,
};
use super::conversation::AIConversationId;
use super::{
AIAgentCitation, AIAgentExchangeId, EntrypointType, PassiveSuggestionTriggerType,
ServerOutputId,
};
use crate::ai::llms::LLMId;
use crate::server::telemetry::AgentModeCitation as CitationForTelemetry;
use crate::CloudModel;
pub trait ForTelemetry {
type Output;
@@ -37,6 +33,14 @@ impl ForTelemetry for AIAgentCitation {
Some(CitationForTelemetry::WarpDocs { page: path.clone() })
}
Self::WebPage { url } => Some(CitationForTelemetry::WebPage { url: url.clone() }),
Self::AgentMemory {
memory_store_id,
memory_id,
..
} => Some(CitationForTelemetry::AgentMemory {
memory_store_id: memory_store_id.clone(),
memory_id: memory_id.clone(),
}),
}
}
}
@@ -44,20 +48,6 @@ impl ForTelemetry for AIAgentCitation {
impl EntrypointType {
pub fn entrypoint(&self) -> String {
match self {
Self::Onboarding { chip_type } => {
format!(
"ONBOARDING.{}",
match chip_type {
OnboardingChipType::FixAnIssue => "FIX_AN_ISSUE",
OnboardingChipType::PullCloudLogs => "PULL_CLOUD_LOGS",
OnboardingChipType::StartAFeature => "START_A_FEATURE",
OnboardingChipType::PythonSnakeGame => "PYTHON_SNAKE_GAME",
OnboardingChipType::ExploreGitHistory => "EXPLORE_GIT_HISTORY",
OnboardingChipType::MatrixThemePicker => "MATRIX_THEME_PICKER",
OnboardingChipType::Other => "OTHER",
}
)
}
Self::PromptSuggestion {
is_static,
is_coding,
+1 -2
View File
@@ -1,6 +1,5 @@
use crate::ai::agent::AIAgentTodo;
use super::AIAgentTodoId;
use crate::ai::agent::AIAgentTodo;
pub(crate) mod popup;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
+17 -17
View File
@@ -1,27 +1,24 @@
use crate::ai::blocklist::{BlocklistAIContextEvent, BlocklistAIContextModel};
use pathfinder_color::ColorU;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::Fill;
use galaxyui::elements::{
ClippedScrollStateHandle, ClippedScrollable, Dismiss, Empty, Expanded, ParentElement,
SavePosition, ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Shrinkable,
Border, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Dismiss, DropShadow, Empty, Expanded, Flex, MainAxisSize, ParentElement,
Radius, SavePosition, ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Shrinkable, Text,
};
use galaxyui::fonts::FamilyId;
use galaxyui::ModelHandle;
use galaxyui::SingletonEntity;
use galaxyui::fonts::{FamilyId, Properties, Weight};
use galaxyui::keymap::FixedBinding;
use galaxyui::{
elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DropShadow, Flex,
MainAxisSize, Radius, Text,
},
fonts::{Properties, Weight},
keymap::FixedBinding,
AppContext, Element, Entity, EntityId, TypedActionView, View, ViewContext,
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext,
};
use pathfinder_color::ColorU;
use crate::ai::agent::icons::{in_progress_icon, pending_icon, succeeded_icon};
use crate::ai::agent::todos::AIAgentTodoList;
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use crate::ai::blocklist::{
BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIHistoryEvent,
BlocklistAIHistoryModel,
};
use crate::ui_components::blended_colors;
pub struct AgentTodosPopupView {
@@ -86,8 +83,11 @@ impl AgentTodosPopupView {
event: &BlocklistAIHistoryEvent,
ctx: &mut ViewContext<Self>,
) {
if let BlocklistAIHistoryEvent::UpdatedTodoList { terminal_view_id } = event {
if *terminal_view_id == self.terminal_view_id {
if let BlocklistAIHistoryEvent::UpdatedTodoList {
terminal_surface_id,
} = event
{
if *terminal_surface_id == self.terminal_view_id {
ctx.notify();
}
}
+10 -7
View File
@@ -1,9 +1,6 @@
use super::{
AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram,
AgentOutputTable, ProgrammingLanguage,
};
use crate::code::editor_management::CodeSource;
use crate::features::FeatureFlag;
use std::collections::HashMap;
use std::path::PathBuf;
use ai::gfm_table::{format_gfm_table, maybe_collect_gfm_table_lines};
use galaxy_util::path::LineAndColumnArg;
use itertools::Itertools;
@@ -13,7 +10,13 @@ use markdown_parser::{
};
use mermaid_to_svg::is_mermaid_diagram;
use regex::Regex;
use std::{collections::HashMap, path::PathBuf};
use super::{
AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram,
AgentOutputTable, ProgrammingLanguage,
};
use crate::code::editor_management::CodeSource;
use crate::features::FeatureFlag;
lazy_static! {
/// Markdown prefix for code blocks. Matches on triple backticks followed by a language.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,683 @@
use chrono::{DateTime, Utc};
use session_sharing_protocol::common::SessionId;
use warp_cli::agent::Harness;
use galaxy_core::features::FeatureFlag;
use warpui::{AppContext, SingletonEntity};
use super::{
artifacts_match_filter, AgentManagementFilters, AgentRunDisplayStatus, ArtifactFilter,
ConversationMetadata, CreatedOnFilter, CreatorFilter, EnvironmentFilter, HarnessFilter,
OwnerFilter, SessionStatus, SourceFilter, StatusFilter,
};
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::ambient_agents::{AgentSource, AmbientAgentTask, AmbientAgentTaskId};
use crate::ai::artifacts::Artifact;
use crate::ai::blocklist::history_model::{AIConversationMetadata, BlocklistAIHistoryModel};
use crate::ai::conversation_navigation::ConversationNavigationData;
use crate::auth::{AuthStateProvider, UserUid};
use crate::util::time_format::human_readable_precise_duration;
use crate::workspace::RestoreConversationLayout;
use crate::workspaces::user_profiles::{UserProfileWithUID, UserProfiles};
const SESSION_EXPIRATION_TIME: chrono::Duration = chrono::Duration::weeks(1);
/// Stable projection identity used by list and navigation surfaces.
///
/// Task-backed rows use the ambient run ID even when they are attached to a local
/// conversation, so task-specific affordances do not disappear when local data is present.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AgentConversationEntryId {
AmbientRun(AmbientAgentTaskId),
Conversation(AIConversationId),
}
impl AgentConversationEntryId {
pub fn as_key(&self) -> String {
match self {
AgentConversationEntryId::AmbientRun(id) => format!("task_{id}"),
AgentConversationEntryId::Conversation(id) => format!("conv_{id}"),
}
}
}
impl From<ConversationOrTaskId> for AgentConversationEntryId {
fn from(id: ConversationOrTaskId) -> Self {
match id {
ConversationOrTaskId::ConversationId(conversation_id) => {
AgentConversationEntryId::Conversation(conversation_id)
}
ConversationOrTaskId::TaskId(task_id) => AgentConversationEntryId::AmbientRun(task_id),
}
}
}
/// Navigation request input for resolving an entry or server-token handle at action time.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AgentConversationNavigationSubject {
Entry(AgentConversationEntryId),
#[allow(dead_code)]
ServerToken(ServerConversationToken),
}
/// Normalized row data for agent conversation list, management, and navigation surfaces.
///
/// The entry keeps local conversation identity, ambient run identity, cloud token identity,
/// display fields, and available actions together so callers do not recompute navigation
/// policy from stale partial sources.
#[derive(Clone, Debug, PartialEq)]
pub struct AgentConversationEntry {
pub id: AgentConversationEntryId,
pub identity: AgentConversationIdentity,
pub provenance: AgentConversationProvenance,
pub display: AgentConversationDisplayData,
pub backing: AgentConversationBackingData,
pub capabilities: AgentConversationCapabilities,
}
/// Cross-system identifiers that may refer to the same underlying conversation/run.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AgentConversationIdentity {
pub local_conversation_id: Option<AIConversationId>,
pub ambient_agent_task_id: Option<AmbientAgentTaskId>,
pub server_conversation_token: Option<ServerConversationToken>,
pub session_id: Option<SessionId>,
}
/// Display-only fields for rendering a conversation entry without consulting source models.
#[derive(Clone, Debug, PartialEq)]
pub struct AgentConversationDisplayData {
pub title: String,
pub initial_query: Option<String>,
pub created_at: DateTime<Utc>,
pub last_updated: DateTime<Utc>,
pub status: AgentRunDisplayStatus,
pub creator: AgentConversationPrincipal,
pub executor: Option<AgentConversationPrincipal>,
pub request_usage: Option<f32>,
pub run_time: Option<String>,
pub session_status: Option<SessionStatus>,
pub source: Option<AgentSource>,
pub working_directory: Option<String>,
pub environment_id: Option<String>,
pub harness: Option<Harness>,
pub artifacts: Vec<Artifact>,
}
/// Type of principal that created or executed a run.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PrincipalType {
User,
ServiceAccount,
}
impl PrincipalType {
/// Parse from the wire-format string sent by the server.
pub fn parse(s: &str) -> Option<Self> {
if s.eq_ignore_ascii_case("user") {
Some(PrincipalType::User)
} else if s.eq_ignore_ascii_case("service_account") || s.eq_ignore_ascii_case("agent") {
Some(PrincipalType::ServiceAccount)
} else {
None
}
}
pub fn is_service_account(self) -> bool {
self == PrincipalType::ServiceAccount
}
}
/// Principal information normalized across local conversations and ambient runs.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct AgentConversationPrincipal {
pub name: Option<String>,
pub uid: Option<String>,
pub principal_type: Option<PrincipalType>,
}
/// Source category that explains why an entry exists and which backing systems can refresh it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AgentConversationProvenance {
LocalInteractive,
AmbientRun,
CloudSyncedConversation,
}
/// Availability flags for the source data that contributed to an entry.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AgentConversationBackingData {
pub has_loaded_conversation: bool,
pub has_local_persisted_data: bool,
pub has_cloud_data: bool,
pub has_ambient_run: bool,
}
/// Actions that should be exposed for an entry after applying current navigation policy.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AgentConversationCapabilities {
pub can_open: bool,
pub can_copy_link: bool,
pub can_share: bool,
pub can_delete: bool,
pub can_fork_locally: bool,
pub can_cancel: bool,
}
impl AgentConversationEntry {
pub(super) fn matches_filters(
&self,
filters: &AgentManagementFilters,
app: &AppContext,
) -> bool {
self.matches_owner_and_creator(&filters.owners, &filters.creator, app)
&& self.matches_status(&filters.status)
&& self.matches_source(&filters.source)
&& self.matches_created_on(&filters.created_on)
&& self.matches_artifact(&filters.artifact)
&& self.matches_environment(&filters.environment)
&& self.matches_harness(&filters.harness)
}
fn matches_owner_and_creator(
&self,
owner_filter: &OwnerFilter,
creator_filter: &CreatorFilter,
app: &AppContext,
) -> bool {
let current_user_id = AuthStateProvider::as_ref(app)
.get()
.user_id()
.map(|uid| uid.as_string());
let passes_owner = match owner_filter {
OwnerFilter::All => true,
OwnerFilter::PersonalOnly => {
if self.backing.has_ambient_run {
self.display.creator.uid == current_user_id
} else {
true
}
}
};
if !passes_owner || matches!(owner_filter, OwnerFilter::PersonalOnly) {
return passes_owner;
}
match creator_filter {
CreatorFilter::All => true,
CreatorFilter::Specific { name, .. } => {
self.display.creator.name.as_ref() == Some(name)
}
}
}
fn matches_status(&self, status_filter: &StatusFilter) -> bool {
match status_filter {
StatusFilter::All => true,
StatusFilter::Working | StatusFilter::Done | StatusFilter::Failed => {
self.display.status.status_filter() == *status_filter
}
}
}
fn matches_source(&self, source_filter: &SourceFilter) -> bool {
match source_filter {
SourceFilter::All => true,
SourceFilter::Specific(source) => self.display.source.as_ref() == Some(source),
}
}
fn matches_created_on(&self, created_on_filter: &CreatedOnFilter) -> bool {
let now = Utc::now();
let created_cutoff = match created_on_filter {
CreatedOnFilter::All => None,
CreatedOnFilter::Last24Hours => Some(now - chrono::Duration::hours(24)),
CreatedOnFilter::Past3Days => Some(now - chrono::Duration::days(3)),
CreatedOnFilter::LastWeek => Some(now - chrono::Duration::days(7)),
};
match created_cutoff {
Some(cutoff) => self.display.created_at >= cutoff,
None => true,
}
}
fn matches_artifact(&self, artifact_filter: &ArtifactFilter) -> bool {
artifacts_match_filter(&self.display.artifacts, artifact_filter)
}
fn matches_environment(&self, environment_filter: &EnvironmentFilter) -> bool {
match environment_filter {
EnvironmentFilter::All => true,
EnvironmentFilter::NoEnvironment => self.display.environment_id.is_none(),
EnvironmentFilter::Specific(id) => self.display.environment_id.as_ref() == Some(id),
}
}
fn matches_harness(&self, harness_filter: &HarnessFilter) -> bool {
match harness_filter {
HarnessFilter::All => true,
HarnessFilter::Specific(harness) => self.display.harness == Some(*harness),
}
}
pub fn has_open_action(
&self,
restore_layout: Option<RestoreConversationLayout>,
app: &AppContext,
) -> bool {
super::AgentConversationsModel::resolve_open_action(
AgentConversationNavigationSubject::Entry(self.id),
restore_layout,
app,
)
.is_some()
}
}
/// Returns the local conversation ID represented by the given task, if this task and a
/// conversation entry both point at the same underlying local run.
///
/// We first match using the orchestration agent ID (task ID / run ID under v2), and fall back
/// to the server conversation token for cases where the task only carries conversation identity
/// through `conversation_id`.
pub(super) fn conversation_id_shadowed_by_task(
task: &AmbientAgentTask,
history_model: &BlocklistAIHistoryModel,
) -> Option<AIConversationId> {
history_model
.conversation_id_for_agent_id(&task.run_id().to_string())
.or_else(|| {
task.conversation_id().and_then(|conversation_id| {
history_model.find_conversation_id_by_server_token(&ServerConversationToken::new(
conversation_id.to_string(),
))
})
})
}
pub(super) fn task_creator_name(task: &AmbientAgentTask, app: &AppContext) -> Option<String> {
task.creator_display_name().or_else(|| {
let uid = task.creator.as_ref().map(|creator| &creator.uid)?;
UserProfiles::as_ref(app).displayable_identifier_for_uid(UserUid::new(uid))
})
}
pub(super) fn task_creator_uid(task: &AmbientAgentTask) -> Option<String> {
task.creator.as_ref().map(|creator| creator.uid.clone())
}
fn current_user_name(app: &AppContext) -> Option<String> {
AuthStateProvider::as_ref(app).get().username_for_display()
}
fn current_user_uid(app: &AppContext) -> Option<String> {
AuthStateProvider::as_ref(app)
.get()
.user_id()
.map(|uid| uid.to_string())
}
fn task_session_id(task: &AmbientAgentTask) -> Option<SessionId> {
task.session_id.as_deref().and_then(parse_session_id)
}
fn task_session_status(task: &AmbientAgentTask) -> SessionStatus {
if FeatureFlag::CloudConversations.is_enabled() {
return if task.active_run_execution().session_link.is_some() {
SessionStatus::Available
} else {
SessionStatus::Unavailable
};
}
if task.active_run_execution().session_id.is_some() {
SessionStatus::Available
} else if (Utc::now() - task.created_at) > SESSION_EXPIRATION_TIME {
SessionStatus::Expired
} else {
SessionStatus::Unavailable
}
}
fn task_run_time(task: &AmbientAgentTask) -> Option<String> {
task.run_time().map(human_readable_precise_duration)
}
fn task_harness(task: &AmbientAgentTask) -> Option<Harness> {
task.agent_config_snapshot.as_ref().and_then(|config| {
config
.harness
.as_ref()
.map(|harness| harness.harness_type)
.or(Some(Harness::Oz))
})
}
fn conversation_title(
metadata: &ConversationMetadata,
history_model: &BlocklistAIHistoryModel,
) -> String {
history_model
.conversation(&metadata.nav_data.id)
.and_then(|conversation| conversation.title().clone())
.unwrap_or(metadata.nav_data.title.clone())
}
fn conversation_display_status(
metadata: &ConversationMetadata,
history_model: &BlocklistAIHistoryModel,
) -> AgentRunDisplayStatus {
history_model
.conversation(&metadata.nav_data.id)
.map(|conversation| AgentRunDisplayStatus::from_conversation_status(conversation.status()))
.unwrap_or(AgentRunDisplayStatus::ConversationSucceeded)
}
fn conversation_request_usage(
metadata: &ConversationMetadata,
history_model: &BlocklistAIHistoryModel,
) -> Option<f32> {
history_model
.conversation(&metadata.nav_data.id)
.map(|conversation| conversation.credits_spent())
.or_else(|| {
history_model
.get_conversation_metadata(&metadata.nav_data.id)
.and_then(|metadata| metadata.credits_spent)
})
}
fn conversation_artifacts(
metadata: &ConversationMetadata,
history_model: &BlocklistAIHistoryModel,
) -> Vec<Artifact> {
history_model
.conversation(&metadata.nav_data.id)
.map(|conversation| conversation.artifacts().to_vec())
.or_else(|| {
history_model
.get_conversation_metadata(&metadata.nav_data.id)
.map(|metadata| metadata.artifacts.clone())
})
.unwrap_or_default()
}
fn principal_from_user_profile(profile: &UserProfileWithUID) -> AgentConversationPrincipal {
let name = profile
.display_name
.as_ref()
.filter(|name| !name.is_empty())
.or_else(|| (!profile.email.is_empty()).then_some(&profile.email))
.cloned()
.or_else(|| Some(profile.firebase_uid.to_string()));
AgentConversationPrincipal {
name,
uid: Some(profile.firebase_uid.to_string()),
principal_type: Some(PrincipalType::User),
}
}
fn conversation_creator(
metadata: &ConversationMetadata,
history_model: &BlocklistAIHistoryModel,
app: &AppContext,
) -> AgentConversationPrincipal {
let server_metadata = history_model.get_server_conversation_metadata(&metadata.nav_data.id);
if let Some(profile) = server_metadata.and_then(|metadata| metadata.creator.as_ref()) {
return principal_from_user_profile(profile);
}
if let Some(uid) = server_metadata.and_then(|metadata| metadata.metadata.creator_uid.as_ref()) {
return AgentConversationPrincipal {
name: UserProfiles::as_ref(app).displayable_identifier_for_uid(UserUid::new(uid)),
uid: Some(uid.clone()),
principal_type: Some(PrincipalType::User),
};
}
AgentConversationPrincipal {
name: current_user_name(app),
uid: current_user_uid(app),
principal_type: Some(PrincipalType::User),
}
}
pub(super) fn entry_for_task(
task: &AmbientAgentTask,
history_model: &BlocklistAIHistoryModel,
app: &AppContext,
) -> AgentConversationEntry {
let local_conversation_id = conversation_id_shadowed_by_task(task, history_model);
let conversation_metadata =
local_conversation_id.and_then(|id| history_model.get_conversation_metadata(&id));
let server_conversation_token = task
.conversation_id()
.map(|id| ServerConversationToken::new(id.to_string()))
.or_else(|| {
local_conversation_id.and_then(|conversation_id| {
server_conversation_token_for_conversation(conversation_id, None, history_model)
})
});
let status = AgentRunDisplayStatus::from_task(task, app);
let has_active_session_id = task
.active_execution_session_id()
.and_then(parse_session_id)
.is_some();
let has_open_ambient_session = ActiveAgentViewsModel::as_ref(app)
.get_terminal_view_id_for_ambient_task(task.task_id)
.is_some();
let can_open = has_open_ambient_session
|| has_active_session_id
|| local_conversation_id.is_some()
|| server_conversation_token.is_some();
let can_copy_link = task.has_active_execution()
&& task.active_run_execution().session_link.is_some()
|| server_conversation_token.is_some();
AgentConversationEntry {
id: AgentConversationEntryId::AmbientRun(task.task_id),
identity: AgentConversationIdentity {
local_conversation_id,
ambient_agent_task_id: Some(task.task_id),
server_conversation_token,
session_id: task_session_id(task),
},
provenance: AgentConversationProvenance::AmbientRun,
display: AgentConversationDisplayData {
title: task.title.clone(),
initial_query: Some(task.prompt.clone()),
created_at: task.created_at,
last_updated: task.updated_at,
status: status.clone(),
creator: AgentConversationPrincipal {
name: task_creator_name(task, app),
uid: task_creator_uid(task),
principal_type: task
.creator
.as_ref()
.and_then(|c| PrincipalType::parse(&c.creator_type)),
},
executor: task
.executor
.as_ref()
.map(|executor| AgentConversationPrincipal {
name: executor.display_name.clone(),
uid: Some(executor.uid.clone()),
principal_type: PrincipalType::parse(&executor.creator_type),
}),
request_usage: task.credits_used(),
run_time: task_run_time(task),
session_status: Some(task_session_status(task)),
source: task.source.clone(),
working_directory: conversation_metadata
.and_then(|metadata| metadata.initial_working_directory.clone()),
environment_id: task
.agent_config_snapshot
.as_ref()
.and_then(|snapshot| snapshot.environment_id.clone()),
harness: task_harness(task),
artifacts: task.artifacts.clone(),
},
backing: AgentConversationBackingData {
has_loaded_conversation: local_conversation_id
.is_some_and(|id| history_model.conversation(&id).is_some()),
has_local_persisted_data: conversation_metadata
.is_some_and(|metadata| metadata.has_local_data),
has_cloud_data: conversation_metadata.is_some_and(|metadata| metadata.has_cloud_data)
|| task.conversation_id().is_some(),
has_ambient_run: true,
},
capabilities: AgentConversationCapabilities {
can_open,
can_copy_link,
can_share: task.conversation_id().is_some()
|| local_conversation_id
.is_some_and(|id| history_model.can_conversation_be_shared(&id)),
can_delete: false,
can_fork_locally: local_conversation_id.is_some(),
can_cancel: status.is_cancellable(),
},
}
}
pub(super) fn entry_for_conversation(
metadata: &ConversationMetadata,
history_model: &BlocklistAIHistoryModel,
app: &AppContext,
) -> AgentConversationEntry {
let conversation_metadata = history_model.get_conversation_metadata(&metadata.nav_data.id);
entry_for_conversation_parts(
metadata.nav_data.clone(),
conversation_metadata,
history_model,
app,
)
}
pub(super) fn entry_for_historical_metadata(
metadata: &AIConversationMetadata,
nav_data: ConversationNavigationData,
history_model: &BlocklistAIHistoryModel,
app: &AppContext,
) -> AgentConversationEntry {
entry_for_conversation_parts(nav_data, Some(metadata), history_model, app)
}
fn entry_for_conversation_parts(
nav_data: ConversationNavigationData,
conversation_metadata: Option<&AIConversationMetadata>,
history_model: &BlocklistAIHistoryModel,
app: &AppContext,
) -> AgentConversationEntry {
let metadata = ConversationMetadata { nav_data };
let conversation_id = metadata.nav_data.id;
let status = conversation_display_status(&metadata, history_model);
let has_loaded_conversation = history_model.conversation(&conversation_id).is_some();
let has_local_persisted_data = conversation_metadata
.is_some_and(|metadata| metadata.has_local_data)
|| has_loaded_conversation;
let has_cloud_data = conversation_metadata.is_some_and(|metadata| metadata.has_cloud_data)
|| server_conversation_token_for_conversation(
conversation_id,
Some(&metadata.nav_data),
history_model,
)
.is_some();
let provenance = if has_cloud_data {
AgentConversationProvenance::CloudSyncedConversation
} else {
AgentConversationProvenance::LocalInteractive
};
AgentConversationEntry {
id: AgentConversationEntryId::Conversation(conversation_id),
identity: AgentConversationIdentity {
local_conversation_id: Some(conversation_id),
ambient_agent_task_id: conversation_metadata
.and_then(|metadata| metadata.server_conversation_metadata.as_ref())
.and_then(|metadata| metadata.ambient_agent_task_id),
server_conversation_token: server_conversation_token_for_conversation(
conversation_id,
Some(&metadata.nav_data),
history_model,
),
session_id: None,
},
provenance,
display: AgentConversationDisplayData {
title: conversation_title(&metadata, history_model),
initial_query: metadata.nav_data.initial_query.clone(),
created_at: metadata.nav_data.last_updated.into(),
last_updated: metadata.nav_data.last_updated.into(),
status: status.clone(),
creator: conversation_creator(&metadata, history_model, app),
executor: None,
request_usage: conversation_request_usage(&metadata, history_model),
run_time: None,
session_status: None,
source: Some(AgentSource::Interactive),
working_directory: metadata
.nav_data
.latest_working_directory
.clone()
.or_else(|| metadata.nav_data.initial_working_directory.clone()),
environment_id: None,
harness: conversation_metadata
.and_then(|metadata| metadata.server_conversation_metadata.as_ref())
.map(|metadata| Harness::from(metadata.harness))
.or(Some(Harness::Oz)),
artifacts: conversation_artifacts(&metadata, history_model),
},
backing: AgentConversationBackingData {
has_loaded_conversation,
has_local_persisted_data,
has_cloud_data,
has_ambient_run: conversation_metadata
.is_some_and(AIConversationMetadata::is_ambient_agent_conversation),
},
capabilities: AgentConversationCapabilities {
can_open: has_local_persisted_data || has_cloud_data,
can_copy_link: server_conversation_token_for_conversation(
conversation_id,
Some(&metadata.nav_data),
history_model,
)
.is_some(),
can_share: history_model.can_conversation_be_shared(&conversation_id),
can_delete: has_local_persisted_data,
can_fork_locally: has_local_persisted_data,
can_cancel: status.is_cancellable(),
},
}
}
fn server_conversation_token_for_conversation(
conversation_id: AIConversationId,
nav_data: Option<&ConversationNavigationData>,
history_model: &BlocklistAIHistoryModel,
) -> Option<ServerConversationToken> {
history_model
.conversation(&conversation_id)
.and_then(|conversation| conversation.server_conversation_token())
.cloned()
.or_else(|| {
history_model
.get_conversation_metadata(&conversation_id)
.and_then(|metadata| metadata.server_conversation_token.clone())
})
.or_else(|| nav_data.and_then(|nav_data| nav_data.server_conversation_token.clone()))
}
pub(super) fn parse_session_id(session_id: &str) -> Option<SessionId> {
match session_id.parse::<SessionId>() {
Ok(session_id) => Some(session_id),
Err(e) => {
log::warn!("Failed to parse shared session ID: {e}");
None
}
}
}
File diff suppressed because it is too large Load Diff
+176 -36
View File
@@ -7,46 +7,102 @@ use futures::future::Either;
use futures::StreamExt;
use galaxyui::r#async::Timer;
use instant::Instant;
use galaxy_core::errors::AnyhowErrorExt as _;
use crate::server::retry_strategies::is_transient_http_error;
use crate::server::server_api::ai::AgentRunEvent;
use crate::server::server_api::presigned_upload::HttpStatusError;
use crate::server::server_api::ServerApi;
pub(crate) const DEFAULT_AGENT_EVENT_RECONNECT_BACKOFF_STEPS: &[u64] = &[1, 2, 5, 10];
pub(crate) const DEFAULT_PERMANENT_ERROR_BACKOFF_STEPS: &[u64] = &[30];
pub(crate) const DEFAULT_AGENT_EVENT_PROACTIVE_RECONNECT: Duration = Duration::from_secs(14 * 60);
pub(crate) const DEFAULT_AGENT_EVENT_FAILURES_BEFORE_ERROR_LOG: usize = 5;
/// Selects which server-side filter shape an [`AgentEventSource`] should use
/// when opening a stream.
///
/// `RunIds` maps to the `?run_ids[]=` query parameter on the SSE endpoint
/// and is used by child-only per-conversation streams and the dormant
/// Claude wake listener. `AncestorRunId` maps to the `?ancestor_run_id=`
/// shape: with `include_self=false` it streams events for every direct
/// child of the supplied parent run (the shared-session viewer's pill bar),
/// and with `include_self=true` it additionally streams the parent run's
/// own events so an owner-side orchestrator can receive child lifecycle
/// events plus its own inbox on one ordered stream.
#[derive(Clone, Debug)]
pub(crate) enum AgentEventFilter {
/// One stream per multiplexed set of run IDs. Matches today's
/// `?run_ids[]=` endpoint.
RunIds(Vec<String>),
/// Stream events for every direct child of the supplied parent run, and
/// (when `include_self` is true) the parent run itself. Matches the
/// `?ancestor_run_id=` endpoint.
AncestorRunId {
ancestor_run_id: String,
include_self: bool,
},
}
impl AgentEventFilter {
/// Returns a short debug label used in driver log lines so we don't have
/// to format the full `Vec<String>` payload on every retry.
pub(crate) fn log_label(&self) -> String {
match self {
AgentEventFilter::RunIds(ids) => format!("run_ids={ids:?}"),
AgentEventFilter::AncestorRunId {
ancestor_run_id,
include_self,
} => format!("ancestor_run_id={ancestor_run_id} include_self={include_self}"),
}
}
}
/// Configuration for the shared agent-event stream driver.
#[derive(Clone, Debug)]
pub(crate) struct AgentEventDriverConfig {
/// Run IDs whose events should be multiplexed into a single stream.
pub run_ids: Vec<String>,
/// Wire-level filter selecting which run IDs the stream serves. Either a
/// concrete multiplexed list of run IDs or an ancestor-scoped child set;
/// see [`AgentEventFilter`].
pub filter: AgentEventFilter,
/// Last fully handled event sequence. Events at or below this cursor are
/// ignored on reconnect so the consumer only sees new work.
pub since_sequence: i64,
/// Exponential-ish reconnect delays, in seconds, used after stream open
/// failures, stream errors, and clean stream termination.
/// Exponential-ish reconnect delays, in seconds, used after transient
/// stream failures (5xx, timeouts, connection resets).
pub reconnect_backoff_steps: &'static [u64],
/// Reconnect delays for permanent HTTP errors (4xx other than 408/429).
/// Typically much slower than transient backoff to reduce log spam while
/// still allowing recovery if the error was spurious.
pub permanent_error_backoff_steps: &'static [u64],
/// Optional deadline for proactively recycling an otherwise healthy stream
/// before upstream infrastructure times it out (for example, before Cloud
/// Run's 20-minute streaming timeout).
pub proactive_reconnect_after: Option<Duration>,
/// Failure count at which reconnect logging is escalated from debug to warn.
/// Failure count at which actionable reconnect failures are reported at Error level.
/// This only affects log severity; retry behavior stays the same.
pub failures_before_error_log: usize,
}
impl AgentEventDriverConfig {
/// Build the production reconnecting configuration used by long-lived
/// orchestration and harness listeners.
pub(crate) fn retry_forever(run_ids: Vec<String>, since_sequence: i64) -> Self {
/// orchestration and harness listeners, parameterised on the wire filter.
pub(crate) fn retry_forever(filter: AgentEventFilter, since_sequence: i64) -> Self {
Self {
run_ids,
filter,
since_sequence,
reconnect_backoff_steps: DEFAULT_AGENT_EVENT_RECONNECT_BACKOFF_STEPS,
permanent_error_backoff_steps: DEFAULT_PERMANENT_ERROR_BACKOFF_STEPS,
proactive_reconnect_after: Some(DEFAULT_AGENT_EVENT_PROACTIVE_RECONNECT),
failures_before_error_log: DEFAULT_AGENT_EVENT_FAILURES_BEFORE_ERROR_LOG,
}
}
/// Convenience: build a `retry_forever` config from a concrete list of
/// run IDs. Lets existing call sites keep their current ergonomics.
pub(crate) fn retry_forever_run_ids(run_ids: Vec<String>, since_sequence: i64) -> Self {
Self::retry_forever(AgentEventFilter::RunIds(run_ids), since_sequence)
}
}
/// Tells the shared driver whether to continue or stop after a handled event.
@@ -75,6 +131,27 @@ pub(crate) enum AgentEventDriverState {
ProactiveReconnect,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct AgentMessageEventMetadata {
pub sequence: i64,
pub message_id: String,
pub occurred_at: String,
}
impl AgentMessageEventMetadata {
pub(crate) fn from_event(event: &AgentRunEvent) -> Option<Self> {
if event.event_type != "new_message" {
return None;
}
Some(Self {
sequence: event.sequence,
message_id: event.ref_id.clone()?,
occurred_at: event.occurred_at.clone(),
})
}
}
/// Parsed items emitted by an [`AgentEventSource`].
pub(crate) enum AgentEventSourceItem {
Open,
@@ -91,13 +168,13 @@ cfg_if::cfg_if! {
}
}
/// Opens a stream of parsed agent events for one or more run IDs.
/// Opens a stream of parsed agent events for the supplied filter.
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
#[cfg_attr(not(target_family = "wasm"), async_trait)]
pub(crate) trait AgentEventSource: Send + Sync {
async fn open_stream(
&self,
run_ids: &[String],
filter: &AgentEventFilter,
since_sequence: i64,
) -> Result<AgentEventSourceStream>;
}
@@ -118,13 +195,28 @@ impl ServerApiAgentEventSource {
impl AgentEventSource for ServerApiAgentEventSource {
async fn open_stream(
&self,
run_ids: &[String],
filter: &AgentEventFilter,
since_sequence: i64,
) -> Result<AgentEventSourceStream> {
let stream = self
.server_api
.stream_agent_events(run_ids, since_sequence)
.await?;
let stream = match filter {
AgentEventFilter::RunIds(run_ids) => {
self.server_api
.stream_agent_events(run_ids, since_sequence)
.await?
}
AgentEventFilter::AncestorRunId {
ancestor_run_id,
include_self,
} => {
self.server_api
.stream_agent_events_for_ancestor(
ancestor_run_id,
*include_self,
since_sequence,
)
.await?
}
};
let stream = stream.filter_map(|event_result| async move {
match event_result {
@@ -138,7 +230,29 @@ impl AgentEventSource for ServerApiAgentEventSource {
}
}
}
Err(err) => Some(Err(anyhow!("SSE stream error: {err:?}"))),
Err(err) => {
let anyhow_err = match err {
reqwest_eventsource::Error::InvalidStatusCode(status_code, response) => {
let body = response
.text()
.await
.unwrap_or_else(|err| format!("(no response body: {err:#})"));
let status_err = HttpStatusError {
status: status_code.as_u16(),
body: body.clone(),
};
anyhow::Error::new(status_err).context(format!(
"SSE stream error: invalid status code {status_code}: {body}"
))
}
#[cfg(not(target_family = "wasm"))]
reqwest_eventsource::Error::Transport(err) => {
anyhow::Error::new(err).context("SSE stream error")
}
err => anyhow!("SSE stream error: {err:?}"),
};
Some(Err(anyhow_err))
}
}
});
@@ -188,18 +302,23 @@ where
let mut has_connected_once = false;
loop {
let mut stream = match source.open_stream(&config.run_ids, since_sequence).await {
Ok(stream) => {
failures = 0;
has_connected_once = true;
notify_driver_state(consumer, AgentEventDriverState::Connected).await;
stream
}
// `open_stream` is lazy for the SSE-backed source: the TCP
// connect happens when the stream is first polled, not when
// this returns Ok. Wait for the `AgentEventSourceItem::Open`
// event below before declaring connectivity, so a server
// outage doesn't reset `failures` between every retry.
let mut stream = match source.open_stream(&config.filter, since_sequence).await {
Ok(stream) => stream,
Err(err) => {
failures += 1;
let backoff = agent_event_backoff(failures, config.reconnect_backoff_steps);
let backoff_steps = if is_transient_http_error(&err) {
config.reconnect_backoff_steps
} else {
config.permanent_error_backoff_steps
};
let backoff = agent_event_backoff(failures, backoff_steps);
log_stream_failure(
&config.run_ids,
&config.filter,
failures,
backoff,
&err,
@@ -249,7 +368,12 @@ where
}
NextDriverItem::StreamItem(Some(Ok(AgentEventSourceItem::Open))) => {
failures = 0;
log::info!("Agent event stream opened for {:?}", config.run_ids);
has_connected_once = true;
notify_driver_state(consumer, AgentEventDriverState::Connected).await;
log::info!(
"Agent event stream opened for {}",
config.filter.log_label()
);
}
NextDriverItem::StreamItem(Some(Ok(AgentEventSourceItem::Event(event)))) => {
failures = 0;
@@ -273,9 +397,14 @@ where
}
NextDriverItem::StreamItem(Some(Err(err))) => {
failures += 1;
let backoff = agent_event_backoff(failures, config.reconnect_backoff_steps);
let backoff_steps = if is_transient_http_error(&err) {
config.reconnect_backoff_steps
} else {
config.permanent_error_backoff_steps
};
let backoff = agent_event_backoff(failures, backoff_steps);
log_stream_failure(
&config.run_ids,
&config.filter,
failures,
backoff,
&err,
@@ -293,12 +422,15 @@ where
Timer::after(backoff).await;
break;
}
// Clean stream closure (server-side close, not an HTTP
// error) — always use the transient backoff schedule since
// there is no HTTP status to classify.
NextDriverItem::StreamItem(None) => {
failures += 1;
let backoff = agent_event_backoff(failures, config.reconnect_backoff_steps);
log::warn!(
"Agent event stream closed for {:?}, reconnecting in {backoff:?}",
config.run_ids
"Agent event stream closed for {}, reconnecting in {backoff:?}",
config.filter.log_label()
);
notify_driver_state(
consumer,
@@ -332,21 +464,20 @@ async fn notify_driver_state<C: AgentEventConsumer>(
}
fn log_stream_failure(
run_ids: &[String],
filter: &AgentEventFilter,
failures: usize,
backoff: Duration,
err: &anyhow::Error,
failures_before_error_log: usize,
) {
if agent_event_failures_exceeded_threshold(failures, failures_before_error_log) {
let label = filter.log_label();
if agent_event_failure_should_log_error(err, failures, failures_before_error_log) {
log::error!(
"Agent event stream failed {failures} consecutive times for {:?}, retrying in {backoff:?}: {err:#}",
run_ids
"Agent event stream failed {failures} consecutive times for {label}, retrying in {backoff:?}: {err:#}"
);
} else {
log::warn!(
"Agent event stream failed for {:?}, retrying in {backoff:?}: {err:#}",
run_ids
"Agent event stream failed {failures} consecutive times for {label}, retrying in {backoff:?}: {err:#}"
);
}
}
@@ -361,6 +492,15 @@ pub(crate) fn agent_event_backoff(failures: usize, backoff_steps: &[u64]) -> Dur
Duration::from_secs(safe_steps[index])
}
#[cfg(test)]
pub(crate) fn agent_event_failures_exceeded_threshold(failures: usize, threshold: usize) -> bool {
failures >= threshold
}
pub(crate) fn agent_event_failure_should_log_error(
err: &anyhow::Error,
failures: usize,
threshold: usize,
) -> bool {
threshold > 0 && failures == threshold && err.is_actionable()
}
+170 -6
View File
@@ -6,9 +6,12 @@ use anyhow::anyhow;
use async_trait::async_trait;
use futures::stream::{self, BoxStream};
use futures::StreamExt;
use galaxy_core::errors::AnyhowErrorExt as _;
use super::*;
use crate::ai::agent_events::driver::agent_event_failure_should_log_error;
use crate::server::server_api::ai::AgentRunEvent;
use crate::server::server_api::presigned_upload::HttpStatusError;
const ZERO_BACKOFF_STEPS: &[u64] = &[0];
@@ -28,7 +31,7 @@ impl FakeAgentEventSource {
impl AgentEventSource for FakeAgentEventSource {
async fn open_stream(
&self,
_run_ids: &[String],
_filter: &AgentEventFilter,
_since_sequence: i64,
) -> anyhow::Result<BoxStream<'static, anyhow::Result<AgentEventSourceItem>>> {
let response = self
@@ -136,9 +139,10 @@ async fn driver_skips_duplicate_sequences_and_persists_new_cursor() {
};
let config = AgentEventDriverConfig {
run_ids: vec!["child-run".to_string()],
filter: AgentEventFilter::RunIds(vec!["child-run".to_string()]),
since_sequence: 2,
reconnect_backoff_steps: DEFAULT_AGENT_EVENT_RECONNECT_BACKOFF_STEPS,
permanent_error_backoff_steps: DEFAULT_PERMANENT_ERROR_BACKOFF_STEPS,
proactive_reconnect_after: None,
failures_before_error_log: DEFAULT_AGENT_EVENT_FAILURES_BEFORE_ERROR_LOG,
};
@@ -181,9 +185,10 @@ async fn driver_resets_failures_after_successful_event_delivery() {
};
let config = AgentEventDriverConfig {
run_ids: vec!["child-run".to_string()],
filter: AgentEventFilter::RunIds(vec!["child-run".to_string()]),
since_sequence: 0,
reconnect_backoff_steps: ZERO_BACKOFF_STEPS,
permanent_error_backoff_steps: DEFAULT_PERMANENT_ERROR_BACKOFF_STEPS,
proactive_reconnect_after: None,
failures_before_error_log: DEFAULT_AGENT_EVENT_FAILURES_BEFORE_ERROR_LOG,
};
@@ -225,9 +230,10 @@ async fn driver_ignores_persist_cursor_errors() {
};
let config = AgentEventDriverConfig {
run_ids: vec!["child-run".to_string()],
filter: AgentEventFilter::RunIds(vec!["child-run".to_string()]),
since_sequence: 0,
reconnect_backoff_steps: ZERO_BACKOFF_STEPS,
permanent_error_backoff_steps: DEFAULT_PERMANENT_ERROR_BACKOFF_STEPS,
proactive_reconnect_after: None,
failures_before_error_log: DEFAULT_AGENT_EVENT_FAILURES_BEFORE_ERROR_LOG,
};
@@ -258,9 +264,10 @@ async fn driver_ignores_driver_state_errors() {
};
let config = AgentEventDriverConfig {
run_ids: vec!["child-run".to_string()],
filter: AgentEventFilter::RunIds(vec!["child-run".to_string()]),
since_sequence: 0,
reconnect_backoff_steps: ZERO_BACKOFF_STEPS,
permanent_error_backoff_steps: DEFAULT_PERMANENT_ERROR_BACKOFF_STEPS,
proactive_reconnect_after: None,
failures_before_error_log: DEFAULT_AGENT_EVENT_FAILURES_BEFORE_ERROR_LOG,
};
@@ -293,9 +300,10 @@ async fn driver_retries_initial_connection_until_stream_opens() {
};
let config = AgentEventDriverConfig {
run_ids: vec!["child-run".to_string()],
filter: AgentEventFilter::RunIds(vec!["child-run".to_string()]),
since_sequence: 0,
reconnect_backoff_steps: ZERO_BACKOFF_STEPS,
permanent_error_backoff_steps: DEFAULT_PERMANENT_ERROR_BACKOFF_STEPS,
proactive_reconnect_after: None,
failures_before_error_log: DEFAULT_AGENT_EVENT_FAILURES_BEFORE_ERROR_LOG,
};
@@ -350,3 +358,159 @@ fn failure_threshold_is_reached_at_and_above_limit() {
assert!(agent_event_failures_exceeded_threshold(5, 5));
assert!(agent_event_failures_exceeded_threshold(6, 5));
}
fn make_http_status_error(status: u16) -> anyhow::Error {
anyhow::Error::new(HttpStatusError {
status,
body: "not found".to_string(),
})
.context("SSE stream error")
}
#[test]
fn actionable_stream_status_reports_only_at_threshold_crossing() {
let err = make_http_status_error(400);
assert_eq!(
[
agent_event_failure_should_log_error(&err, 4, 5),
agent_event_failure_should_log_error(&err, 5, 5),
agent_event_failure_should_log_error(&err, 6, 5),
],
[false, true, false]
);
}
#[test]
fn zero_threshold_disables_stream_error_escalation() {
let err = make_http_status_error(400);
assert!(!agent_event_failure_should_log_error(&err, 1, 0));
}
#[test]
fn non_actionable_stream_statuses_do_not_report_at_threshold() {
for status in [408, 429] {
let err = make_http_status_error(status);
assert!(
!agent_event_failure_should_log_error(&err, 5, 5),
"status {status}"
);
}
}
#[test]
fn server_error_status_reports_at_threshold_crossing() {
let err = make_http_status_error(500);
assert!(agent_event_failure_should_log_error(&err, 5, 5));
}
#[test]
fn http_status_error_actionability_follows_status_classification() {
assert!(make_http_status_error(400).is_actionable());
assert!(make_http_status_error(500).is_actionable());
assert!(!make_http_status_error(429).is_actionable());
}
#[tokio::test]
async fn driver_uses_slow_backoff_on_permanent_http_error() {
let source = FakeAgentEventSource::new(vec![
// First attempt: stream opens then returns a 404-enriched error.
ok_stream(vec![
Ok(AgentEventSourceItem::Open),
Err(make_http_status_error(404)),
]),
// Second attempt: succeeds with a stoppable event.
ok_stream(vec![
Ok(AgentEventSourceItem::Open),
Ok(AgentEventSourceItem::Event(make_run_event(
1,
"new_message",
"child-run",
Some("msg-1"),
))),
]),
]);
let mut consumer = RecordingConsumer {
stop_after: 1,
..Default::default()
};
// Use asymmetric values: transient=9999s (would block if chosen),
// permanent=0s (instant). Proving backoff is 0s confirms the
// permanent schedule was selected.
let config = AgentEventDriverConfig {
filter: AgentEventFilter::RunIds(vec!["child-run".to_string()]),
since_sequence: 0,
reconnect_backoff_steps: &[9999],
permanent_error_backoff_steps: ZERO_BACKOFF_STEPS,
proactive_reconnect_after: None,
failures_before_error_log: DEFAULT_AGENT_EVENT_FAILURES_BEFORE_ERROR_LOG,
};
run_agent_event_driver(source, config, &mut consumer)
.await
.unwrap();
assert_eq!(consumer.handled_sequences, vec![1]);
// The backoff must be from the permanent schedule (0s), not transient (9999s).
let retry_backoff = consumer
.driver_states
.iter()
.find_map(|s| match s {
AgentEventDriverState::RetryScheduled { backoff, .. } => Some(*backoff),
_ => None,
})
.unwrap();
assert_eq!(retry_backoff, Duration::from_secs(0));
}
#[tokio::test]
async fn driver_uses_fast_backoff_on_transient_http_error() {
let source = FakeAgentEventSource::new(vec![
// First attempt: stream opens then returns a 500-enriched error.
ok_stream(vec![
Ok(AgentEventSourceItem::Open),
Err(make_http_status_error(500)),
]),
// Second attempt: succeeds.
ok_stream(vec![
Ok(AgentEventSourceItem::Open),
Ok(AgentEventSourceItem::Event(make_run_event(
1,
"new_message",
"child-run",
Some("msg-1"),
))),
]),
]);
let mut consumer = RecordingConsumer {
stop_after: 1,
..Default::default()
};
// Set permanent backoff to something large so we can verify it was NOT used.
let config = AgentEventDriverConfig {
filter: AgentEventFilter::RunIds(vec!["child-run".to_string()]),
since_sequence: 0,
reconnect_backoff_steps: ZERO_BACKOFF_STEPS,
permanent_error_backoff_steps: &[9999],
proactive_reconnect_after: None,
failures_before_error_log: DEFAULT_AGENT_EVENT_FAILURES_BEFORE_ERROR_LOG,
};
run_agent_event_driver(source, config, &mut consumer)
.await
.unwrap();
// Should have retried with the fast backoff (0s) and succeeded.
assert_eq!(consumer.handled_sequences, vec![1]);
// The backoff used should be from ZERO_BACKOFF_STEPS (0s), not permanent (9999s).
let retry_backoff = consumer
.driver_states
.iter()
.find_map(|s| match s {
AgentEventDriverState::RetryScheduled { backoff, .. } => Some(*backoff),
_ => None,
})
.unwrap();
assert_eq!(retry_backoff, Duration::from_secs(0));
}
+145 -16
View File
@@ -5,20 +5,33 @@ use anyhow::{anyhow, Context, Result};
#[cfg(not(target_family = "wasm"))]
use futures::future::Either;
#[cfg(not(target_family = "wasm"))]
use instant::Instant;
#[cfg(not(target_family = "wasm"))]
use reqwest::Error as ReqwestError;
#[cfg(not(target_family = "wasm"))]
use galaxyui::r#async::Timer;
use crate::ai::agent::ReceivedMessageInput;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::server::server_api::ai::{AIClient, AgentRunEvent, ReadAgentMessageResponse};
#[cfg(not(target_family = "wasm"))]
use crate::server::server_api::presigned_upload::HttpStatusError;
use crate::server::server_api::ServerApi;
pub(crate) const DEFAULT_AGENT_MESSAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(5);
const DEFAULT_AGENT_MESSAGE_RETRY_DELAY: Duration = Duration::from_millis(50);
/// Hydrates `new_message` agent events into full message payloads and delivery
/// acknowledgements.
#[derive(Clone)]
pub(crate) struct MessageHydrator {
ai_client: Arc<dyn AIClient>,
task_scoped_server_api: Option<Arc<ServerApi>>,
task_id: Option<AmbientAgentTaskId>,
#[cfg_attr(target_family = "wasm", allow(dead_code))]
fetch_timeout: Duration,
#[cfg_attr(target_family = "wasm", allow(dead_code))]
retry_delay: Duration,
}
impl MessageHydrator {
@@ -26,16 +39,57 @@ impl MessageHydrator {
Self::with_fetch_timeout(ai_client, DEFAULT_AGENT_MESSAGE_FETCH_TIMEOUT)
}
pub(crate) fn for_task(server_api: Arc<ServerApi>, task_id: AmbientAgentTaskId) -> Self {
let ai_client: Arc<dyn AIClient> = server_api.clone();
Self {
ai_client,
task_scoped_server_api: Some(server_api),
task_id: Some(task_id),
fetch_timeout: DEFAULT_AGENT_MESSAGE_FETCH_TIMEOUT,
retry_delay: DEFAULT_AGENT_MESSAGE_RETRY_DELAY,
}
}
#[cfg(test)]
pub(crate) fn with_fetch_timing(
ai_client: Arc<dyn AIClient>,
fetch_timeout: Duration,
retry_delay: Duration,
) -> Self {
Self {
ai_client,
task_scoped_server_api: None,
task_id: None,
fetch_timeout,
retry_delay,
}
}
pub(crate) fn with_fetch_timeout(
ai_client: Arc<dyn AIClient>,
fetch_timeout: Duration,
) -> Self {
Self {
ai_client,
task_scoped_server_api: None,
task_id: None,
fetch_timeout,
retry_delay: DEFAULT_AGENT_MESSAGE_RETRY_DELAY,
}
}
async fn read_message(&self, message_id: &str) -> Result<ReadAgentMessageResponse> {
match (self.task_scoped_server_api.as_ref(), self.task_id) {
(Some(server_api), Some(task_id)) => {
server_api
.read_agent_message_for_task(&task_id, message_id)
.await
}
_ => self.ai_client.read_agent_message(message_id).await,
}
.with_context(|| format!("Failed to read agent message {message_id}"))
}
pub(crate) async fn hydrate_event_for_recipient(
&self,
event: &AgentRunEvent,
@@ -55,6 +109,17 @@ impl MessageHydrator {
return None;
}
};
if message.body.is_empty() {
log::warn!(
"Hydrated empty-body agent message: message_id={} event_sequence={} recipient_run_id={} sender_run_id={} subject={:?} task_id={:?}",
message.message_id,
event.sequence,
recipient_run_id,
message.sender_run_id,
message.subject,
self.task_id.map(|task_id| task_id.to_string())
);
}
Some(ReceivedMessageInput {
message_id: message.message_id,
@@ -70,16 +135,35 @@ impl MessageHydrator {
&self,
message_id: &str,
) -> Result<ReadAgentMessageResponse> {
let read_message = self.ai_client.read_agent_message(message_id);
let timeout = Timer::after(self.fetch_timeout);
futures::pin_mut!(read_message);
futures::pin_mut!(timeout);
let deadline = Instant::now() + self.fetch_timeout;
let mut last_error = None;
match futures::future::select(read_message, timeout).await {
Either::Left((result, _)) => {
result.with_context(|| format!("Failed to read agent message {message_id}"))
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(message_read_timeout_error(message_id, last_error));
}
let read_message = self.read_message(message_id);
let timeout = Timer::after(remaining);
futures::pin_mut!(read_message);
futures::pin_mut!(timeout);
match futures::future::select(read_message, timeout).await {
Either::Left((Ok(message), _)) => return Ok(message),
Either::Left((Err(err), _)) if should_retry_message_read_error(&err) => {
last_error = Some(err);
let sleep_duration = self
.retry_delay
.min(deadline.saturating_duration_since(Instant::now()));
if sleep_duration.is_zero() {
return Err(message_read_timeout_error(message_id, last_error));
}
Timer::after(sleep_duration).await;
}
Either::Left((Err(err), _)) => return Err(err),
Either::Right(_) => return Err(message_read_timeout_error(message_id, last_error)),
}
Either::Right(_) => Err(anyhow!("Timed out reading agent message {message_id}")),
}
}
@@ -88,10 +172,7 @@ impl MessageHydrator {
&self,
message_id: &str,
) -> Result<ReadAgentMessageResponse> {
self.ai_client
.read_agent_message(message_id)
.await
.with_context(|| format!("Failed to read agent message {message_id}"))
self.read_message(message_id).await
}
pub(crate) async fn read_message_from_event_with_timeout(
@@ -105,10 +186,15 @@ impl MessageHydrator {
}
pub(crate) async fn mark_message_delivered(&self, message_id: &str) -> Result<()> {
self.ai_client
.mark_message_delivered(message_id)
.await
.with_context(|| format!("Failed to mark agent message {message_id} as delivered"))
match (self.task_scoped_server_api.as_ref(), self.task_id) {
(Some(server_api), Some(task_id)) => {
server_api
.mark_message_delivered_for_task(&task_id, message_id)
.await
}
_ => self.ai_client.mark_message_delivered(message_id).await,
}
.with_context(|| format!("Failed to mark agent message {message_id} as delivered"))
}
pub(crate) async fn mark_messages_delivered_best_effort<'a, I>(
@@ -130,3 +216,46 @@ impl MessageHydrator {
failures
}
}
#[cfg(not(target_family = "wasm"))]
fn should_retry_message_read_error(err: &anyhow::Error) -> bool {
// Immediate read-after-event lag can surface as a short-lived 404 before
// the message row becomes readable. Restrict retries to status-preserving
// eventual-consistency/server responses plus clearly transient transport
// failures so permanent 4xxs fail fast instead of timing out.
if let Some(status) = message_read_error_status(err) {
return matches!(status, 404 | 408 | 429 | 500..=599);
}
err.chain().any(|cause| {
cause
.downcast_ref::<ReqwestError>()
.is_some_and(is_transient_message_read_transport_error)
})
}
#[cfg(not(target_family = "wasm"))]
fn message_read_error_status(err: &anyhow::Error) -> Option<u16> {
err.chain().find_map(|cause| {
cause
.downcast_ref::<HttpStatusError>()
.map(|http_err| http_err.status)
})
}
#[cfg(not(target_family = "wasm"))]
fn is_transient_message_read_transport_error(err: &ReqwestError) -> bool {
err.is_timeout() || err.is_connect()
}
#[cfg(not(target_family = "wasm"))]
fn message_read_timeout_error(
message_id: &str,
last_error: Option<anyhow::Error>,
) -> anyhow::Error {
let timeout_error = anyhow!("Timed out reading agent message {message_id}");
match last_error {
Some(err) => timeout_error.context(err),
None => timeout_error,
}
}
+129 -11
View File
@@ -1,4 +1,6 @@
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use mockall::predicate::eq;
@@ -6,6 +8,7 @@ use super::*;
use crate::server::server_api::ai::{
AIClient, AgentRunEvent, MockAIClient, ReadAgentMessageResponse,
};
use crate::server::server_api::presigned_upload::HttpStatusError;
fn make_run_event(
sequence: i64,
@@ -23,6 +26,25 @@ fn make_run_event(
}
}
fn make_message_response(message_id: &str) -> ReadAgentMessageResponse {
ReadAgentMessageResponse {
message_id: message_id.to_string(),
sender_run_id: "parent-run".to_string(),
subject: "Need a redirect".to_string(),
body: "Switch to the failing test first.".to_string(),
sent_at: "2026-01-01T00:00:00Z".to_string(),
delivered_at: None,
read_at: Some("2026-01-01T00:00:01Z".to_string()),
}
}
fn http_status_read_error(status: u16) -> anyhow::Error {
anyhow::Error::new(HttpStatusError {
status,
body: format!("status {status} body"),
})
}
#[tokio::test]
async fn hydrator_reads_new_message_for_matching_run() {
let mut ai_client = MockAIClient::new();
@@ -30,17 +52,7 @@ async fn hydrator_reads_new_message_for_matching_run() {
.expect_read_agent_message()
.with(eq("msg-123"))
.times(1)
.returning(|_| {
Ok(ReadAgentMessageResponse {
message_id: "msg-123".to_string(),
sender_run_id: "parent-run".to_string(),
subject: "Need a redirect".to_string(),
body: "Switch to the failing test first.".to_string(),
sent_at: "2026-01-01T00:00:00Z".to_string(),
delivered_at: None,
read_at: Some("2026-01-01T00:00:01Z".to_string()),
})
});
.returning(|_| Ok(make_message_response("msg-123")));
let ai_client: Arc<dyn AIClient> = Arc::new(ai_client);
let hydrator = MessageHydrator::new(ai_client);
@@ -69,3 +81,109 @@ async fn hydrator_ignores_events_for_other_runs() {
.await
.is_none());
}
#[tokio::test]
async fn read_message_with_timeout_retries_transient_failures_until_success() {
let attempts = Arc::new(AtomicUsize::new(0));
let attempts_clone = attempts.clone();
let mut ai_client = MockAIClient::new();
ai_client
.expect_read_agent_message()
.with(eq("msg-123"))
.times(2)
.returning(move |_| {
let attempt = attempts_clone.fetch_add(1, Ordering::SeqCst);
if attempt == 0 {
Err(http_status_read_error(404))
} else {
Ok(make_message_response("msg-123"))
}
});
let ai_client: Arc<dyn AIClient> = Arc::new(ai_client);
let hydrator = MessageHydrator::with_fetch_timing(
ai_client,
Duration::from_millis(100),
Duration::from_millis(5),
);
let message = hydrator.read_message_with_timeout("msg-123").await.unwrap();
assert_eq!(attempts.load(Ordering::SeqCst), 2);
assert_eq!(message.message_id, "msg-123");
assert_eq!(message.body, "Switch to the failing test first.");
}
#[tokio::test]
async fn read_message_with_timeout_times_out_after_retrying_transient_failures() {
let attempts = Arc::new(AtomicUsize::new(0));
let attempts_clone = attempts.clone();
let mut ai_client = MockAIClient::new();
ai_client
.expect_read_agent_message()
.with(eq("msg-123"))
.returning(move |_| {
attempts_clone.fetch_add(1, Ordering::SeqCst);
Err(http_status_read_error(404))
});
let ai_client: Arc<dyn AIClient> = Arc::new(ai_client);
let hydrator = MessageHydrator::with_fetch_timing(
ai_client,
Duration::from_millis(120),
Duration::from_millis(20),
);
let err = hydrator
.read_message_with_timeout("msg-123")
.await
.expect_err("expected timeout after transient retries");
let err_chain = format!("{err:#}");
assert!(
err_chain.contains("Timed out reading agent message msg-123"),
"{err:#}"
);
assert!(
attempts.load(Ordering::SeqCst) >= 2,
"expected at least one retry before timeout"
);
}
#[tokio::test]
async fn read_message_with_timeout_does_not_retry_permanent_http_failures() {
let attempts = Arc::new(AtomicUsize::new(0));
let attempts_clone = attempts.clone();
let mut ai_client = MockAIClient::new();
ai_client
.expect_read_agent_message()
.with(eq("msg-123"))
.times(1)
.returning(move |_| {
attempts_clone.fetch_add(1, Ordering::SeqCst);
Err(http_status_read_error(403))
});
let ai_client: Arc<dyn AIClient> = Arc::new(ai_client);
let hydrator = MessageHydrator::with_fetch_timing(
ai_client,
Duration::from_millis(100),
Duration::from_millis(5),
);
let err = hydrator
.read_message_with_timeout("msg-123")
.await
.expect_err("expected permanent failure to fail fast");
let err_chain = format!("{err:#}");
assert!(
err_chain.contains("HTTP request failed with status 403"),
"{err:#}"
);
assert_eq!(
attempts.load(Ordering::SeqCst),
1,
"permanent 4xx errors should not retry"
);
}
+2 -2
View File
@@ -8,11 +8,11 @@ mod message_hydrator;
pub(crate) use driver::{
agent_event_backoff, agent_event_failures_exceeded_threshold, AgentEventDriverState,
AgentEventSource, AgentEventSourceItem, DEFAULT_AGENT_EVENT_FAILURES_BEFORE_ERROR_LOG,
DEFAULT_AGENT_EVENT_RECONNECT_BACKOFF_STEPS,
DEFAULT_AGENT_EVENT_RECONNECT_BACKOFF_STEPS, DEFAULT_PERMANENT_ERROR_BACKOFF_STEPS,
};
pub(crate) use driver::{
run_agent_event_driver, AgentEventConsumer, AgentEventConsumerControlFlow,
AgentEventDriverConfig, ServerApiAgentEventSource,
AgentEventDriverConfig, AgentEventFilter, AgentMessageEventMetadata, ServerApiAgentEventSource,
};
pub(crate) use message_hydrator::MessageHydrator;
@@ -1,9 +1,8 @@
use std::collections::HashMap;
use galaxy_core::features::FeatureFlag;
use galaxyui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity, WindowId};
use crate::settings::AISettings;
use galaxy_core::send_telemetry_from_ctx;
use galaxyui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity, ViewHandle, WindowId};
use crate::ai::active_agent_views_model::{ActiveAgentViewsEvent, ActiveAgentViewsModel};
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
@@ -12,16 +11,16 @@ use crate::ai::agent_management::notifications::{
NotificationSourceAgent,
};
use crate::ai::artifacts::Artifact;
use crate::ai::blocklist::BlocklistAIHistoryEvent;
use crate::ai::blocklist::{BlocklistAIHistoryEvent, ConversationStatusUpdate, QueuedQueryModel};
use crate::server::telemetry::TelemetryEvent;
use crate::settings::AISettings;
use crate::terminal::cli_agent_sessions::{
CLIAgentSessionStatus, CLIAgentSessionsModel, CLIAgentSessionsModelEvent,
};
use crate::terminal::CLIAgent;
use crate::terminal::{CLIAgent, TerminalView};
use crate::workspace::util::is_terminal_view_in_same_tab;
use crate::workspace::{Workspace, WorkspaceRegistry};
use crate::BlocklistAIHistoryModel;
use galaxy_core::send_telemetry_from_ctx;
/// Singleton model responsible for triggering in-app notifications on blocking conversation
/// status updates and tracking/storing these notifications for the notifications mailbox.
@@ -43,17 +42,17 @@ impl SingletonEntity for AgentNotificationsModel {}
impl AgentNotificationsModel {
pub(crate) fn new(ctx: &mut ModelContext<Self>) -> Self {
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, move |me, event, ctx| {
ctx.subscribe_to_model(&history_model, move |me, _, event, ctx| {
me.handle_history_event(event, ctx);
});
let cli_sessions_model = CLIAgentSessionsModel::handle(ctx);
ctx.subscribe_to_model(&cli_sessions_model, |me, event, ctx| {
ctx.subscribe_to_model(&cli_sessions_model, |me, _, event, ctx| {
me.handle_cli_agent_session_event(event, ctx);
});
let active_views_model = ActiveAgentViewsModel::handle(ctx);
ctx.subscribe_to_model(&active_views_model, |me, event, ctx| {
ctx.subscribe_to_model(&active_views_model, |me, _, event, ctx| {
me.handle_active_agent_views_changed(event, ctx);
});
@@ -165,14 +164,19 @@ impl AgentNotificationsModel {
CLIAgent::Codex => "Notification from Codex",
_ => "Task completed.",
};
let metadata = TerminalViewMetadata::lookup(*terminal_view_id, ctx);
self.add_notification(
title,
message.to_owned(),
NotificationCategory::Complete,
NotificationSourceAgent::CLI(*agent),
NotificationSourceAgent::CLI {
agent: *agent,
is_ambient: metadata.is_ambient,
},
NotificationOrigin::CLISession(*terminal_view_id),
*terminal_view_id,
vec![],
metadata.branch,
ctx,
);
}
@@ -180,16 +184,21 @@ impl AgentNotificationsModel {
let title = session_context
.display_title()
.unwrap_or_else(|| format!("{} needs attention", agent.display_name()));
let metadata = TerminalViewMetadata::lookup(*terminal_view_id, ctx);
self.add_notification(
title,
message
.clone()
.unwrap_or_else(|| "Waiting for input.".to_owned()),
NotificationCategory::Request,
NotificationSourceAgent::CLI(*agent),
NotificationSourceAgent::CLI {
agent: *agent,
is_ambient: metadata.is_ambient,
},
NotificationOrigin::CLISession(*terminal_view_id),
*terminal_view_id,
vec![],
metadata.branch,
ctx,
);
}
@@ -237,10 +246,11 @@ impl AgentNotificationsModel {
}
let BlocklistAIHistoryEvent::UpdatedConversationStatus {
terminal_view_id,
terminal_surface_id,
conversation_id,
// We shouldn't trigger toasts when restoring conversations on startup.
is_restored: false,
update: ConversationStatusUpdate::Changed { .. },
..
} = event
else {
return;
@@ -262,7 +272,7 @@ impl AgentNotificationsModel {
&status,
*conversation_id,
latest_query,
*terminal_view_id,
*terminal_surface_id,
ctx,
);
// The new mailbox path handled the event — skip the legacy toast path below.
@@ -273,7 +283,7 @@ impl AgentNotificationsModel {
return;
}
if is_terminal_view_visible(*terminal_view_id, ctx) {
if is_terminal_view_visible(*terminal_surface_id, ctx) {
return;
}
@@ -286,7 +296,7 @@ impl AgentNotificationsModel {
ctx.emit(AgentManagementEvent::ConversationNeedsAttention {
window_id,
tab_index,
terminal_view_id: *terminal_view_id,
terminal_view_id: *terminal_surface_id,
conversation_id: *conversation_id,
});
}
@@ -310,22 +320,36 @@ impl AgentNotificationsModel {
}
let title = latest_query.unwrap_or_else(|| "Agent task".to_owned());
let metadata = TerminalViewMetadata::lookup(terminal_view_id, ctx);
let oz_agent = NotificationSourceAgent::Oz {
is_ambient: metadata.is_ambient,
};
match status {
// When the agent resumes its work, clear stale notifications.
ConversationStatus::InProgress => {
// When the agent resumes its work (or is automatically recovering from a
// transient failure), clear stale notifications.
ConversationStatus::InProgress | ConversationStatus::TransientError => {
self.remove_notification_by_source(origin, ctx);
}
ConversationStatus::Success => {
// Suppress the completion notification when a queued follow-up prompt will
// auto-send as soon as this conversation finishes. The conversation isn't
// really in a stopped state, so the notification would be noisy. Pending
// artifacts are left intact so they roll into the notification fired when the
// conversation eventually finishes with an empty queue.
if QueuedQueryModel::as_ref(ctx).has_autofireable_prompt(conversation_id) {
return;
}
let artifacts = self.flush_pending_artifacts(conversation_id);
self.add_notification(
title,
"Task completed.".to_owned(),
NotificationCategory::Complete,
NotificationSourceAgent::Oz,
oz_agent,
origin,
terminal_view_id,
artifacts,
metadata.branch,
ctx,
);
}
@@ -335,10 +359,11 @@ impl AgentNotificationsModel {
title,
"Task was cancelled.".to_owned(),
NotificationCategory::Complete,
NotificationSourceAgent::Oz,
oz_agent,
origin,
terminal_view_id,
artifacts,
metadata.branch,
ctx,
);
}
@@ -347,10 +372,11 @@ impl AgentNotificationsModel {
title,
blocked_action.clone(),
NotificationCategory::Request,
NotificationSourceAgent::Oz,
oz_agent,
origin,
terminal_view_id,
vec![],
metadata.branch,
ctx,
);
}
@@ -360,13 +386,20 @@ impl AgentNotificationsModel {
title,
"Something went wrong.".to_owned(),
NotificationCategory::Error,
NotificationSourceAgent::Oz,
oz_agent,
origin,
terminal_view_id,
artifacts,
metadata.branch,
ctx,
);
}
// Yielded conversations are still active; mirror the
// InProgress arm and clear any stale notification for this
// origin.
ConversationStatus::WaitingForEvents => {
self.remove_notification_by_source(origin, ctx);
}
}
}
@@ -401,14 +434,12 @@ impl AgentNotificationsModel {
origin: NotificationOrigin,
terminal_view_id: EntityId,
artifacts: Vec<Artifact>,
branch: Option<String>,
ctx: &mut ModelContext<Self>,
) {
if !*AISettings::as_ref(ctx).show_agent_notifications {
return;
}
let show_agent_notifications = *AISettings::as_ref(ctx).show_agent_notifications;
let is_visible = is_terminal_view_visible(terminal_view_id, ctx);
let branch = resolve_git_branch_for_terminal_view(terminal_view_id, ctx);
let item = NotificationItem::new(
title,
message,
@@ -420,12 +451,14 @@ impl AgentNotificationsModel {
artifacts,
branch,
);
send_telemetry_from_ctx!(
TelemetryEvent::AgentNotificationShown {
agent_variant: agent.into(),
},
ctx
);
if show_agent_notifications {
send_telemetry_from_ctx!(
TelemetryEvent::AgentNotificationShown {
agent_variant: agent.into(),
},
ctx
);
}
let id = item.id;
self.notifications.push(item);
@@ -453,13 +486,22 @@ pub enum AgentManagementEvent {
impl ConversationStatus {
/// Returns true if the updating the conversation with this status should trigger some
/// notification to the user.
///
/// Exhaustive match so a new `ConversationStatus` variant forces a
/// deliberate decision about whether it should fire a notification.
pub fn should_trigger_notification(&self) -> bool {
matches!(
self,
match self {
ConversationStatus::Success
| ConversationStatus::Blocked { .. }
| ConversationStatus::Error
)
| ConversationStatus::Blocked { .. }
| ConversationStatus::Error => true,
// Streaming hasn't reached a notable state; a recovering or
// yielded conversation is still active; user-cancellations are
// self-evident.
ConversationStatus::InProgress
| ConversationStatus::TransientError
| ConversationStatus::WaitingForEvents
| ConversationStatus::Cancelled => false,
}
}
}
@@ -502,17 +544,41 @@ fn window_and_tab_idx_id_for_conversation(
})
}
fn resolve_git_branch_for_terminal_view(
/// Per-notification metadata derived from a single [`TerminalView`] lookup. Both fields
/// are read on the same emit path, so we resolve the view once and pass the projection
/// down rather than walking the workspace tree for each.
struct TerminalViewMetadata {
is_ambient: bool,
branch: Option<String>,
}
impl TerminalViewMetadata {
fn lookup(terminal_view_id: EntityId, app: &AppContext) -> Self {
let Some(terminal_view) = find_terminal_view_by_id(terminal_view_id, app) else {
return Self {
is_ambient: false,
branch: None,
};
};
let view = terminal_view.as_ref(app);
Self {
is_ambient: view.is_ambient_agent_session(app),
branch: view.current_git_branch(app),
}
}
}
fn find_terminal_view_by_id(
terminal_view_id: EntityId,
app: &AppContext,
) -> Option<String> {
) -> Option<ViewHandle<TerminalView>> {
for (_, workspace_handle) in WorkspaceRegistry::as_ref(app).all_workspaces(app) {
for pane_group in workspace_handle.as_ref(app).tab_views() {
let pane_group = pane_group.as_ref(app);
for pane_id in pane_group.terminal_pane_ids() {
if let Some(terminal_view) = pane_group.terminal_view_from_pane_id(pane_id, app) {
if terminal_view.id() == terminal_view_id {
return terminal_view.as_ref(app).current_git_branch(app);
return Some(terminal_view);
}
}
}
@@ -1,14 +1,19 @@
use settings::Setting as _;
use galaxy_core::features::FeatureFlag;
use galaxyui::{App, EntityId, ModelHandle};
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::artifacts::Artifact;
use crate::ai::blocklist::BlocklistAIHistoryEvent;
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
use crate::BlocklistAIHistoryModel;
use galaxyui::{App, EntityId, ModelHandle, SingletonEntity};
use super::AgentNotificationsModel;
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
use crate::ai::agent_management::notifications::{
NotificationCategory, NotificationFilter, NotificationOrigin, NotificationSourceAgent,
};
use crate::ai::artifacts::Artifact;
use crate::ai::blocklist::BlocklistAIHistoryEvent;
use crate::settings::AISettings;
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::{report_if_error, BlocklistAIHistoryModel};
fn setup_app(
app: &mut App,
@@ -16,7 +21,11 @@ fn setup_app(
ModelHandle<BlocklistAIHistoryModel>,
ModelHandle<AgentNotificationsModel>,
) {
initialize_settings_for_tests(app);
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
// Registered after the history model since it subscribes to history events; the
// notifications model reads it to suppress completion notifications when a prompt is queued.
app.add_singleton_model(crate::ai::blocklist::QueuedQueryModel::new);
app.add_singleton_model(|_| CLIAgentSessionsModel::new());
app.add_singleton_model(|_| ActiveAgentViewsModel::new());
let notifications = app.add_singleton_model(AgentNotificationsModel::new);
@@ -51,7 +60,7 @@ fn artifact_event_accumulates_into_pending() {
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
terminal_view_id,
terminal_surface_id: terminal_view_id,
conversation_id,
artifact: make_pr_artifact("https://github.com/org/repo/pull/42", "feature-branch"),
});
@@ -76,14 +85,14 @@ fn multiple_artifacts_accumulated_across_turns() {
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
terminal_view_id,
terminal_surface_id: terminal_view_id,
conversation_id,
artifact: make_plan_artifact("doc-1", "My Plan"),
});
});
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
terminal_view_id,
terminal_surface_id: terminal_view_id,
conversation_id,
artifact: make_pr_artifact("https://github.com/org/repo/pull/1", "main"),
});
@@ -98,6 +107,46 @@ fn multiple_artifacts_accumulated_across_turns() {
});
}
#[test]
fn add_notification_tracks_unread_activity_when_in_app_notifications_are_hidden() {
App::test((), |mut app| async move {
let _guard = FeatureFlag::HOANotifications.override_enabled(true);
let (_history, notifications) = setup_app(&mut app);
AISettings::handle(&app).update(&mut app, |settings, ctx| {
report_if_error!(settings.show_agent_notifications.set_value(false, ctx));
});
let conversation_id = AIConversationId::new();
let terminal_view_id = EntityId::new();
notifications.update(&mut app, |model, ctx| {
model.add_notification(
"Agent task".to_owned(),
"Task completed.".to_owned(),
NotificationCategory::Complete,
NotificationSourceAgent::Oz { is_ambient: false },
NotificationOrigin::Conversation(conversation_id),
terminal_view_id,
vec![],
None,
ctx,
);
});
notifications.read(&app, |model, _| {
assert_eq!(
model
.notifications()
.filtered_count(NotificationFilter::All),
1
);
assert!(model
.notifications()
.has_unread_for_terminal_view(terminal_view_id));
});
});
}
#[test]
fn flush_drains_pending_artifacts() {
App::test((), |mut app| async move {
@@ -109,7 +158,7 @@ fn flush_drains_pending_artifacts() {
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
terminal_view_id,
terminal_surface_id: terminal_view_id,
conversation_id,
artifact: make_pr_artifact("https://github.com/org/repo/pull/1", "branch-1"),
});
@@ -153,7 +202,7 @@ fn deletion_cleans_up_pending_artifacts() {
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
terminal_view_id,
terminal_surface_id: terminal_view_id,
conversation_id,
artifact: make_pr_artifact("https://github.com/org/repo/pull/1", "branch-1"),
});
@@ -161,9 +210,10 @@ fn deletion_cleans_up_pending_artifacts() {
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
ctx.emit(BlocklistAIHistoryEvent::DeletedConversation {
terminal_view_id,
terminal_surface_id: terminal_view_id,
conversation_id,
conversation_title: None,
run_id: None,
});
});
@@ -185,14 +235,14 @@ fn separate_conversations_have_independent_pending_artifacts() {
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
terminal_view_id,
terminal_surface_id: terminal_view_id,
conversation_id: conv_a,
artifact: make_pr_artifact("https://github.com/org/repo/pull/1", "branch-a"),
});
});
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
terminal_view_id,
terminal_surface_id: terminal_view_id,
conversation_id: conv_b,
artifact: make_plan_artifact("doc-b", "Plan B"),
});
@@ -209,3 +259,178 @@ fn separate_conversations_have_independent_pending_artifacts() {
});
});
}
// should_trigger_notification: pure-function tests pinning which statuses
// fire user-facing notifications. Terminal-error and blocked surface;
// in-progress, waiting-for-events, and user-cancelled do not.
#[test]
fn should_trigger_notification_returns_true_for_success() {
assert!(ConversationStatus::Success.should_trigger_notification());
}
#[test]
fn should_trigger_notification_returns_true_for_blocked() {
assert!(ConversationStatus::Blocked {
blocked_action: "approve diff".to_owned(),
}
.should_trigger_notification());
}
#[test]
fn should_trigger_notification_returns_true_for_error() {
assert!(ConversationStatus::Error.should_trigger_notification());
}
#[test]
fn should_trigger_notification_returns_false_for_in_progress() {
assert!(!ConversationStatus::InProgress.should_trigger_notification());
}
#[test]
fn should_trigger_notification_returns_false_for_waiting_for_events() {
assert!(!ConversationStatus::WaitingForEvents.should_trigger_notification());
}
#[test]
fn should_trigger_notification_returns_false_for_cancelled() {
assert!(!ConversationStatus::Cancelled.should_trigger_notification());
}
// Mailbox suppression for non-terminal status updates. In App::test the
// `is_conversation_open` gate always returns false, so the
// WaitingForEvents and InProgress arms both clear stale notifications
// regardless of status; this still pins the user-visible contract that
// no stale "Task completed" toast survives a non-terminal transition.
/// Disables `show_agent_notifications` so subsequent `add_notification`
/// calls skip the `send_telemetry_from_ctx!` branch — the test app does
/// not register a `TelemetryContextProvider` singleton and the macro
/// would otherwise panic.
fn disable_telemetry_path(app: &mut App) {
AISettings::handle(app).update(app, |settings, ctx| {
report_if_error!(settings.show_agent_notifications.set_value(false, ctx));
});
}
/// Pre-populates a `Complete` notification for `conversation_id` so that a
/// subsequent non-terminal status update has something to clear.
fn seed_stale_notification(
notifications: &ModelHandle<AgentNotificationsModel>,
app: &mut App,
conversation_id: AIConversationId,
terminal_view_id: EntityId,
) {
notifications.update(app, |model, ctx| {
model.add_notification(
"Agent task".to_owned(),
"Task completed.".to_owned(),
NotificationCategory::Complete,
NotificationSourceAgent::Oz { is_ambient: false },
NotificationOrigin::Conversation(conversation_id),
terminal_view_id,
vec![],
None,
ctx,
);
});
}
#[test]
fn waiting_for_events_clears_stale_notification_and_adds_none() {
App::test((), |mut app| async move {
let _guard = FeatureFlag::HOANotifications.override_enabled(true);
let (history, notifications) = setup_app(&mut app);
disable_telemetry_path(&mut app);
let conversation = AIConversation::new(false, false);
let conversation_id = conversation.id();
let terminal_view_id = EntityId::new();
history.update(&mut app, |model, ctx| {
model.restore_conversations(terminal_view_id, vec![conversation], ctx);
});
seed_stale_notification(&notifications, &mut app, conversation_id, terminal_view_id);
notifications.read(&app, |model, _| {
assert_eq!(
model
.notifications()
.filtered_count(NotificationFilter::All),
1,
"precondition: one stale notification queued"
);
});
history.update(&mut app, |model, ctx| {
let conv = model
.conversation_mut(&conversation_id)
.expect("conversation was just restored");
conv.update_status(ConversationStatus::WaitingForEvents, terminal_view_id, ctx);
});
notifications.read(&app, |model, _| {
assert_eq!(
model
.notifications()
.filtered_count(NotificationFilter::All),
0,
"WaitingForEvents must clear stale notifications and add no new toast"
);
});
});
}
#[test]
fn in_progress_resume_clears_stale_notification_and_adds_none() {
App::test((), |mut app| async move {
let _guard = FeatureFlag::HOANotifications.override_enabled(true);
let (history, notifications) = setup_app(&mut app);
disable_telemetry_path(&mut app);
let conversation = AIConversation::new(false, false);
let conversation_id = conversation.id();
let terminal_view_id = EntityId::new();
history.update(&mut app, |model, ctx| {
model.restore_conversations(terminal_view_id, vec![conversation], ctx);
});
// First move the conversation into WaitingForEvents, then back into
// InProgress. The second transition is the resume signal that
// PRODUCT.md (18) requires not to fire a notification.
history.update(&mut app, |model, ctx| {
let conv = model
.conversation_mut(&conversation_id)
.expect("conversation was just restored");
conv.update_status(ConversationStatus::WaitingForEvents, terminal_view_id, ctx);
});
seed_stale_notification(&notifications, &mut app, conversation_id, terminal_view_id);
notifications.read(&app, |model, _| {
assert_eq!(
model
.notifications()
.filtered_count(NotificationFilter::All),
1,
"precondition: one stale notification queued before the resume transition"
);
});
history.update(&mut app, |model, ctx| {
let conv = model
.conversation_mut(&conversation_id)
.expect("conversation still exists");
conv.update_status(ConversationStatus::InProgress, terminal_view_id, ctx);
});
notifications.read(&app, |model, _| {
assert_eq!(
model
.notifications()
.filtered_count(NotificationFilter::All),
0,
"WaitingForEvents → InProgress resume must not fire a notification \
(covers PRODUCT.md (18))"
);
});
});
}
@@ -3,8 +3,8 @@
//! This modal is displayed when users click "New agent" to choose between
//! cloud and local agent modes.
use crate::appearance::Appearance;
use crate::ui_components::icons::Icon;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::color::blend::Blend;
use galaxy_core::ui::theme::color::internal_colors;
use galaxyui::elements::{
@@ -17,8 +17,9 @@ use galaxyui::keymap::{FixedBinding, Keystroke};
use galaxyui::platform::Cursor;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use crate::appearance::Appearance;
use crate::ui_components::icons::Icon;
// Modal dimensions based on Figma design.
const MODAL_WIDTH: f32 = 440.;
@@ -1,3 +1,25 @@
use std::collections::HashMap;
use serde::Serialize;
use string_offset::CharCounter;
use warp_completer::signatures::CommandRegistry;
use warp_completer::util::parse_current_commands_and_tokens;
use warp_completer::ParsedTokensSnapshot;
use galaxy_core::report_error;
use galaxy_core::ui::theme::{AnsiColorIdentifier, AnsiColors};
use warpui::clipboard::ClipboardContent;
use warpui::elements::new_scrollable::{ClippedAxisConfiguration, DualAxisConfig, NewScrollable};
use warpui::elements::{
Align, Border, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Element, Empty, Expanded, Flex, Highlight, HighlightedRange,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::prelude::ChildView;
use warpui::text_layout::TextStyle;
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle};
use crate::ai::agent_management::telemetry::{AgentManagementTelemetryEvent, SetupGuideStep};
use crate::ai::blocklist::code_block::{
render_code_block_plain, CodeBlockOptions, CodeSnippetButtonHandles,
@@ -8,26 +30,6 @@ use crate::send_telemetry_from_ctx;
use crate::view_components::action_button::{ActionButton, SecondaryTheme};
use crate::workflows::workflow::{Argument, ArgumentType, Workflow};
use crate::workflows::WorkflowType;
use galaxy_completer::signatures::CommandRegistry;
use galaxy_completer::{util::parse_current_commands_and_tokens, ParsedTokensSnapshot};
use galaxy_core::report_error;
use galaxy_core::ui::theme::{AnsiColorIdentifier, AnsiColors};
use galaxyui::clipboard::ClipboardContent;
use galaxyui::elements::{
new_scrollable::{ClippedAxisConfiguration, DualAxisConfig, NewScrollable},
Align, Border, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Element, Empty, Expanded, Flex, Highlight, HighlightedRange,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::prelude::ChildView;
use galaxyui::text_layout::TextStyle;
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::ViewHandle;
use galaxyui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext};
use serde::Serialize;
use std::collections::HashMap;
use string_offset::CharCounter;
const DOCS_URL: &str = "https://docs.warp.dev/agent-platform/cloud-agents/overview";
const ENV_DOCS_URL: &str =
@@ -412,6 +414,7 @@ impl CloudSetupGuideView {
mouse_handles: Some(handles),
file_path: None,
},
true,
app,
None,
)
@@ -4,14 +4,12 @@ use galaxy_core::ui::theme::AnsiColorIdentifier;
use galaxyui::elements::{ChildView, CrossAxisAlignment, Empty, Flex, ParentElement};
use galaxyui::{AppContext, Element, Entity, TypedActionView, View, ViewContext, ViewHandle};
use crate::view_components::copyable_text_field::COPY_FEEDBACK_DURATION;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_conversations_model::AgentRunDisplayStatus;
use crate::ai::agent_management::view::ManagementCardItemId;
use crate::ai::agent_conversations_model::{AgentConversationEntryId, AgentRunDisplayStatus};
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, ButtonSize, SecondaryTheme};
use crate::view_components::copyable_text_field::COPY_FEEDBACK_DURATION;
use crate::workspace::WorkspaceAction;
const BUTTON_SPACING: f32 = 4.;
@@ -25,7 +23,7 @@ pub struct ActionButtonsConfig {
pub fork_conversation_id: Option<AIConversationId>,
/// Shows an info button for viewing more details.
/// Only used in management view hover toolbelt.
pub view_details_item_id: Option<ManagementCardItemId>,
pub view_details_item_id: Option<AgentConversationEntryId>,
/// Conversation link URL (either to the transcript or live session) for copy link button.
pub copy_link_url: Option<String>,
}
@@ -87,7 +85,7 @@ pub enum AgentDetailsButtonEvent {
Open,
CancelTask { task_id: AmbientAgentTaskId },
ForkConversation { conversation_id: AIConversationId },
ViewDetails { item_id: ManagementCardItemId },
ViewDetails { item_id: AgentConversationEntryId },
CopyLink { link: String },
}
@@ -260,9 +258,7 @@ impl TypedActionView for ConversationActionButtonsRow {
}
AgentDetailsAction::ViewDetails => {
if let Some(item_id) = &self.config.view_details_item_id {
ctx.emit(AgentDetailsButtonEvent::ViewDetails {
item_id: item_id.clone(),
});
ctx.emit(AgentDetailsButtonEvent::ViewDetails { item_id: *item_id });
}
}
AgentDetailsAction::CopyLink => {
@@ -42,12 +42,23 @@ impl NotificationFilter {
}
}
/// Identifies the agent that produced a notification.
/// Identifies the agent that produced a notification, including whether the run was
/// ambient (cloud) or local. The `is_ambient` flag drives the cloud-lobe rendering in
/// [`render_agent_avatar`].
#[derive(Debug, Clone, Copy)]
#[allow(clippy::upper_case_acronyms)]
pub enum NotificationSourceAgent {
Oz,
CLI(CLIAgent),
Oz { is_ambient: bool },
CLI { agent: CLIAgent, is_ambient: bool },
}
impl NotificationSourceAgent {
pub fn is_ambient(&self) -> bool {
match self {
NotificationSourceAgent::Oz { is_ambient }
| NotificationSourceAgent::CLI { is_ambient, .. } => *is_ambient,
}
}
}
/// Identifies the conversation or session a notification belongs to.
@@ -1,7 +1,10 @@
use std::sync::Arc;
use pathfinder_color::ColorU;
use galaxy_core::ui::appearance::Appearance as CoreAppearance;
use galaxy_core::ui::icons::Icon;
use galaxy_core::ui::theme::{Fill, GalaxyTheme};
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::{Fill, WarpTheme};
use galaxyui::clipboard::ClipboardContent;
use galaxyui::elements::{
ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DispatchEventResult,
@@ -11,10 +14,6 @@ use galaxyui::elements::{
use galaxyui::fonts::Weight;
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::{View, ViewContext, ViewHandle};
use pathfinder_color::ColorU;
use galaxy_core::ui::appearance::Appearance as CoreAppearance;
use galaxy_core::ui::theme::color::internal_colors;
use crate::ai::agent::conversation::ConversationStatus;
use crate::ai::agent_management::notifications::item::NotificationSourceAgent;
@@ -25,9 +24,7 @@ use crate::ai::artifacts::{
};
use crate::appearance::Appearance;
use crate::send_telemetry_from_ctx;
use crate::ui_components::icon_with_status::{
render_icon_with_status, IconWithStatusSizing, IconWithStatusVariant,
};
use crate::ui_components::icon_with_status::{render_icon_with_status, IconWithStatusVariant};
use crate::util::time_format::format_elapsed_since;
use crate::view_components::action_button::ActionButtonTheme;
use crate::workspace::WorkspaceAction;
@@ -399,14 +396,8 @@ fn render_message_text(message: &str, expanded: bool, appearance: &Appearance) -
.finish()
}
const NOTIFICATION_AVATAR_SIZING: IconWithStatusSizing = IconWithStatusSizing {
icon_size: 16.,
padding: 8.,
badge_icon_size: 12.,
badge_padding: 2.,
overall_size_override: None,
badge_offset: (6., 6.),
};
/// Total size of the agent avatar component rendered alongside each notification.
const NOTIFICATION_AVATAR_SIZE: f32 = 32.;
fn render_agent_avatar(
agent: NotificationSourceAgent,
@@ -415,18 +406,20 @@ fn render_agent_avatar(
) -> Box<dyn Element> {
let status = notification_category_to_conversation_status(category);
let variant = match agent {
NotificationSourceAgent::Oz => IconWithStatusVariant::OzAgent {
NotificationSourceAgent::Oz { is_ambient } => IconWithStatusVariant::OzAgent {
status: Some(status),
is_ambient: false,
is_ambient,
},
NotificationSourceAgent::CLI(cli) => IconWithStatusVariant::CLIAgent {
agent: cli,
NotificationSourceAgent::CLI { agent, is_ambient } => IconWithStatusVariant::CLIAgent {
agent,
status: Some(status),
is_ambient,
},
};
render_icon_with_status(
variant,
&NOTIFICATION_AVATAR_SIZING,
NOTIFICATION_AVATAR_SIZE,
0.,
theme,
theme.surface_2(),
)
@@ -12,7 +12,7 @@ fn make_conversation_notification(
"test".to_owned(),
"msg".to_owned(),
NotificationCategory::Complete,
NotificationSourceAgent::Oz,
NotificationSourceAgent::Oz { is_ambient: false },
NotificationOrigin::Conversation(conversation_id),
false,
terminal_view_id,
@@ -26,7 +26,10 @@ fn make_cli_session_notification(terminal_view_id: EntityId) -> NotificationItem
"cli test".to_owned(),
"cli msg".to_owned(),
NotificationCategory::Complete,
NotificationSourceAgent::CLI(CLIAgent::Claude),
NotificationSourceAgent::CLI {
agent: CLIAgent::Claude,
is_ambient: false,
},
NotificationOrigin::CLISession(terminal_view_id),
false,
terminal_view_id,
+20
View File
@@ -108,9 +108,14 @@ pub enum AgentManagementTelemetryEvent {
/// User clicked "Continue locally" in the tombstone
#[cfg(not(target_family = "wasm"))]
TombstoneContinueLocally,
/// User clicked "Continue" in the tombstone to start a cloud follow-up.
TombstoneContinueInCloud { task_id: String },
/// User clicked "Continue locally" in the details panel
#[cfg(not(target_family = "wasm"))]
DetailsPanelContinueLocally,
/// User invoked the /continue-locally slash command
#[cfg(not(target_family = "wasm"))]
SlashCommandContinueLocally,
/// User clicked "Open in Warp" in the tombstone (wasm)
#[cfg(target_family = "wasm")]
TombstoneOpenInWarp,
@@ -190,8 +195,13 @@ impl TelemetryEvent for AgentManagementTelemetryEvent {
}
#[cfg(not(target_family = "wasm"))]
AgentManagementTelemetryEvent::TombstoneContinueLocally => None,
AgentManagementTelemetryEvent::TombstoneContinueInCloud { task_id } => Some(json!({
"task_id": task_id,
})),
#[cfg(not(target_family = "wasm"))]
AgentManagementTelemetryEvent::DetailsPanelContinueLocally => None,
#[cfg(not(target_family = "wasm"))]
AgentManagementTelemetryEvent::SlashCommandContinueLocally => None,
#[cfg(target_family = "wasm")]
AgentManagementTelemetryEvent::TombstoneOpenInWarp => None,
AgentManagementTelemetryEvent::CloudRunCancelled { task_id } => {
@@ -242,8 +252,11 @@ impl TelemetryEventDesc for AgentManagementTelemetryEventDiscriminants {
Self::TombstoneArtifactClicked => "AgentManagement.TombstoneArtifactClicked",
#[cfg(not(target_family = "wasm"))]
Self::TombstoneContinueLocally => "AgentManagement.TombstoneContinueLocally",
Self::TombstoneContinueInCloud => "AgentManagement.TombstoneContinueInCloud",
#[cfg(not(target_family = "wasm"))]
Self::DetailsPanelContinueLocally => "AgentManagement.DetailsPanelContinueLocally",
#[cfg(not(target_family = "wasm"))]
Self::SlashCommandContinueLocally => "AgentManagement.SlashCommandContinueLocally",
#[cfg(target_family = "wasm")]
Self::TombstoneOpenInWarp => "AgentManagement.TombstoneOpenInWarp",
Self::CloudRunCancelled => "AgentManagement.CloudRunCancelled",
@@ -274,10 +287,17 @@ impl TelemetryEventDesc for AgentManagementTelemetryEventDiscriminants {
Self::TombstoneArtifactClicked => "User clicked an artifact in the tombstone view",
#[cfg(not(target_family = "wasm"))]
Self::TombstoneContinueLocally => "User clicked Continue locally in the tombstone",
Self::TombstoneContinueInCloud => {
"User clicked Continue in the tombstone to start a cloud follow-up"
}
#[cfg(not(target_family = "wasm"))]
Self::DetailsPanelContinueLocally => {
"User clicked Continue locally in the details panel"
}
#[cfg(not(target_family = "wasm"))]
Self::SlashCommandContinueLocally => {
"User invoked /continue-locally to fork a cloud conversation locally"
}
#[cfg(target_family = "wasm")]
Self::TombstoneOpenInWarp => "User clicked Open in Warp in the tombstone",
Self::CloudRunCancelled => "User cancelled a cloud run",
+305 -308
View File
@@ -8,62 +8,9 @@ use galaxyui::scene::DropShadow;
use galaxyui::ui_components::button::ButtonVariant;
use lazy_static::lazy_static;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use settings::Setting;
use siphasher::sip::SipHasher;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_conversations_model::{
AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, ArtifactFilter,
ConversationOrTask, CreatedOnFilter, CreatorFilter, EnvironmentFilter, HarnessFilter,
OwnerFilter, SessionStatus, SourceFilter, StatusFilter,
};
use crate::ai::agent_management::agent_type_selector::{
AgentType, AgentTypeSelector, AgentTypeSelectorEvent,
};
use crate::ai::agent_management::cloud_setup_guide_view::{
CloudSetupGuideEvent, CloudSetupGuideView,
};
use crate::ai::agent_management::details_action_buttons::{
ActionButtonsConfig, AgentDetailsButtonEvent, ConversationActionButtonsRow,
};
use crate::ai::agent_management::telemetry::{
AgentManagementTelemetryEvent, ArtifactType, FilterType, OpenedFrom,
};
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::ambient_agents::{cancel_task_with_toast, AgentSource};
use crate::ai::artifacts::{Artifact, ArtifactButtonsRow, ArtifactButtonsRowEvent};
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
use crate::ai::conversation_details_panel::{
ConversationDetailsData, ConversationDetailsPanel, ConversationDetailsPanelEvent,
};
use crate::ai::conversation_status_ui::render_status_element;
use crate::ai::harness_display;
use crate::app_state::PersistedAgentManagementFilters;
use crate::appearance::Appearance;
use crate::auth::AuthStateProvider;
use crate::editor::{
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys,
PropagateHorizontalNavigationKeys, SingleLineEditorOptions, TextOptions,
};
use crate::menu::{MenuItem, MenuItemFields};
use crate::notebooks::NotebookId;
use crate::settings::ai::AISettings;
use crate::ui_components::avatar::{Avatar, AvatarContent};
use crate::util::time_format::format_approx_duration_from_now_utc;
use crate::view_components::action_button::{
ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme,
};
use crate::view_components::compactible_action_button::{
CompactibleActionButton, MEDIUM_SIZE_SWITCH_THRESHOLD,
};
use crate::view_components::dropdown::{Dropdown, DropdownAction, DropdownStyle};
use crate::view_components::DismissibleToast;
use crate::view_components::FilterableDropdown;
use crate::workflows::WorkflowType;
use crate::workspace::{ForkedConversationDestination, ToastStack};
use crate::workspace::{RestoreConversationLayout, WorkspaceAction};
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{send_telemetry_from_ctx, AgentModeEntrypoint};
use galaxy_cli::agent::Harness;
use galaxy_core::ui::icons::Icon;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::Fill;
@@ -79,15 +26,71 @@ use galaxyui::elements::{
SizeConstraintCondition, SizeConstraintSwitch, Stack, Text, Wrap,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::keymap::FixedBinding;
use galaxyui::platform::Cursor;
use galaxyui::ui_components::components::UiComponent;
use galaxyui::ui_components::components::UiComponentStyles;
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::{
keymap::FixedBinding, Action, AppContext, Entity, FocusContext, ModelHandle, SingletonEntity,
TypedActionView, View, ViewContext, ViewHandle, WeakViewHandle,
AppContext, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle, WeakViewHandle,
};
use pathfinder_geometry::vector::vec2f;
use settings::Setting;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_conversations_model::{
AgentConversationEntry, AgentConversationEntryId, AgentConversationNavigationSubject,
AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, ArtifactFilter,
ConversationUpdateKind, CreatedOnFilter, CreatorFilter, EnvironmentFilter, HarnessFilter,
OwnerFilter, SessionStatus, SourceFilter, StatusFilter,
};
use crate::ai::agent_management::agent_type_selector::{
AgentType, AgentTypeSelector, AgentTypeSelectorEvent,
};
use crate::ai::agent_management::cloud_setup_guide_view::{
CloudSetupGuideEvent, CloudSetupGuideView,
};
use crate::ai::agent_management::details_action_buttons::{
ActionButtonsConfig, AgentDetailsButtonEvent, ConversationActionButtonsRow,
};
use crate::ai::agent_management::telemetry::{
AgentManagementTelemetryEvent, ArtifactType, FilterType, OpenedFrom,
};
use crate::ai::ambient_agents::{cancel_task_with_toast, AgentSource};
use crate::ai::artifacts::{Artifact, ArtifactButtonsRow, ArtifactButtonsRowEvent};
use crate::ai::blocklist::format_credits;
use crate::ai::conversation_details_panel::{
ConversationDetailsData, ConversationDetailsPanel, ConversationDetailsPanelEvent,
};
use crate::ai::harness_availability::HarnessAvailabilityModel;
use crate::ai::harness_display;
use crate::app_state::PersistedAgentManagementFilters;
use crate::appearance::Appearance;
use crate::auth::AuthStateProvider;
use crate::editor::{
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys,
PropagateHorizontalNavigationKeys, SingleLineEditorOptions, TextOptions,
};
use crate::menu::{MenuItem, MenuItemFields};
use crate::notebooks::NotebookId;
use crate::settings::ai::AISettings;
use crate::ui_components::agent_icon::agent_conversation_entry_icon_variant;
use crate::ui_components::avatar::{Avatar, AvatarContent};
use crate::ui_components::icon_with_status::render_icon_with_status;
use crate::util::time_format::format_approx_duration_from_now_utc;
use crate::view_components::action_button::{
ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme,
};
use crate::view_components::compactible_action_button::{
CompactibleActionButton, MEDIUM_SIZE_SWITCH_THRESHOLD,
};
use crate::view_components::dropdown::{
Dropdown, DropdownAction, DropdownItemAction, DropdownStyle,
};
use crate::view_components::{DismissibleToast, FilterableDropdown};
use crate::workflows::WorkflowType;
use crate::workspace::{
ForkedConversationDestination, RestoreConversationLayout, ToastStack, WorkspaceAction,
};
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{send_telemetry_from_ctx, AgentModeEntrypoint};
lazy_static! {
static ref HASHER: SipHasher = SipHasher::new_with_keys(0, 0);
@@ -105,8 +108,9 @@ const CARD_CONTENT_PADDING: f32 = 12.;
const CARD_BORDER_RADIUS: f32 = 4.;
const CARD_MARGIN_BOTTOM: f32 = 8.;
const STATUS_ICON_SIZE: f32 = 12.;
const BUTTON_SIZE: f32 = 20.;
/// Total size of the agent icon-with-status component rendered in each card's header row.
const CARD_AGENT_ICON_SIZE: f32 = 24.;
const CREATOR_AVATAR_FONT_SIZE: f32 = 10.;
const SESSION_EXPIRED_TEXT: &str = "Sessions expire after one week and cannot be opened.";
@@ -125,21 +129,7 @@ fn should_show_artifacts(artifacts: &[Artifact]) -> bool {
!artifacts.is_empty() && FeatureFlag::ConversationArtifacts.is_enabled()
}
/// Identifies a card item - either a task ID or a conversation ID
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ManagementCardItemId {
Task(AmbientAgentTaskId),
Conversation(AIConversationId),
}
impl ManagementCardItemId {
fn as_key(&self) -> String {
match self {
ManagementCardItemId::Task(id) => format!("task_{id}"),
ManagementCardItemId::Conversation(id) => format!("conv_{id}"),
}
}
}
pub type ManagementCardItemId = AgentConversationEntryId;
/// Store state for a given task row
struct CardState {
@@ -222,6 +212,13 @@ impl AgentManagementView {
Self::handle_agent_management_model_event,
);
ctx.subscribe_to_model(
&HarnessAvailabilityModel::handle(ctx),
|me, _, _event, ctx| {
me.update_harness_dropdown(ctx);
},
);
let list_state = Self::construct_fresh_list_state(ctx.handle());
let all_filter_button = ctx.add_typed_action_view(|_ctx| {
@@ -496,7 +493,7 @@ impl AgentManagementView {
let make_status_option =
|label: &str, action: AgentManagementViewAction, icon_data: Option<(Icon, Fill)>| {
let mut fields = MenuItemFields::new(label)
.with_on_select_action(DropdownAction::SelectActionAndClose(action));
.with_on_select_action(DropdownAction::select_action_and_close(action));
if let Some((icon, color)) = icon_data {
fields = fields.with_icon(icon).with_override_icon_color(color);
}
@@ -546,7 +543,7 @@ impl AgentManagementView {
}
/// Build the list of source filter items.
fn build_source_dropdown_items() -> Vec<MenuItem<DropdownAction<AgentManagementViewAction>>> {
fn build_source_dropdown_items() -> Vec<MenuItem<DropdownAction>> {
// Build up the sources list
let mut sources = vec![
AgentSource::WebApp,
@@ -564,14 +561,16 @@ impl AgentManagementView {
}
let mut items = vec![MenuItem::Item(
MenuItemFields::new("All").with_on_select_action(DropdownAction::SelectActionAndClose(
AgentManagementViewAction::SetSourceFilter(SourceFilter::All),
)),
MenuItemFields::new("All").with_on_select_action(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetSourceFilter(SourceFilter::All),
),
),
)];
for source in sources {
items.push(MenuItem::Item(
MenuItemFields::new(source.display_name()).with_on_select_action(
DropdownAction::SelectActionAndClose(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetSourceFilter(SourceFilter::Specific(source)),
),
),
@@ -601,22 +600,22 @@ impl AgentManagementView {
let items = vec![
MenuItem::Item(MenuItemFields::new("All").with_on_select_action(
DropdownAction::SelectActionAndClose(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetCreatedOnFilter(CreatedOnFilter::All),
),
)),
MenuItem::Item(MenuItemFields::new("Last 24 hours").with_on_select_action(
DropdownAction::SelectActionAndClose(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetCreatedOnFilter(CreatedOnFilter::Last24Hours),
),
)),
MenuItem::Item(MenuItemFields::new("Past 3 days").with_on_select_action(
DropdownAction::SelectActionAndClose(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetCreatedOnFilter(CreatedOnFilter::Past3Days),
),
)),
MenuItem::Item(MenuItemFields::new("Last week").with_on_select_action(
DropdownAction::SelectActionAndClose(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetCreatedOnFilter(CreatedOnFilter::LastWeek),
),
)),
@@ -635,29 +634,29 @@ impl AgentManagementView {
let items = vec![
MenuItem::Item(MenuItemFields::new("All").with_on_select_action(
DropdownAction::SelectActionAndClose(AgentManagementViewAction::SetArtifactFilter(
ArtifactFilter::All,
)),
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetArtifactFilter(ArtifactFilter::All),
),
)),
MenuItem::Item(MenuItemFields::new("Pull Request").with_on_select_action(
DropdownAction::SelectActionAndClose(AgentManagementViewAction::SetArtifactFilter(
ArtifactFilter::PullRequest,
)),
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetArtifactFilter(ArtifactFilter::PullRequest),
),
)),
MenuItem::Item(MenuItemFields::new("Plan").with_on_select_action(
DropdownAction::SelectActionAndClose(AgentManagementViewAction::SetArtifactFilter(
ArtifactFilter::Plan,
)),
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetArtifactFilter(ArtifactFilter::Plan),
),
)),
MenuItem::Item(MenuItemFields::new("Screenshot").with_on_select_action(
DropdownAction::SelectActionAndClose(AgentManagementViewAction::SetArtifactFilter(
ArtifactFilter::Screenshot,
)),
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetArtifactFilter(ArtifactFilter::Screenshot),
),
)),
MenuItem::Item(MenuItemFields::new("File").with_on_select_action(
DropdownAction::SelectActionAndClose(AgentManagementViewAction::SetArtifactFilter(
ArtifactFilter::File,
)),
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetArtifactFilter(ArtifactFilter::File),
),
)),
];
@@ -672,17 +671,27 @@ impl AgentManagementView {
let mut dropdown = Dropdown::new(ctx);
Self::setup_filter_menu(&mut dropdown, "Harness", ctx);
// "All" has no leading icon, matching the Status dropdown's "All" row.
let items = Self::build_harness_dropdown_items(ctx);
dropdown.set_rich_items(items, ctx);
dropdown.set_selected_by_index(0, ctx);
dropdown
}
fn build_harness_dropdown_items(app: &AppContext) -> Vec<MenuItem<DropdownAction>> {
let mut items = vec![MenuItem::Item(
MenuItemFields::new("All").with_on_select_action(DropdownAction::SelectActionAndClose(
AgentManagementViewAction::SetHarnessFilter(HarnessFilter::All),
)),
MenuItemFields::new("All").with_on_select_action(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetHarnessFilter(HarnessFilter::All),
),
),
)];
for harness in [Harness::Oz, Harness::Claude, Harness::Gemini] {
let mut fields = MenuItemFields::new(harness_display::display_name(harness))
let availability = HarnessAvailabilityModel::as_ref(app);
for entry in availability.available_harnesses() {
let harness = entry.harness;
let mut fields = MenuItemFields::new(entry.display_name.clone())
.with_icon(harness_display::icon_for(harness))
.with_on_select_action(DropdownAction::SelectActionAndClose(
.with_on_select_action(DropdownAction::select_action_and_close(
AgentManagementViewAction::SetHarnessFilter(HarnessFilter::Specific(harness)),
));
if let Some(color) = harness_display::brand_color(harness) {
@@ -691,9 +700,7 @@ impl AgentManagementView {
items.push(MenuItem::Item(fields));
}
dropdown.set_rich_items(items, ctx);
dropdown.set_selected_by_index(0, ctx);
dropdown
items
}
fn create_environment_dropdown(
@@ -732,7 +739,7 @@ impl AgentManagementView {
}
// Initialize the dropdown menu for the filter dropdowns (status, source)
fn setup_filter_menu<A: Action + Clone>(
fn setup_filter_menu<A: DropdownItemAction>(
dropdown: &mut Dropdown<A>,
label_prefix: &'static str,
ctx: &mut ViewContext<Dropdown<A>>,
@@ -744,7 +751,7 @@ impl AgentManagementView {
}
// Initialize the dropdown menu for the searchable filter dropdowns (creator)
fn setup_searchable_filter_menu<A: Action + Clone>(
fn setup_searchable_filter_menu<A: DropdownItemAction>(
dropdown: &mut FilterableDropdown<A>,
label_prefix: &'static str,
ctx: &mut ViewContext<FilterableDropdown<A>>,
@@ -755,6 +762,13 @@ impl AgentManagementView {
dropdown.set_button_variant(ButtonVariant::Secondary);
}
fn update_harness_dropdown(&mut self, ctx: &mut ViewContext<Self>) {
let items = Self::build_harness_dropdown_items(ctx);
self.harness_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_rich_items(items, ctx);
});
}
/// Since the valid set of environments depends on what tasks we have loaded in,
/// we use this function to update the available options depending on the most recent
/// set of tasks.
@@ -771,7 +785,7 @@ impl AgentManagementView {
self.environment_dropdown.update(ctx, |dropdown, ctx| {
let mut items = vec![MenuItem::Item(
MenuItemFields::new("All").with_on_select_action(
DropdownAction::SelectActionAndClose(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetEnvironmentFilter(EnvironmentFilter::All),
),
),
@@ -779,7 +793,7 @@ impl AgentManagementView {
items.push(MenuItem::Item(
MenuItemFields::new("None").with_on_select_action(
DropdownAction::SelectActionAndClose(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetEnvironmentFilter(
EnvironmentFilter::NoEnvironment,
),
@@ -793,7 +807,7 @@ impl AgentManagementView {
for (environment_id, environment_name) in sorted_envs {
items.push(MenuItem::Item(
MenuItemFields::new(environment_name).with_on_select_action(
DropdownAction::SelectActionAndClose(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetEnvironmentFilter(
EnvironmentFilter::Specific(environment_id),
),
@@ -818,7 +832,7 @@ impl AgentManagementView {
self.creator_dropdown.update(ctx, |dropdown, ctx| {
let mut items = vec![MenuItem::Item(
MenuItemFields::new("All").with_on_select_action(
DropdownAction::SelectActionAndClose(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetCreatorFilter(CreatorFilter::All),
),
),
@@ -826,7 +840,7 @@ impl AgentManagementView {
for (name, uid) in creators {
items.push(MenuItem::Item(
MenuItemFields::new(&name).with_on_select_action(
DropdownAction::SelectActionAndClose(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetCreatorFilter(CreatorFilter::Specific {
name,
uid,
@@ -938,46 +952,34 @@ impl AgentManagementView {
let model = AgentConversationsModel::as_ref(ctx);
let search_query = self.search_query.trim().to_lowercase();
let cards: Vec<CardData> = model
.get_tasks_and_conversations(&self.filters, ctx)
.filter(|t| {
.get_entries(&self.filters, ctx)
.into_iter()
.filter(|entry| {
if search_query.is_empty() {
return true;
}
match_indices_case_insensitive(&t.title(ctx), &search_query).is_some()
match_indices_case_insensitive(&entry.display.title, &search_query).is_some()
})
.map(|t| {
let item_id = match t {
ConversationOrTask::Task(task) => ManagementCardItemId::Task(task.task_id),
ConversationOrTask::Conversation(conversation) => {
ManagementCardItemId::Conversation(conversation.nav_data.id)
}
};
let artifacts = t.artifacts(ctx);
let copy_link_url = t.session_or_conversation_link(ctx);
let mut config = match t {
ConversationOrTask::Task(task) => ActionButtonsConfig::for_task(
task.task_id,
&t.display_status(ctx),
None, // Don't show open button in card hover
copy_link_url,
),
ConversationOrTask::Conversation(conversation) => {
ActionButtonsConfig::for_conversation(
conversation.nav_data.id,
None, // Don't show open button in card hover
copy_link_url,
.map(|entry| {
let item_id = entry.id;
let copy_link_url = entry
.capabilities
.can_copy_link
.then(|| {
AgentConversationsModel::resolve_copy_link(
AgentConversationNavigationSubject::Entry(entry.id),
ctx,
)
}
};
// Show info button in card hover for ViewDetails if feature flag enabled
})
.flatten();
let mut config = Self::action_buttons_config_for_entry(&entry, None, copy_link_url);
if FeatureFlag::AgentManagementDetailsView.is_enabled() {
config.view_details_item_id = Some(item_id.clone());
config.view_details_item_id = Some(item_id);
}
CardData {
item_id,
artifacts,
artifacts: entry.display.artifacts,
action_buttons_config: config,
}
})
@@ -1026,11 +1028,8 @@ impl AgentManagementView {
} else {
None
};
let action_buttons_view = self.create_action_buttons_view(
card.item_id.clone(),
card.action_buttons_config,
ctx,
);
let action_buttons_view =
self.create_action_buttons_view(card.item_id, card.action_buttons_config, ctx);
new_items.push(CardState {
hover_state: MouseStateHandle::default(),
@@ -1074,6 +1073,29 @@ impl AgentManagementView {
view
}
fn action_buttons_config_for_entry(
entry: &AgentConversationEntry,
open_action: Option<WorkspaceAction>,
copy_link_url: Option<String>,
) -> ActionButtonsConfig {
if let Some(task_id) = entry.identity.ambient_agent_task_id {
ActionButtonsConfig::for_task(
task_id,
&entry.display.status,
open_action,
copy_link_url,
)
} else if let Some(conversation_id) = entry.identity.local_conversation_id {
ActionButtonsConfig::for_conversation(conversation_id, open_action, copy_link_url)
} else {
ActionButtonsConfig {
open_action,
copy_link_url,
..Default::default()
}
}
}
fn handle_action_buttons_event(
&mut self,
item_id: &ManagementCardItemId,
@@ -1109,6 +1131,7 @@ impl AgentManagementView {
summarize_after_fork: false,
summarization_prompt: None,
initial_prompt: None,
initial_attachments: vec![],
destination: ForkedConversationDestination::NewTab,
});
}
@@ -1122,7 +1145,7 @@ impl AgentManagementView {
);
self.update_details_panel_for_item(item_id, ctx);
self.selected_item_id = Some(item_id.clone());
self.selected_item_id = Some(*item_id);
ctx.notify();
}
AgentDetailsButtonEvent::CopyLink { link } => {
@@ -1136,7 +1159,7 @@ impl AgentManagementView {
ctx
);
}
ManagementCardItemId::Task(task_id) => {
ManagementCardItemId::AmbientRun(task_id) => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::SessionLinkCopied {
task_id: task_id.to_string(),
@@ -1237,13 +1260,9 @@ impl AgentManagementView {
self.refresh_details_panel_if_needed(ctx);
self.get_tasks_from_model(ctx);
}
AgentConversationsModelEvent::ConversationUpdated => {
self.get_tasks_from_model(ctx);
self.refresh_details_panel_if_needed(ctx);
ctx.notify();
AgentConversationsModelEvent::ConversationUpdated { kind } => {
self.handle_conversation_updated(*kind, ctx);
}
// TaskManuallyOpened is handled by the conversation list view, not here.
AgentConversationsModelEvent::TaskManuallyOpened => {}
AgentConversationsModelEvent::ConversationArtifactsUpdated { conversation_id } => {
self.update_artifacts_for_conversation(*conversation_id, ctx);
self.refresh_details_panel_if_needed(ctx);
@@ -1253,11 +1272,48 @@ impl AgentManagementView {
/// Refresh the details panel if it's currently showing an item
fn refresh_details_panel_if_needed(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(item_id) = self.selected_item_id.clone() {
if let Some(item_id) = self.selected_item_id {
self.update_details_panel_for_item(&item_id, ctx);
}
}
/// Decide how much work a `ConversationUpdated` event requires, based on its kind and the
/// active status filter:
/// * `Restored`: the underlying status didn't change, so the visible cards don't change
/// either. Just refresh the details panel.
/// * `MetadataChanged`: rebuild the cards so metadata-derived actions update.
/// * `TitleChanged`: rebuild the cards so filtering and titles update.
/// * `StatusSet` that crosses the active status filter: rebuild the
/// card list via `get_tasks_from_model`.
/// * `StatusSet` that doesn't cross the active filter (or `All` is active):
/// just refresh the details panel re-render so the status icon picks up the new value.
fn handle_conversation_updated(
&mut self,
kind: ConversationUpdateKind,
ctx: &mut ViewContext<Self>,
) {
match kind {
ConversationUpdateKind::Restored => {}
ConversationUpdateKind::MetadataChanged => self.get_tasks_from_model(ctx),
ConversationUpdateKind::TitleChanged => self.get_tasks_from_model(ctx),
ConversationUpdateKind::StatusSet {
prev_filter,
new_filter,
} => {
if self
.filters
.status
.is_membership_crossed(prev_filter, new_filter)
{
self.get_tasks_from_model(ctx);
} else {
ctx.notify();
}
}
}
self.refresh_details_panel_if_needed(ctx);
}
/// Update the details panel with fresh data for the given item.
fn update_details_panel_for_item(
&mut self,
@@ -1265,75 +1321,28 @@ impl AgentManagementView {
ctx: &mut ViewContext<Self>,
) {
let model = AgentConversationsModel::as_ref(ctx);
let data = match item_id {
ManagementCardItemId::Task(task_id) => {
let Some(task_wrapper) = model.get_task(task_id) else {
return;
};
// Agent management view should always open in a new tab
let open_action =
task_wrapper.get_open_action(Some(RestoreConversationLayout::NewTab), ctx);
let copy_link_url = task_wrapper.session_or_conversation_link(ctx);
let Some(task) = model.get_task_data(task_id) else {
return;
};
ConversationDetailsData::from_task(&task, open_action, copy_link_url, ctx)
}
ManagementCardItemId::Conversation(conversation_id) => {
let Some(conversation) = model.get_conversation(conversation_id) else {
return;
};
// Agent management view should always open in a new tab
let open_action =
conversation.get_open_action(Some(RestoreConversationLayout::NewTab), ctx);
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
let ai_conversation = conversation
.navigation_data()
.and_then(|nav| history_model.conversation(&nav.id));
let server_conv_id = ai_conversation
.and_then(|c| c.server_conversation_token())
.map(|t| t.as_str().to_string())
.or_else(|| {
conversation
.navigation_data()
.and_then(|nav| history_model.get_conversation_metadata(&nav.id))
.and_then(|m| m.server_conversation_token.as_ref())
.map(|t| t.as_str().to_string())
});
let artifacts = ai_conversation
.map(|c| c.artifacts().to_vec())
.unwrap_or_default();
let status = Some(conversation.status(ctx));
let navigation_data = conversation.navigation_data();
let copy_link_url = conversation.session_or_conversation_link(ctx);
// Prefer server-reported harness when available; otherwise treat as a pure
// local conversation (always Warp Agent).
let harness = navigation_data
.and_then(|nav| history_model.get_server_conversation_metadata(&nav.id))
.map(|m| Harness::from(m.harness))
.or(Some(Harness::Oz));
ConversationDetailsData::from_conversation_metadata(
*conversation_id,
conversation.title(ctx),
conversation.creator_name(ctx),
conversation.created_at().with_timezone(&chrono::Local),
navigation_data.and_then(|n| n.initial_working_directory.clone()),
conversation.request_usage(ctx),
server_conv_id,
artifacts,
open_action,
status,
navigation_data.and_then(|n| n.initial_query.clone()),
copy_link_url,
harness,
)
}
let Some(entry) = model.get_entry_by_id(item_id, ctx) else {
return;
};
let open_action = AgentConversationsModel::resolve_open_action(
AgentConversationNavigationSubject::Entry(*item_id),
Some(RestoreConversationLayout::NewTab),
ctx,
);
let copy_link_url = AgentConversationsModel::resolve_copy_link(
AgentConversationNavigationSubject::Entry(*item_id),
ctx,
);
let task = entry
.identity
.ambient_agent_task_id
.and_then(|task_id| model.get_task_data(&task_id));
let data = ConversationDetailsData::from_agent_conversation_entry(
&entry,
task.as_ref(),
open_action,
copy_link_url,
);
self.details_panel.update(ctx, |p, ctx| {
p.set_conversation_details(data, ctx);
@@ -1347,19 +1356,14 @@ impl AgentManagementView {
ctx: &mut ViewContext<Self>,
) {
let model = AgentConversationsModel::as_ref(ctx);
let Some(card_data) = model.get_conversation(&conversation_id) else {
return;
};
let artifacts = card_data.artifacts(ctx);
// Find the index of the card for this conversation
let Some(index) = self
.items
.iter()
.position(|card| card.item_id == ManagementCardItemId::Conversation(conversation_id))
else {
let Some((index, entry)) = self.items.iter().enumerate().find_map(|(index, card)| {
let entry = model.get_entry_by_id(&card.item_id, ctx)?;
(entry.identity.local_conversation_id == Some(conversation_id))
.then_some((index, entry))
}) else {
return;
};
let artifacts = entry.display.artifacts;
// Update the artifact buttons for this card
if should_show_artifacts(&artifacts) {
@@ -1497,7 +1501,7 @@ impl AgentManagementView {
fn render_session_status_label(
appearance: &Appearance,
mouse_state: MouseStateHandle,
session_status: SessionStatus,
session_status: &SessionStatus,
) -> Box<dyn Element> {
let theme = appearance.theme();
let font_family = appearance.ui_font_family();
@@ -1604,21 +1608,17 @@ impl AgentManagementView {
};
let model = AgentConversationsModel::as_ref(app);
let card_data = match &card_state.item_id {
ManagementCardItemId::Task(task_id) => model.get_task(task_id),
ManagementCardItemId::Conversation(conv_id) => model.get_conversation(conv_id),
};
let Some(card_data) = card_data else {
let Some(entry) = model.get_entry_by_id(&card_state.item_id, app) else {
return Empty::new().finish();
};
self.render_card(card_state, &card_data, appearance, app)
self.render_card(card_state, &entry, appearance, app)
}
fn render_card(
&self,
card_state: &CardState,
card_data: &ConversationOrTask,
entry: &AgentConversationEntry,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
@@ -1642,10 +1642,8 @@ impl AgentManagementView {
let card_hoverable = Hoverable::new(card_state.hover_state.clone(), move |mouse_state| {
let mut card_content = Flex::column()
.with_spacing(CARD_ROW_SPACING)
.with_child(Self::render_header_row(
card_state, card_data, appearance, app,
))
.with_child(Self::render_metadata_row(card_data, appearance, app));
.with_child(Self::render_header_row(card_state, entry, appearance))
.with_child(Self::render_metadata_row(entry, appearance, app));
// Add artifacts row if there is a buttons view
if let Some(buttons_element) = artifact_buttons_element {
@@ -1706,18 +1704,12 @@ impl AgentManagementView {
})
.with_defer_events_to_children();
// Add click handler to open session if available
let item_id = card_state.item_id.clone();
let card_hoverable = if card_data
.get_open_action(Some(RestoreConversationLayout::NewTab), app)
.is_some()
{
let item_id = card_state.item_id;
let card_hoverable = if entry.capabilities.can_open {
card_hoverable
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AgentManagementViewAction::OpenSession {
item_id: item_id.clone(),
});
ctx.dispatch_typed_action(AgentManagementViewAction::OpenSession { item_id });
})
} else {
card_hoverable
@@ -1728,29 +1720,30 @@ impl AgentManagementView {
fn render_header_row(
card_state: &CardState,
card_data: &ConversationOrTask,
entry: &AgentConversationEntry,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let theme = appearance.theme();
let font_family = appearance.ui_font_family();
let font_size = appearance.ui_font_size();
let title = card_data.title(app);
let title_text = Text::new_inline(title, font_family, font_size)
let title_text = Text::new_inline(entry.display.title.clone(), font_family, font_size)
.with_color(theme.active_ui_text_color().into());
let status_icon =
render_status_element(&card_data.display_status(app), STATUS_ICON_SIZE, appearance);
// Build the time and avatar elements
let last_updated = card_data.last_updated();
let time_str = format_approx_duration_from_now_utc(last_updated);
let status_icon = render_icon_with_status(
agent_conversation_entry_icon_variant(entry),
CARD_AGENT_ICON_SIZE,
0.,
theme,
internal_colors::fg_overlay_1(theme),
);
let time_str = format_approx_duration_from_now_utc(entry.display.last_updated);
let time_text = Text::new_inline(time_str, font_family, font_size)
.with_color(theme.nonactive_ui_text_color().into());
let creator_name = card_data
.creator_name(app)
let creator_name = entry
.display
.creator
.name
.clone()
.unwrap_or_else(|| "Unknown".to_string());
let avatar = Self::render_avatar_with_tooltip(
&creator_name,
@@ -1763,12 +1756,11 @@ impl AgentManagementView {
.with_spacing(2.)
.with_child(Container::new(status_icon).with_margin_right(4.).finish())
.with_child(Expanded::new(1., title_text.finish()).finish());
let mut time_and_avatar = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(4.);
if let Some(session_status) = card_data.get_session_status() {
if let Some(session_status) = &entry.display.session_status {
time_and_avatar.add_child(Self::render_session_status_label(
appearance,
card_state.session_status_hover_state.clone(),
@@ -1792,45 +1784,56 @@ impl AgentManagementView {
}
fn render_metadata_row(
card_data: &ConversationOrTask,
entry: &AgentConversationEntry,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let theme = appearance.theme();
let font_family = appearance.ui_font_family();
let font_size = appearance.ui_font_size();
// Build metadata parts conditionally
let mut metadata_parts = Vec::new();
if let Some(source) = card_data.source() {
if let Some(source) = &entry.display.source {
metadata_parts.push(format!("Source: {}", source.display_name()));
}
if FeatureFlag::AgentHarness.is_enabled() {
if let Some(harness) = card_data.harness() {
let availability = HarnessAvailabilityModel::as_ref(app);
if availability.should_show_harness_selector() {
if let Some(harness) = entry.display.harness {
metadata_parts.push(format!(
"Harness: {}",
harness_display::display_name(harness)
availability.display_name_for(harness)
));
}
}
if let Some(run_time) = card_data.run_time() {
if let Some(executor) = &entry.display.executor {
let same_as_creator =
executor.uid.is_some() && executor.uid == entry.display.creator.uid;
if !same_as_creator {
if let Some(name) = executor.name.as_deref().or(executor.uid.as_deref()) {
let label = if executor
.principal_type
.is_some_and(|pt| pt.is_service_account())
{
"Agent"
} else {
"Executor"
};
metadata_parts.push(format!("{label}: {name}"));
}
}
}
if let Some(run_time) = &entry.display.run_time {
metadata_parts.push(format!("Run time: {run_time}"));
}
if let Some(usage) = card_data.display_request_usage(app) {
if let Some(usage) = entry.display.request_usage.map(format_credits) {
metadata_parts.push(format!("Credits used: {usage}"));
}
if let Some(tokens) = card_data.display_total_tokens(app) {
metadata_parts.push(format!("Tokens: {tokens}"));
}
let metadata_text = metadata_parts.join("");
Text::new(metadata_text, font_family, font_size)
Text::new(metadata_parts.join(""), font_family, font_size)
.with_color(theme.nonactive_ui_text_color().into())
.finish()
}
@@ -1947,7 +1950,7 @@ impl AgentManagementView {
.with_child(ChildView::new(&self.created_on_dropdown).finish())
.with_child(ChildView::new(&self.artifact_dropdown).finish());
if FeatureFlag::AgentHarness.is_enabled() {
if HarnessAvailabilityModel::as_ref(app).should_show_harness_selector() {
filters_wrap.add_child(ChildView::new(&self.harness_dropdown).finish());
}
@@ -2356,17 +2359,11 @@ impl TypedActionView for AgentManagementView {
ctx.notify();
}
AgentManagementViewAction::OpenSession { item_id } => {
let model = AgentConversationsModel::as_ref(ctx);
let card_data = match item_id {
ManagementCardItemId::Task(task_id) => model.get_task(task_id),
ManagementCardItemId::Conversation(conv_id) => model.get_conversation(conv_id),
};
let Some(card_data) = card_data else {
return;
};
let Some(action) =
card_data.get_open_action(Some(RestoreConversationLayout::NewTab), ctx)
else {
let Some(action) = AgentConversationsModel::resolve_open_action(
AgentConversationNavigationSubject::Entry(*item_id),
Some(RestoreConversationLayout::NewTab),
ctx,
) else {
return;
};
@@ -2380,7 +2377,7 @@ impl TypedActionView for AgentManagementView {
ctx
);
}
ManagementCardItemId::Task(task_id) => {
ManagementCardItemId::AmbientRun(task_id) => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::CloudRunOpened {
task_id: task_id.to_string(),
+2
View File
@@ -4,6 +4,8 @@ use anyhow::{Context, Result};
use galaxy_cli::agent::OutputFormat;
use galaxyui::{platform::TerminationMode, AppContext, SingletonEntity};
use serde::Serialize;
use galaxyui::platform::TerminationMode;
use galaxyui::{AppContext, SingletonEntity};
use crate::auth::auth_manager::{AuthManager, AuthManagerEvent};
use crate::auth::user::PrincipalType;
+12 -10
View File
@@ -1,13 +1,15 @@
//! Commands to interact with available agents via the public API.
use warp_cli::agent::ListAgentSkillsArgs;
use warp_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
use warp_graphql::queries::user_repo_auth_status::UserRepoAuthStatusEnum;
use warpui::platform::TerminationMode;
use warpui::{AppContext, ModelContext, SingletonEntity};
use crate::ai::agent_sdk::oauth_flow::poll_oauth_until_terminal;
use crate::ai::cloud_environments::GithubRepo;
use crate::server::server_api::ai::AgentListItem;
use crate::server::server_api::ai::AgentSkillItem;
use crate::server::server_api::ServerApiProvider;
use galaxy_cli::agent::ListAgentConfigsArgs;
use galaxy_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
use galaxy_graphql::queries::user_repo_auth_status::UserRepoAuthStatusEnum;
use galaxyui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
const MAX_LINE_WIDTH: usize = 90;
const MAX_AUTH_ATTEMPTS: u32 = 8;
@@ -15,8 +17,8 @@ const MAX_AUTH_ATTEMPTS: u32 = 8;
/// Singleton model that runs async work for agent CLI commands.
struct AgentConfigRunner;
/// List all available agents.
pub fn list_agents(ctx: &mut AppContext, args: ListAgentConfigsArgs) -> anyhow::Result<()> {
/// List all available agent skills.
pub fn list_skills(ctx: &mut AppContext, args: ListAgentSkillsArgs) -> anyhow::Result<()> {
let runner = ctx.add_singleton_model(|_ctx| AgentConfigRunner);
runner.update(ctx, |runner, ctx| runner.list(args.repo.clone(), ctx))
}
@@ -220,7 +222,7 @@ impl AgentConfigRunner {
println!("Fetching agent skills from your Warp environments...");
}
let list_future = async move { ai_client.list_agents(repo).await };
let list_future = async move { ai_client.list_skills(repo).await };
ctx.spawn(list_future, |_, result, ctx| match result {
Ok(agents) => {
@@ -234,9 +236,9 @@ impl AgentConfigRunner {
}
/// Print a list of agents in a card-style format.
fn print_agents_table(agents: &[AgentListItem]) {
fn print_agents_table(agents: &[AgentSkillItem]) {
if agents.is_empty() {
println!("No agents found.");
println!("No skills found.");
return;
}
+515
View File
@@ -0,0 +1,515 @@
//! Commands to manage named agents via the public API.
use std::cmp::Ordering;
use std::io::Write as _;
use anyhow::anyhow;
use comfy_table::Cell;
use serde::Serialize;
use warp_cli::agent::{
AgentCreateArgs, AgentDeleteArgs, AgentGetArgs, AgentListArgs, AgentSortByArg, AgentUpdateArgs,
OutputFormat,
};
use warp_cli::json_filter::JsonOutput;
use warp_cli::SortOrderArg;
use warpui::platform::TerminationMode;
use warpui::{AppContext, ModelContext, SingletonEntity};
use super::output::TableFormat;
use crate::server::server_api::ai::{
AgentResponse, CreateAgentRequest, SecretRef, UpdateAgentRequest,
};
use crate::server::server_api::ServerApiProvider;
/// Singleton model that runs async work for named-agent CLI commands.
struct AgentManagementRunner;
pub fn list_agents(
ctx: &mut AppContext,
output_format: OutputFormat,
args: AgentListArgs,
) -> anyhow::Result<()> {
let runner = ctx.add_singleton_model(|_ctx| AgentManagementRunner);
runner.update(ctx, |runner, ctx| runner.list(args, output_format, ctx))
}
pub fn get_agent(
ctx: &mut AppContext,
output_format: OutputFormat,
args: AgentGetArgs,
) -> anyhow::Result<()> {
let runner = ctx.add_singleton_model(|_ctx| AgentManagementRunner);
runner.update(ctx, |runner, ctx| runner.get(args, output_format, ctx))
}
pub fn create_agent(
ctx: &mut AppContext,
output_format: OutputFormat,
args: AgentCreateArgs,
) -> anyhow::Result<()> {
let runner = ctx.add_singleton_model(|_ctx| AgentManagementRunner);
runner.update(ctx, |runner, ctx| runner.create(args, output_format, ctx))
}
pub fn update_agent(
ctx: &mut AppContext,
output_format: OutputFormat,
args: AgentUpdateArgs,
) -> anyhow::Result<()> {
let runner = ctx.add_singleton_model(|_ctx| AgentManagementRunner);
runner.update(ctx, |runner, ctx| runner.update(args, output_format, ctx))
}
pub fn delete_agent(
ctx: &mut AppContext,
output_format: OutputFormat,
args: AgentDeleteArgs,
) -> anyhow::Result<()> {
let runner = ctx.add_singleton_model(|_ctx| AgentManagementRunner);
runner.update(ctx, |runner, ctx| runner.delete(args, output_format, ctx))
}
impl AgentManagementRunner {
fn spawn_command(
&self,
future: impl warpui::r#async::Spawnable<Output = anyhow::Result<()>>,
ctx: &mut ModelContext<Self>,
) {
ctx.spawn(future, |_, result, ctx| match result {
Ok(()) => {
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
});
}
fn list(
&self,
args: AgentListArgs,
output_format: OutputFormat,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let future = async move {
ensure_json_sort_is_not_requested(output_format, &args.json_output, &args)?;
if matches!(output_format, OutputFormat::Json) || args.json_output.force_json_output() {
let response = ai_client.list_agents_raw().await?;
super::output::print_raw_json(response, &args.json_output)?;
} else {
let mut agents = ai_client.list_agents().await?;
sort_agents(&mut agents, args.sort_by, args.sort_order);
print_agents(&agents, output_format)?;
}
Ok(())
};
self.spawn_command(future, ctx);
Ok(())
}
fn get(
&self,
args: AgentGetArgs,
output_format: OutputFormat,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let future = async move {
if matches!(output_format, OutputFormat::Json) || args.json_output.force_json_output() {
let response = ai_client.get_agent_raw(&args.uid).await?;
super::output::print_raw_json(response, &args.json_output)?;
} else {
let agent = ai_client.get_agent(&args.uid).await?;
print_single_agent(&agent, output_format)?;
}
Ok(())
};
self.spawn_command(future, ctx);
Ok(())
}
fn create(
&self,
args: AgentCreateArgs,
output_format: OutputFormat,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let future = async move {
let json_output = args.json_output.clone();
let request = CreateAgentRequest {
name: args.name,
description: args.description,
secrets: secret_refs(args.secrets),
skills: args.skills,
base_model: args.base_model,
environment_id: args.environment,
};
if matches!(output_format, OutputFormat::Json) || json_output.force_json_output() {
let response = ai_client.create_agent_raw(request).await?;
super::output::print_raw_json(response, &json_output)?;
} else {
let agent = ai_client.create_agent(request).await?;
print_single_agent(&agent, output_format)?;
}
Ok(())
};
self.spawn_command(future, ctx);
Ok(())
}
fn update(
&self,
args: AgentUpdateArgs,
output_format: OutputFormat,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let future = async move {
let uid = args.uid.clone();
let json_output = args.json_output.clone();
let current_agent = if args.add_secrets.is_empty()
&& args.remove_secrets.is_empty()
&& args.add_skills.is_empty()
&& args.remove_skills.is_empty()
{
None
} else {
Some(ai_client.get_agent(&uid).await?)
};
let request = UpdateAgentRequest {
name: args.name,
description: if args.remove_description {
Some(String::new())
} else {
args.description
},
secrets: if args.remove_all_secrets {
Some(vec![])
} else if args.add_secrets.is_empty() && args.remove_secrets.is_empty() {
None
} else {
let current_agent = current_agent
.as_ref()
.expect("current agent is fetched when applying secret deltas");
Some(apply_secret_deltas(
&current_agent.secrets,
args.add_secrets,
args.remove_secrets,
))
},
skills: if args.remove_all_skills {
Some(vec![])
} else if args.add_skills.is_empty() && args.remove_skills.is_empty() {
None
} else {
let current_agent = current_agent
.as_ref()
.expect("current agent is fetched when applying skill deltas");
Some(apply_string_deltas(
&current_agent.skills,
args.add_skills,
args.remove_skills,
))
},
base_model: if args.remove_base_model {
Some(String::new())
} else {
args.base_model
},
environment_id: if args.remove_environment {
Some(String::new())
} else {
args.environment
},
};
if request_is_empty(&request) {
return Err(anyhow!("No updates requested"));
}
if matches!(output_format, OutputFormat::Json) || json_output.force_json_output() {
let response = ai_client.update_agent_raw(&uid, request).await?;
super::output::print_raw_json(response, &json_output)?;
} else {
let agent = ai_client.update_agent(&uid, request).await?;
print_single_agent(&agent, output_format)?;
}
Ok(())
};
self.spawn_command(future, ctx);
Ok(())
}
fn delete(
&self,
args: AgentDeleteArgs,
output_format: OutputFormat,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let future = async move {
ai_client.delete_agent(&args.uid).await?;
print_delete_result(&args.uid, output_format)?;
Ok(())
};
self.spawn_command(future, ctx);
Ok(())
}
}
fn secret_refs(secrets: Vec<String>) -> Vec<SecretRef> {
secrets.into_iter().map(|name| SecretRef { name }).collect()
}
/// Add and remove the requested secrets, starting with `current` as a baseline.
fn apply_secret_deltas(
current: &[SecretRef],
add_secrets: Vec<String>,
remove_secrets: Vec<String>,
) -> Vec<SecretRef> {
let names = apply_string_deltas(
&current
.iter()
.map(|secret| secret.name.clone())
.collect::<Vec<_>>(),
add_secrets,
remove_secrets,
);
secret_refs(names)
}
/// Add and remove the requested values, starting with `current` as a baseline.
fn apply_string_deltas(
current: &[String],
add_values: Vec<String>,
remove_values: Vec<String>,
) -> Vec<String> {
let mut values = current
.iter()
.filter(|value| !remove_values.contains(value))
.cloned()
.collect::<Vec<_>>();
for value in add_values {
if !values.contains(&value) {
values.push(value);
}
}
values
}
fn request_is_empty(request: &UpdateAgentRequest) -> bool {
request.name.is_none()
&& request.description.is_none()
&& request.secrets.is_none()
&& request.skills.is_none()
&& request.base_model.is_none()
&& request.environment_id.is_none()
}
fn ensure_json_sort_is_not_requested(
output_format: OutputFormat,
json_output: &JsonOutput,
args: &AgentListArgs,
) -> anyhow::Result<()> {
if (matches!(output_format, OutputFormat::Json) || json_output.force_json_output())
&& (args.sort_by.is_some() || args.sort_order.is_some())
{
return Err(anyhow!(
"--sort-by and --sort-order are not supported with JSON output"
));
}
Ok(())
}
fn sort_agents(
agents: &mut [AgentResponse],
sort_by: Option<AgentSortByArg>,
sort_order: Option<SortOrderArg>,
) {
let sort_by = sort_by.unwrap_or(AgentSortByArg::Name);
let default_order = match sort_by {
AgentSortByArg::Name => SortOrderArg::Asc,
AgentSortByArg::CreatedAt => SortOrderArg::Desc,
};
let sort_order = sort_order.unwrap_or(default_order);
agents.sort_by(|left, right| {
let ordering = match sort_by {
AgentSortByArg::Name => left
.name
.to_lowercase()
.cmp(&right.name.to_lowercase())
.then_with(|| left.uid.cmp(&right.uid)),
AgentSortByArg::CreatedAt => left
.created_at
.cmp(&right.created_at)
.then_with(|| left.uid.cmp(&right.uid)),
};
match sort_order {
SortOrderArg::Asc => ordering,
SortOrderArg::Desc => match ordering {
Ordering::Less => Ordering::Greater,
Ordering::Equal => Ordering::Equal,
Ordering::Greater => Ordering::Less,
},
}
});
}
impl TableFormat for AgentResponse {
fn header() -> Vec<Cell> {
vec![
Cell::new("UID"),
Cell::new("Name"),
Cell::new("Created"),
Cell::new("Description"),
Cell::new("Secrets"),
Cell::new("Skills"),
Cell::new("Base model"),
Cell::new("Environment"),
]
}
fn row(&self) -> Vec<Cell> {
vec![
Cell::new(&self.uid),
Cell::new(&self.name),
Cell::new(self.created_at.to_rfc3339()),
Cell::new(display_optional(self.description.as_deref())),
Cell::new(display_list(
self.secrets.iter().map(|secret| secret.name.as_str()),
)),
Cell::new(display_list(self.skills.iter().map(String::as_str))),
Cell::new(display_optional(self.base_model.as_deref())),
Cell::new(display_optional(self.environment_id.as_deref())),
]
}
}
fn print_agents(agents: &[AgentResponse], output_format: OutputFormat) -> anyhow::Result<()> {
match output_format {
OutputFormat::Pretty | OutputFormat::Text => {
let (visible_agents, hidden_count) = visible_agents_and_hidden_count(agents);
match output_format {
OutputFormat::Pretty if visible_agents.is_empty() => {
println!("No agents found.");
print_skills_hint();
}
OutputFormat::Pretty => {
super::output::write_list(visible_agents, output_format, std::io::stdout())?;
print_skills_hint();
}
OutputFormat::Text => {
super::output::write_list(visible_agents, output_format, std::io::stdout())?;
}
OutputFormat::Json | OutputFormat::Ndjson => {
unreachable!("handled by outer match")
}
}
print_disabled_agents_hidden_notice(hidden_count);
}
OutputFormat::Ndjson => {
for agent in agents {
super::output::write_json_line(agent, std::io::stdout())?;
}
}
OutputFormat::Json => unreachable!("JSON output is handled by the raw API path"),
}
Ok(())
}
fn visible_agents_and_hidden_count(agents: &[AgentResponse]) -> (Vec<AgentResponse>, usize) {
let visible_agents = agents
.iter()
.filter(|agent| agent.available)
.cloned()
.collect::<Vec<_>>();
let hidden_count = agents.len() - visible_agents.len();
(visible_agents, hidden_count)
}
fn print_disabled_agents_hidden_notice(hidden_count: usize) {
if hidden_count > 0 {
eprintln!("{hidden_count} disabled agents hidden");
}
}
fn print_single_agent(agent: &AgentResponse, output_format: OutputFormat) -> anyhow::Result<()> {
match output_format {
OutputFormat::Pretty | OutputFormat::Text => {
super::output::write_list([agent.clone()], output_format, std::io::stdout())?;
}
OutputFormat::Ndjson => {
super::output::write_json_line(agent, std::io::stdout())?;
}
OutputFormat::Json => unreachable!("JSON output is handled by the raw API path"),
}
Ok(())
}
fn print_skills_hint() {
let binary_name = warp_cli::binary_name().unwrap_or_else(|| "warp".to_string());
println!("\n\nLooking for your agent skills? Use `{binary_name} agent skills` instead.");
}
#[derive(Serialize)]
struct DeleteAgentResult<'a> {
uid: &'a str,
deleted: bool,
}
fn print_delete_result(uid: &str, output_format: OutputFormat) -> anyhow::Result<()> {
match output_format {
OutputFormat::Pretty => {
println!("Deleted agent {uid}.");
}
OutputFormat::Text => {
let mut stdout = std::io::stdout();
writeln!(stdout, "{uid}")?;
}
OutputFormat::Ndjson => {
super::output::write_json_line(
&DeleteAgentResult { uid, deleted: true },
std::io::stdout(),
)?;
}
OutputFormat::Json => {
super::output::write_json(
&DeleteAgentResult { uid, deleted: true },
std::io::stdout(),
)?;
}
}
Ok(())
}
fn display_optional(value: Option<&str>) -> String {
value
.filter(|value| !value.is_empty())
.unwrap_or("-")
.to_string()
}
fn display_list<'a>(values: impl IntoIterator<Item = &'a str>) -> String {
let values = values
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
if values.is_empty() {
"-".to_string()
} else {
values.join(", ")
}
}
impl warpui::Entity for AgentManagementRunner {
type Event = ();
}
impl SingletonEntity for AgentManagementRunner {}
#[cfg(test)]
#[path = "agent_management_tests.rs"]
mod tests;
@@ -0,0 +1,170 @@
use chrono::{TimeZone as _, Utc};
use super::*;
fn agent(uid: &str, name: &str, created_at_seconds: i64) -> AgentResponse {
agent_with_available(uid, name, created_at_seconds, true)
}
fn agent_with_available(
uid: &str,
name: &str,
created_at_seconds: i64,
available: bool,
) -> AgentResponse {
AgentResponse {
uid: uid.to_string(),
name: name.to_string(),
description: None,
available,
created_at: Utc
.timestamp_opt(created_at_seconds, 0)
.single()
.expect("valid timestamp"),
secrets: vec![],
skills: vec![],
base_model: None,
environment_id: None,
}
}
#[test]
fn table_format_does_not_include_available_column() {
let header = AgentResponse::header()
.into_iter()
.map(|cell| cell.content().to_string())
.collect::<Vec<_>>();
let row = agent("1", "agent", 1).row();
assert_eq!(
header,
[
"UID",
"Name",
"Created",
"Description",
"Secrets",
"Skills",
"Base model",
"Environment",
]
);
assert_eq!(row.len(), header.len());
}
#[test]
fn visible_agents_and_hidden_count_filters_disabled_agents() {
let agents = vec![
agent_with_available("1", "enabled", 1, true),
agent_with_available("2", "disabled", 2, false),
];
let (visible_agents, hidden_count) = visible_agents_and_hidden_count(&agents);
assert_eq!(visible_agents.len(), 1);
assert_eq!(visible_agents[0].name, "enabled");
assert_eq!(hidden_count, 1);
}
#[test]
fn sort_agents_defaults_to_name_ascending() {
let mut agents = vec![agent("2", "zeta", 2), agent("1", "alpha", 1)];
sort_agents(&mut agents, None, None);
assert_eq!(agents[0].name, "alpha");
assert_eq!(agents[1].name, "zeta");
}
#[test]
fn sort_agents_defaults_created_at_to_descending() {
let mut agents = vec![agent("1", "old", 1), agent("2", "new", 2)];
sort_agents(&mut agents, Some(AgentSortByArg::CreatedAt), None);
assert_eq!(agents[0].name, "new");
assert_eq!(agents[1].name, "old");
}
#[test]
fn sort_agents_respects_explicit_sort_order_without_sort_field() {
let mut agents = vec![agent("1", "alpha", 1), agent("2", "zeta", 2)];
sort_agents(&mut agents, None, Some(SortOrderArg::Desc));
assert_eq!(agents[0].name, "zeta");
assert_eq!(agents[1].name, "alpha");
}
#[test]
fn update_request_omits_unset_fields_and_serializes_clears() {
let request = UpdateAgentRequest {
description: Some(String::new()),
secrets: Some(vec![]),
base_model: Some(String::new()),
..Default::default()
};
let json = serde_json::to_value(request).expect("request serializes");
assert_eq!(
json,
serde_json::json!({
"description": "",
"secrets": [],
"base_model": "",
})
);
}
#[test]
fn rejects_sort_for_json_output() {
let args = AgentListArgs {
sort_by: Some(AgentSortByArg::Name),
sort_order: None,
json_output: JsonOutput { filter: None },
};
let err = ensure_json_sort_is_not_requested(OutputFormat::Json, &args.json_output, &args)
.unwrap_err();
assert!(err.to_string().contains("not supported with JSON output"));
}
#[test]
fn apply_string_deltas_removes_and_appends_without_duplicates() {
let values = apply_string_deltas(
&["old".to_string(), "keep".to_string()],
vec!["new".to_string(), "keep".to_string()],
vec!["old".to_string()],
);
assert_eq!(values, ["keep", "new"]);
}
#[test]
fn apply_secret_deltas_uses_secret_names() {
let values = apply_secret_deltas(
&[
SecretRef {
name: "OLD_TOKEN".to_string(),
},
SecretRef {
name: "KEEP_TOKEN".to_string(),
},
],
vec!["NEW_TOKEN".to_string()],
vec!["OLD_TOKEN".to_string()],
);
assert_eq!(
values,
[
SecretRef {
name: "KEEP_TOKEN".to_string()
},
SecretRef {
name: "NEW_TOKEN".to_string()
},
]
);
}
+260 -77
View File
@@ -3,54 +3,50 @@ use std::io::Write as _;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Context as _};
use comfy_table::Cell;
use futures::{future, StreamExt};
use serde::Serialize;
use warp_cli::agent::{Harness, OutputFormat, Prompt, RunCloudArgs};
use warp_cli::json_filter::JsonOutput;
use warp_cli::task::{
ArtifactTypeArg, ExecutionLocationArg, ListTasksArgs, MessageCommand, MessageDeliveredArgs,
MessageListArgs, MessageReadArgs, MessageSendArgs, MessageWatchArgs, RunSortByArg,
RunSourceArg, RunStateArg, TaskGetArgs,
};
use warp_cli::{GlobalOptions, SortOrderArg};
use galaxy_core::channel::ChannelState;
use galaxy_core::features::FeatureFlag;
use warpui::platform::TerminationMode;
use warpui::r#async::{Spawnable, Timer};
use warpui::{AppContext, ModelContext, SingletonEntity};
use super::common::{parse_ambient_task_id, EnvironmentChoice, ResolveConfigurationError};
use crate::ai::agent::{extract_user_query_mode, UserQueryMode};
use crate::ai::agent_sdk::driver::attachments::{
process_attachment, MAX_ATTACHMENT_COUNT_FOR_CLOUD_QUERY,
};
use crate::ai::ambient_agents::spawn::{
spawn_task, AmbientAgentEvent, SessionJoinInfo, TASK_STATUS_POLLING_DURATION,
};
use crate::ai::ambient_agents::task::HarnessConfig;
use crate::ai::ambient_agents::AmbientAgentTaskState;
use crate::ai::ambient_agents::{AgentConfigSnapshot, AmbientAgentTask};
use crate::ai::ambient_agents::{
AgentConfigSnapshot, AmbientAgentTask, AmbientAgentTaskId, AmbientAgentTaskState,
};
use crate::ai::artifacts::Artifact;
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::CloudModel;
use crate::server::ids::{ServerId, SyncId};
use crate::server::server_api::ai::{
AIClient, AgentMessageHeader, AgentRunEvent, AgentSource, ArtifactType, ExecutionLocation,
ListAgentMessagesRequest, ReadAgentMessageResponse, RunSortBy, RunSortOrder,
SendAgentMessageRequest, SendAgentMessageResponse, SpawnAgentRequest, TaskListFilter,
};
use crate::server::server_api::ServerApi;
use crate::terminal::shared_session;
use crate::util::time_format::format_approx_duration_from_now_utc;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{
terminal::shared_session, util::time_format::format_approx_duration_from_now_utc,
ServerApiProvider,
};
use anyhow::{anyhow, Context as _};
use comfy_table::Cell;
use futures::{future, StreamExt};
use serde::Serialize;
use galaxy_cli::{
agent::{Harness, OutputFormat, Prompt, RunCloudArgs},
json_filter::JsonOutput,
task::{
ArtifactTypeArg, ExecutionLocationArg, ListTasksArgs, MessageCommand, MessageDeliveredArgs,
MessageListArgs, MessageReadArgs, MessageSendArgs, MessageWatchArgs, RunSortByArg,
RunSortOrderArg, RunSourceArg, RunStateArg, TaskGetArgs,
},
GlobalOptions,
};
use galaxy_core::channel::ChannelState;
use galaxy_core::features::FeatureFlag;
use galaxyui::r#async::Timer;
use galaxyui::{
platform::TerminationMode, r#async::Spawnable, AppContext, ModelContext, SingletonEntity,
};
use crate::ai::agent_sdk::driver::attachments::{
process_attachment, MAX_ATTACHMENT_COUNT_FOR_CLOUD_QUERY,
};
use crate::cloud_object::model::persistence::CloudModel;
use crate::server::ids::{ServerId, SyncId};
use super::common::{EnvironmentChoice, ResolveConfigurationError};
use crate::ServerApiProvider;
const MAX_LINE_WIDTH: usize = 90;
const STREAM_RETRY_BACKOFF_STEPS: &[u64] = &[1, 2, 5, 10];
@@ -185,10 +181,10 @@ fn sort_by_from_arg(arg: RunSortByArg) -> RunSortBy {
}
}
fn sort_order_from_arg(arg: RunSortOrderArg) -> RunSortOrder {
fn sort_order_from_arg(arg: SortOrderArg) -> RunSortOrder {
match arg {
RunSortOrderArg::Asc => RunSortOrder::Asc,
RunSortOrderArg::Desc => RunSortOrder::Desc,
SortOrderArg::Asc => RunSortOrder::Asc,
SortOrderArg::Desc => RunSortOrder::Desc,
}
}
@@ -266,11 +262,8 @@ impl AmbientAgentRunner {
);
return;
}
// TODO: Consider making the server's prompt field optional when skill is provided,
// rather than sending an empty string for skill-only invocations.
let prompt_string = match prompt {
Some(Prompt::PlainText(text)) => text,
let prompt = match prompt {
Some(Prompt::PlainText(text)) => Some(text),
Some(Prompt::SavedPrompt(id)) => {
// Resolve the saved prompt to pass along as the ambient agent query.
// We look up the prompt text here, rather than passing along the saved prompt ID,
@@ -294,7 +287,7 @@ impl AmbientAgentRunner {
match workflow {
Some(cloud_workflow) => match cloud_workflow.model().data.prompt() {
Some(prompt_text) => prompt_text.to_string(),
Some(prompt_text) => Some(prompt_text.to_string()),
None => {
super::report_fatal_error(
anyhow::anyhow!("'{id}' is not a saved prompt"),
@@ -312,8 +305,7 @@ impl AmbientAgentRunner {
}
}
}
// Skill-only invocation: use empty prompt, skill provides instructions
None => String::new(),
None => None,
};
let loaded_file = match args.config_file.file.as_deref() {
@@ -327,6 +319,23 @@ impl AmbientAgentRunner {
None => None,
};
// The `--runner` CLI flag is gated in `run_agent`, but a config file
// can also set `runner_id`, which would otherwise bypass the gate.
if loaded_file
.as_ref()
.and_then(|f| f.file.runner_id.as_ref())
.is_some()
&& !FeatureFlag::CloudRunners.is_enabled()
{
super::report_fatal_error(
anyhow::anyhow!(
"`runner_id` is set in the config file but runner support is not enabled"
),
ctx,
);
return;
}
// Validate and process attachments early, before environment selection
// This ensures users don't have to go through env selection if attachment validation fails
if args.attachment_paths.len() > MAX_ATTACHMENT_COUNT_FOR_CLOUD_QUERY {
@@ -416,10 +425,13 @@ impl AmbientAgentRunner {
let harness_override = (args.harness != Harness::Oz).then_some(HarnessConfig {
harness_type: args.harness,
model_id: None,
reasoning_level: None,
});
let harness_auth_secrets = args.claude_auth_secret.clone().map(|name| {
crate::ai::ambient_agents::task::HarnessAuthSecretsConfig {
claude_auth_secret_name: Some(name),
codex_auth_secret_name: None,
}
});
@@ -428,6 +440,7 @@ impl AmbientAgentRunner {
AgentConfigSnapshot {
name: args.name,
environment_id,
runner_id: args.runner,
model_id: args.model.model.clone(),
base_prompt: None,
mcp_servers: cli_mcp_servers,
@@ -471,8 +484,16 @@ impl AmbientAgentRunner {
None
};
let (prompt, mode) = match prompt {
Some(prompt) => {
let (prompt, mode) = extract_user_query_mode(prompt);
(Some(prompt), mode)
}
None => (None, UserQueryMode::Normal),
};
let request = SpawnAgentRequest {
prompt: prompt_string,
prompt,
mode,
config,
title: None,
team: match (args.scope.team, args.scope.personal) {
@@ -480,12 +501,17 @@ impl AmbientAgentRunner {
(_, true) => Some(false),
_ => None,
},
agent_identity_uid: args.agent_uid,
skill,
attachments,
interactive: None,
parent_run_id: None,
runtime_skills: vec![],
referenced_attachments: vec![],
conversation_id: args.conversation,
initial_snapshot_token: None,
snapshot_disabled: None,
orchestration_handoff: None,
};
let should_open = args.open;
@@ -640,17 +666,42 @@ impl AmbientAgentRunner {
output_format: OutputFormat,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let provider = ServerApiProvider::as_ref(ctx);
let ai_client = provider.get_ai_client();
let server_api = provider.get();
let scoped_task_id = task_id_for_message_send(&args.sender_run_id)?;
let future = async move {
let response = ai_client
.send_agent_message(SendAgentMessageRequest {
to: args.to,
subject: args.subject,
body: args.body,
sender_run_id: args.sender_run_id,
})
.await?;
let request = SendAgentMessageRequest {
to: args.to,
subject: args.subject,
body: args.body,
sender_run_id: args.sender_run_id,
};
let log_context = SendAgentMessageLogContext::new(&request, scoped_task_id.as_ref());
log_context.log_start();
let send_message = async move {
match scoped_task_id {
Some(task_id) => {
server_api
.send_agent_message_for_task(&task_id, request)
.await
}
None => ai_client.send_agent_message(request).await,
}
};
let response = match send_message.await {
Ok(response) => {
log_context.log_success(&response);
response
}
Err(err) => {
let err = err.context(log_context.error_context());
log_context.log_error(&err);
eprintln!("{err:#}");
return Err(err);
}
};
print_send_message_response(&response, output_format)?;
Ok(())
};
@@ -658,25 +709,31 @@ impl AmbientAgentRunner {
Ok(())
}
fn list_messages(
&self,
args: MessageListArgs,
output_format: OutputFormat,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let provider = ServerApiProvider::as_ref(ctx);
let ai_client = provider.get_ai_client();
let server_api = provider.get();
let future = async move {
let messages = ai_client
.list_agent_messages(
&args.run_id,
ListAgentMessagesRequest {
unread_only: args.unread,
since: args.since,
limit: args.limit,
},
)
.await?;
let request = ListAgentMessagesRequest {
unread_only: args.unread,
since: args.since,
limit: args.limit,
};
let messages = match task_id_from_run_id(&args.run_id) {
Some(task_id) => {
server_api
.list_agent_messages_for_task(&task_id, &args.run_id, request)
.await?
}
None => ai_client.list_agent_messages(&args.run_id, request).await?,
};
super::output::print_list(messages, output_format);
Ok(())
};
@@ -708,10 +765,20 @@ impl AmbientAgentRunner {
output_format: OutputFormat,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let provider = ServerApiProvider::as_ref(ctx);
let ai_client = provider.get_ai_client();
let server_api = provider.get();
let scoped_task_id = task_id_from_oz_run_id_env()?;
let future = async move {
let message = ai_client.read_agent_message(&args.message_id).await?;
let message = match scoped_task_id {
Some(task_id) => {
server_api
.read_agent_message_for_task(&task_id, &args.message_id)
.await?
}
None => ai_client.read_agent_message(&args.message_id).await?,
};
print_read_message_response(&message, output_format)?;
Ok(())
};
@@ -726,10 +793,20 @@ impl AmbientAgentRunner {
output_format: OutputFormat,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let provider = ServerApiProvider::as_ref(ctx);
let ai_client = provider.get_ai_client();
let server_api = provider.get();
let scoped_task_id = task_id_from_oz_run_id_env()?;
let future = async move {
ai_client.mark_message_delivered(&args.message_id).await?;
match scoped_task_id {
Some(task_id) => {
server_api
.mark_message_delivered_for_task(&task_id, &args.message_id)
.await?
}
None => ai_client.mark_message_delivered(&args.message_id).await?,
}
print_mark_message_delivered_result(&args.message_id, output_format)?;
Ok(())
};
@@ -790,6 +867,10 @@ impl AmbientAgentRunner {
table.add_row(vec![title_cell]);
}
if let Some(executor) = task.executor_display_name() {
table.add_row(vec![format!("Executed as: {executor}")]);
}
// Agent config snapshot (if available)
if let Some(config) = task.agent_config_snapshot.as_ref() {
let config_str =
@@ -933,6 +1014,91 @@ fn write_stream_record<T: Serialize>(record: &T) -> anyhow::Result<()> {
stdout.flush().context("unable to flush stdout")?;
Ok(())
}
fn task_id_from_run_id(run_id: &str) -> Option<AmbientAgentTaskId> {
run_id.parse().ok()
}
fn task_id_from_oz_run_id_env() -> anyhow::Result<Option<AmbientAgentTaskId>> {
match std::env::var(warp_cli::OZ_RUN_ID_ENV) {
Ok(run_id) => parse_ambient_task_id(&run_id, "Invalid OZ_RUN_ID").map(Some),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => Err(anyhow!(
"{} is set but is not valid Unicode",
warp_cli::OZ_RUN_ID_ENV
)),
}
}
fn task_id_for_message_send(sender_run_id: &str) -> anyhow::Result<Option<AmbientAgentTaskId>> {
match task_id_from_run_id(sender_run_id) {
Some(task_id) => Ok(Some(task_id)),
None => task_id_from_oz_run_id_env(),
}
}
#[derive(Debug, Clone)]
struct SendAgentMessageLogContext {
sender_run_id: String,
task_id: Option<String>,
target_agent_ids: Vec<String>,
subject: String,
body_len: usize,
}
impl SendAgentMessageLogContext {
fn new(request: &SendAgentMessageRequest, task_id: Option<&AmbientAgentTaskId>) -> Self {
Self {
sender_run_id: request.sender_run_id.clone(),
task_id: task_id.map(|task_id| task_id.to_string()),
target_agent_ids: request.to.clone(),
subject: request.subject.clone(),
body_len: request.body.chars().count(),
}
}
fn error_context(&self) -> String {
format!(
"Failed to send agent message (sender_run_id={:?}, task_id={:?}, target_agent_ids={:?})",
self.sender_run_id, self.task_id, self.target_agent_ids
)
}
fn log_start(&self) {
log::info!(
"Sending ambient agent message: sender_run_id={:?} task_id={:?} target_agent_ids={:?} subject={:?} body_len={}",
self.sender_run_id,
self.task_id,
self.target_agent_ids,
self.subject,
self.body_len
);
}
fn log_success(&self, response: &SendAgentMessageResponse) {
log::info!(
"Sent ambient agent message: sender_run_id={:?} task_id={:?} target_agent_ids={:?} subject={:?} body_len={} message_ids={:?}",
self.sender_run_id,
self.task_id,
self.target_agent_ids,
self.subject,
self.body_len,
response.message_ids
);
}
fn log_error(&self, err: &anyhow::Error) {
log::warn!(
"Failed to send ambient agent message: sender_run_id={:?} task_id={:?} target_agent_ids={:?} subject={:?} body_len={} error={err:#}",
self.sender_run_id,
self.task_id,
self.target_agent_ids,
self.subject,
self.body_len
);
}
}
async fn watch_messages_forever(
server_api: Arc<ServerApi>,
ai_client: Arc<dyn AIClient>,
@@ -940,15 +1106,25 @@ async fn watch_messages_forever(
) -> anyhow::Result<()> {
let run_id = args.run_id;
let watched_run_ids = vec![run_id.clone()];
let scoped_task_id = task_id_from_run_id(&run_id);
let mut last_seen_sequence = args.since_sequence;
let mut initial_connect = true;
let mut failures = 0usize;
loop {
let mut stream = match server_api
.stream_agent_events(&watched_run_ids, last_seen_sequence)
.await
{
let stream_result = match scoped_task_id.as_ref() {
Some(task_id) => {
server_api
.stream_agent_events_for_task(task_id, &watched_run_ids, last_seen_sequence)
.await
}
None => {
server_api
.stream_agent_events(&watched_run_ids, last_seen_sequence)
.await
}
};
let mut stream = match stream_result {
Ok(stream) => {
if !initial_connect {
eprintln!(
@@ -1004,8 +1180,15 @@ async fn watch_messages_forever(
last_seen_sequence = event.sequence;
continue;
};
let message = match ai_client.read_agent_message(&message_id).await {
let message_result = match scoped_task_id.as_ref() {
Some(task_id) => {
server_api
.read_agent_message_for_task(task_id, &message_id)
.await
}
None => ai_client.read_agent_message(&message_id).await,
};
let message = match message_result {
Ok(message) => message,
Err(err) => {
failures += 1;
+53 -7
View File
@@ -1,17 +1,17 @@
//! Unit tests for `filter_from_args`. Verifies the clap enums are faithfully translated into
//! `TaskListFilter` without dropping any fields.
//! Unit tests for ambient agent CLI argument mapping and message helpers.
use chrono::{TimeZone, Utc};
use galaxy_cli::json_filter::JsonOutput;
use galaxy_cli::task::{
ArtifactTypeArg, ExecutionLocationArg, ListTasksArgs, RunSortByArg, RunSortOrderArg,
RunSourceArg, RunStateArg,
ArtifactTypeArg, ExecutionLocationArg, ListTasksArgs, RunSortByArg, RunSourceArg, RunStateArg,
};
use warp_cli::SortOrderArg;
use super::*;
use crate::server::server_api::ai::{ArtifactType, ExecutionLocation, RunSortBy, RunSortOrder};
const TASK_ID: &str = "00000000-0000-0000-0000-000000000001";
const OTHER_TASK_ID: &str = "00000000-0000-0000-0000-000000000002";
/// A `ListTasksArgs` whose fields are all at their defaults.
fn empty_args() -> ListTasksArgs {
ListTasksArgs {
@@ -138,7 +138,7 @@ fn every_field_maps_through() {
updated_after: Some(updated_after),
query: Some("oz run".to_string()),
sort_by: Some(RunSortByArg::CreatedAt),
sort_order: Some(RunSortOrderArg::Asc),
sort_order: Some(SortOrderArg::Asc),
cursor: Some("abcd==".to_string()),
json_output: JsonOutput::default(),
};
@@ -167,3 +167,49 @@ fn every_field_maps_through() {
assert_eq!(filter.sort_order, Some(RunSortOrder::Asc));
assert_eq!(filter.cursor.as_deref(), Some("abcd=="));
}
#[test]
fn task_id_from_run_id_accepts_task_uuid() {
let task_id = task_id_from_run_id(TASK_ID).expect("valid task id");
assert_eq!(task_id.to_string(), TASK_ID);
}
#[test]
fn task_id_from_run_id_ignores_non_task_ids() {
assert!(task_id_from_run_id("local-child-run").is_none());
}
#[test]
#[serial_test::serial]
fn task_id_for_message_send_prefers_sender_run_id() {
std::env::set_var(warp_cli::OZ_RUN_ID_ENV, OTHER_TASK_ID);
let task_id = task_id_for_message_send(TASK_ID)
.expect("valid task id")
.expect("task id");
std::env::remove_var(warp_cli::OZ_RUN_ID_ENV);
assert_eq!(task_id.to_string(), TASK_ID);
}
#[test]
#[serial_test::serial]
fn task_id_for_message_send_falls_back_to_oz_run_id() {
std::env::set_var(warp_cli::OZ_RUN_ID_ENV, TASK_ID);
let task_id = task_id_for_message_send("local-child-run")
.expect("valid env task id")
.expect("task id");
std::env::remove_var(warp_cli::OZ_RUN_ID_ENV);
assert_eq!(task_id.to_string(), TASK_ID);
}
#[test]
#[serial_test::serial]
fn task_id_from_oz_run_id_env_rejects_invalid_value() {
std::env::set_var(warp_cli::OZ_RUN_ID_ENV, "not-a-task-id");
let err = task_id_from_oz_run_id_env().expect_err("invalid task id");
std::env::remove_var(warp_cli::OZ_RUN_ID_ENV);
assert!(err.to_string().contains("Invalid OZ_RUN_ID"));
}
+485
View File
@@ -0,0 +1,485 @@
use std::cmp::Reverse;
use std::fmt;
use std::io::{self, IsTerminal as _};
use anyhow::{anyhow, Result};
use chrono::{DateTime, Utc};
use comfy_table::Cell;
use inquire::{Confirm, InquireError, Select};
use serde::Serialize;
use warp_cli::agent::OutputFormat;
use warp_cli::api_key::{
ApiKeyCommand, ApiKeyExpirationArgs, ApiKeySortByArg, CreateApiKeyArgs, ExpireApiKeyArgs,
ListApiKeysArgs,
};
use warp_cli::{GlobalOptions, SortOrderArg};
use warp_graphql::mutations::expire_api_key::ExpireApiKeyResult;
use warp_graphql::mutations::generate_api_key::GenerateApiKeyResult;
use warp_graphql::queries::api_keys::ApiKeyProperties;
use warp_graphql::scalars::Time;
use warpui::platform::TerminationMode;
use warpui::{AppContext, ModelContext, SingletonEntity};
use super::output::{self, TableFormat};
use crate::server::ids::ApiKeyUid;
use crate::util::time_format::format_approx_duration_from_now_utc;
use crate::ServerApiProvider;
/// Run API key-related commands.
pub fn run(
ctx: &mut AppContext,
global_options: GlobalOptions,
command: ApiKeyCommand,
) -> Result<()> {
let runner = ctx.add_singleton_model(|_ctx| ApiKeyCommandRunner);
match command {
ApiKeyCommand::List(args) => {
runner.update(ctx, |runner, ctx| {
runner.list(global_options.output_format, args, ctx)
});
Ok(())
}
ApiKeyCommand::Create(args) => {
runner.update(ctx, |runner, ctx| {
runner.create(global_options.output_format, args, ctx)
});
Ok(())
}
ApiKeyCommand::Expire(args) => {
runner.update(ctx, |runner, ctx| {
runner.expire(global_options.output_format, args, ctx)
});
Ok(())
}
}
}
struct ApiKeyCommandRunner;
impl ApiKeyCommandRunner {
fn list(
&self,
output_format: OutputFormat,
args: ListApiKeysArgs,
ctx: &mut ModelContext<Self>,
) {
let auth_client = ServerApiProvider::as_ref(ctx).get_auth_client();
ctx.spawn(
async move {
let mut keys: Vec<_> = auth_client
.list_api_keys()
.await?
.into_iter()
.map(ApiKeyInfo::from)
.collect();
sort_api_keys(&mut keys, args.sort_by, args.sort_order);
if args.json_output.force_json_output() {
output::print_raw_json(serde_json::to_value(&keys)?, &args.json_output)?;
} else {
output::print_list(keys, output_format);
}
Ok(())
},
|_, result: Result<()>, ctx| finish_command(result, ctx),
);
}
fn create(
&self,
output_format: OutputFormat,
args: CreateApiKeyArgs,
ctx: &mut ModelContext<Self>,
) {
let auth_client = ServerApiProvider::as_ref(ctx).get_auth_client();
ctx.spawn(
async move {
let json_output = args.json_output;
let expires_at = expires_at_from_args(args.expiration)?;
let agent_uid = args.agent_uid.map(cynic::Id::new);
let result = auth_client
.create_api_key(args.name, None, agent_uid, expires_at)
.await?;
let result = match result {
GenerateApiKeyResult::GenerateApiKeyOutput(output) => CreatedApiKeyInfo {
raw_api_key: output.raw_api_key,
api_key: ApiKeyInfo::from(output.api_key),
},
GenerateApiKeyResult::UserFacingError(e) => {
return Err(anyhow!(
warp_graphql::client::get_user_facing_error_message(e)
));
}
GenerateApiKeyResult::Unknown => {
return Err(anyhow!("failed to create API key"))
}
};
print_created_api_key(result, output_format, json_output)?;
Ok(())
},
|_, result: Result<()>, ctx| finish_command(result, ctx),
);
}
fn expire(
&self,
output_format: OutputFormat,
args: ExpireApiKeyArgs,
ctx: &mut ModelContext<Self>,
) {
let key_identifier = args.key_uid;
let force = args.force;
let json_output = args.json_output;
let auth_client = ServerApiProvider::as_ref(ctx).get_auth_client();
ctx.spawn(
async move {
let keys = auth_client
.list_api_keys()
.await?
.into_iter()
.map(ApiKeyInfo::from)
.collect();
Ok(keys)
},
move |_, result: Result<Vec<ApiKeyInfo>>, ctx| {
let keys = match result {
Ok(keys) => keys,
Err(err) => {
super::report_fatal_error(err, ctx);
return;
}
};
let key = match resolve_api_key_identifier(&keys, &key_identifier) {
Ok(Some(key)) => key,
Ok(None) => {
ctx.terminate_app(TerminationMode::ForceTerminate, None);
return;
}
Err(err) => {
super::report_fatal_error(err, ctx);
return;
}
};
if !force {
if !io::stdin().is_terminal() {
super::report_fatal_error(
anyhow!(
"Refusing to expire API key without confirmation in non-interactive mode (use --force to bypass)"
),
ctx,
);
return;
}
let prompt = format!("Expire API key '{key}'?");
let should_expire = match Confirm::new(&prompt)
.with_default(false)
.with_help_message("This action takes effect immediately")
.prompt()
{
Ok(should_expire) => should_expire,
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
ctx.terminate_app(TerminationMode::ForceTerminate, None);
return;
}
Err(err) => {
super::report_fatal_error(err.into(), ctx);
return;
}
};
if !should_expire {
println!("Expiration cancelled");
ctx.terminate_app(TerminationMode::ForceTerminate, None);
return;
}
}
let uid = ApiKeyUid::from(key.uid);
let auth_client = ServerApiProvider::as_ref(ctx).get_auth_client();
ctx.spawn(
async move {
let result = auth_client.expire_api_key(&uid).await?;
let expired = match result {
ExpireApiKeyResult::ExpireApiKeyOutput(output) => output.success,
ExpireApiKeyResult::UserFacingError(e) => {
return Err(anyhow!(
warp_graphql::client::get_user_facing_error_message(e)
));
}
ExpireApiKeyResult::Unknown => {
return Err(anyhow!("failed to expire API key"))
}
};
print_expire_api_key_result(
uid.to_string(),
expired,
output_format,
json_output,
)?;
Ok(())
},
|_, result: Result<()>, ctx| finish_command(result, ctx),
);
},
);
}
}
impl warpui::Entity for ApiKeyCommandRunner {
type Event = ();
}
impl SingletonEntity for ApiKeyCommandRunner {}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
struct ApiKeyInfo {
uid: String,
name: String,
key_suffix: String,
scope: String,
created_at: DateTime<Utc>,
last_used_at: Option<DateTime<Utc>>,
expires_at: Option<DateTime<Utc>>,
}
impl From<ApiKeyProperties> for ApiKeyInfo {
fn from(key: ApiKeyProperties) -> Self {
Self {
uid: key.uid.into_inner(),
name: key.name,
key_suffix: key.key_suffix,
scope: key.owner_type.to_string(),
created_at: key.created_at.utc(),
last_used_at: key.last_used_at.map(|t| t.utc()),
expires_at: key.expires_at.map(|t| t.utc()),
}
}
}
impl fmt::Display for ApiKeyInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = &self.name;
let uid = &self.uid;
let created_at = self.created_at.format("%Y-%m-%d %H:%M:%S UTC");
write!(f, "{name} ({uid}, created {created_at})")
}
}
impl TableFormat for ApiKeyInfo {
fn header() -> Vec<Cell> {
vec![
Cell::new("UID"),
Cell::new("Name"),
Cell::new("Key"),
Cell::new("Scope"),
Cell::new("Created"),
Cell::new("Last Used"),
Cell::new("Expires At"),
]
}
fn row(&self) -> Vec<Cell> {
vec![
Cell::new(&self.uid),
Cell::new(&self.name),
Cell::new(format!("wk-**{}", self.key_suffix)),
Cell::new(&self.scope),
Cell::new(format_approx_duration_from_now_utc(self.created_at)),
Cell::new(
self.last_used_at
.map(format_approx_duration_from_now_utc)
.unwrap_or_else(|| "Never".to_string()),
),
Cell::new(
self.expires_at
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
.unwrap_or_else(|| "Never".to_string()),
),
]
}
}
fn resolve_api_key_identifier(
keys: &[ApiKeyInfo],
key_identifier: &str,
) -> Result<Option<ApiKeyInfo>> {
if let Some(key) = keys.iter().find(|key| key.uid == key_identifier) {
return Ok(Some(key.clone()));
}
let mut matches = keys
.iter()
.filter(|key| key.name == key_identifier)
.cloned()
.collect::<Vec<_>>();
matches.sort_by_key(|key| Reverse(key.created_at));
if matches.is_empty() {
return Err(anyhow!("API key '{key_identifier}' not found"));
} else if matches.len() == 1 {
return Ok(Some(matches[0].clone()));
}
if io::stdin().is_terminal() {
return match Select::new(
&format!("Multiple API keys match '{key_identifier}'. Select a key to expire:"),
matches,
)
.prompt()
{
Ok(key) => Ok(Some(key)),
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None),
Err(err) => Err(err.into()),
};
}
println!("Multiple API keys match '{key_identifier}':");
for key in matches {
println!(" {key}");
}
Err(anyhow!(
"Multiple API keys match '{key_identifier}'; specify the key by UID"
))
}
#[derive(Debug, Clone, Serialize)]
struct CreatedApiKeyInfo {
raw_api_key: String,
api_key: ApiKeyInfo,
}
#[derive(Debug, Clone, Serialize)]
struct ExpiredApiKeyInfo {
key_uid: String,
expired: bool,
}
fn sort_api_keys(
keys: &mut [ApiKeyInfo],
sort_by: Option<ApiKeySortByArg>,
sort_order: Option<SortOrderArg>,
) {
let Some(sort_by) = sort_by else {
return;
};
let descending = matches!(sort_order, Some(SortOrderArg::Desc));
match sort_by {
ApiKeySortByArg::Name => {
if descending {
keys.sort_by_key(|k| Reverse(k.name.to_lowercase()));
} else {
keys.sort_by_key(|k| k.name.to_lowercase());
}
}
ApiKeySortByArg::CreatedAt => {
if descending {
keys.sort_by_key(|k| Reverse(k.created_at));
} else {
keys.sort_by_key(|k| k.created_at);
}
}
ApiKeySortByArg::LastUsedAt => {
if descending {
keys.sort_by_key(|k| Reverse(k.last_used_at));
} else {
keys.sort_by_key(|k| k.last_used_at);
}
}
ApiKeySortByArg::ExpiresAt => {
if descending {
keys.sort_by_key(|k| Reverse(k.expires_at));
} else {
keys.sort_by_key(|k| k.expires_at);
}
}
ApiKeySortByArg::Scope => {
if descending {
keys.sort_by_key(|k| Reverse(k.scope.clone()));
} else {
keys.sort_by_key(|k| k.scope.clone());
}
}
}
}
fn expires_at_from_args(args: ApiKeyExpirationArgs) -> Result<Option<Time>> {
if args.no_expiration {
return Ok(None);
}
if let Some(expires_at) = args.expires_at {
return Ok(Some(Time::from(expires_at)));
}
if let Some(expires_in) = args.expires_in {
let duration = chrono::Duration::from_std(expires_in.into())
.map_err(|_| anyhow!("expiration duration is too large"))?;
return Ok(Some(Time::from(Utc::now() + duration)));
}
Err(anyhow!("expiration behavior is required"))
}
fn print_created_api_key(
result: CreatedApiKeyInfo,
output_format: OutputFormat,
json_output: warp_cli::json_filter::JsonOutput,
) -> Result<()> {
if json_output.force_json_output() {
output::print_raw_json(serde_json::to_value(&result)?, &json_output)?;
return Ok(());
}
match output_format {
OutputFormat::Json => output::write_json(&result, std::io::stdout())?,
OutputFormat::Ndjson => output::write_json_line(&result, std::io::stdout())?,
OutputFormat::Pretty | OutputFormat::Text => {
println!("API key '{}' created.", result.api_key.name);
println!("UID: {}", result.api_key.uid);
println!("Raw API key: {}", result.raw_api_key);
println!("This secret key is shown only once. Store it securely.");
}
}
Ok(())
}
fn print_expire_api_key_result(
key_uid: String,
expired: bool,
output_format: OutputFormat,
json_output: warp_cli::json_filter::JsonOutput,
) -> Result<()> {
let result = ExpiredApiKeyInfo { key_uid, expired };
if json_output.force_json_output() {
output::print_raw_json(serde_json::to_value(&result)?, &json_output)?;
return Ok(());
}
match output_format {
OutputFormat::Json => output::write_json(&result, std::io::stdout())?,
OutputFormat::Ndjson => output::write_json_line(&result, std::io::stdout())?,
OutputFormat::Pretty | OutputFormat::Text => {
if expired {
println!("API key '{}' expired.", result.key_uid);
} else {
println!("API key '{}' was not expired.", result.key_uid);
}
}
}
Ok(())
}
fn finish_command(result: Result<()>, ctx: &mut ModelContext<ApiKeyCommandRunner>) {
match result {
Ok(()) => ctx.terminate_app(TerminationMode::ForceTerminate, None),
Err(err) => super::report_fatal_error(err, ctx),
}
}
#[cfg(test)]
#[path = "api_key_tests.rs"]
mod tests;
+115
View File
@@ -0,0 +1,115 @@
use super::*;
fn key(name: &str, scope: &str, created_at: DateTime<Utc>) -> ApiKeyInfo {
key_with_uid(name, name, scope, created_at)
}
fn key_with_uid(uid: &str, name: &str, scope: &str, created_at: DateTime<Utc>) -> ApiKeyInfo {
ApiKeyInfo {
uid: uid.to_string(),
name: name.to_string(),
key_suffix: "abcd".to_string(),
scope: scope.to_string(),
created_at,
last_used_at: None,
expires_at: None,
}
}
#[test]
fn sort_api_keys_sorts_by_name_ascending() {
let created_at = Utc::now();
let mut keys = vec![
key("beta", "Team", created_at),
key("alpha", "Personal", created_at),
];
sort_api_keys(
&mut keys,
Some(ApiKeySortByArg::Name),
Some(SortOrderArg::Asc),
);
assert_eq!(keys[0].name, "alpha");
assert_eq!(keys[1].name, "beta");
}
#[test]
fn sort_api_keys_sorts_by_created_at_descending() {
let older = Utc::now() - chrono::Duration::days(1);
let newer = Utc::now();
let mut keys = vec![key("older", "Team", older), key("newer", "Personal", newer)];
sort_api_keys(
&mut keys,
Some(ApiKeySortByArg::CreatedAt),
Some(SortOrderArg::Desc),
);
assert_eq!(keys[0].name, "newer");
assert_eq!(keys[1].name, "older");
}
#[test]
fn resolve_api_key_identifier_prefers_uid_match() {
let created_at = Utc::now();
let keys = vec![
key_with_uid("target", "other-name", "Team", created_at),
key_with_uid("other-uid", "target", "Team", created_at),
];
assert_eq!(
resolve_api_key_identifier(&keys, "target")
.unwrap()
.unwrap(),
keys[0].clone()
);
}
#[test]
fn resolve_api_key_identifier_falls_back_to_name_match() {
let created_at = Utc::now();
let keys = vec![key_with_uid("uid-1", "deploy-key", "Team", created_at)];
assert_eq!(
resolve_api_key_identifier(&keys, "deploy-key")
.unwrap()
.unwrap(),
keys[0].clone()
);
}
#[test]
fn resolve_api_key_identifier_errors_for_ambiguous_name_matches() {
let created_at = Utc::now();
let keys = vec![
key_with_uid("uid-1", "deploy-key", "Team", created_at),
key_with_uid("uid-2", "deploy-key", "Personal", created_at),
];
let err = resolve_api_key_identifier(&keys, "deploy-key").unwrap_err();
assert_eq!(
err.to_string(),
"Multiple API keys match 'deploy-key'; specify the key by UID"
);
}
#[test]
fn resolve_api_key_identifier_errors_when_not_found() {
let created_at = Utc::now();
let keys = vec![key_with_uid("uid-1", "deploy-key", "Team", created_at)];
let err = resolve_api_key_identifier(&keys, "missing-key").unwrap_err();
assert_eq!(err.to_string(), "API key 'missing-key' not found");
}
#[test]
fn api_key_display_includes_creation_date() {
let created_at = "2026-01-02T03:04:05Z".parse().unwrap();
let key = key_with_uid("uid-1", "deploy-key", "Team", created_at);
assert_eq!(
key.to_string(),
"deploy-key (uid-1, created 2026-01-02 03:04:05 UTC)"
);
}
+7 -7
View File
@@ -1,4 +1,5 @@
use std::{path::PathBuf, sync::Arc};
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result};
use galaxy_cli::agent::OutputFormat;
@@ -6,19 +7,18 @@ use galaxy_cli::artifact::{
ArtifactCommand, DownloadArtifactArgs, GetArtifactArgs, UploadArtifactArgs,
};
use galaxy_cli::GlobalOptions;
use galaxyui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
use serde::Serialize;
use galaxyui::platform::TerminationMode;
use galaxyui::{AppContext, ModelContext, SingletonEntity};
use super::artifact_upload::{
CompletedFileArtifactUpload, FileArtifactUploadRequest, FileArtifactUploader,
};
use crate::ai::artifact_download::{download_artifact_bytes, download_destination};
#[cfg(test)]
use crate::server::server_api::ai::FileArtifactRecord;
use crate::server::server_api::ai::{AIClient, ArtifactDownloadResponse};
use crate::server::server_api::{ServerApi, ServerApiProvider};
use super::artifact_upload::{
CompletedFileArtifactUpload, FileArtifactUploadRequest, FileArtifactUploader,
};
/// Run artifact-related commands.
pub fn run(
ctx: &mut AppContext,
+1
View File
@@ -1,6 +1,7 @@
use galaxy_cli::agent::OutputFormat;
use std::path::PathBuf;
use super::*;
fn sample_completed_upload() -> CompletedFileArtifactUpload {
+3 -10
View File
@@ -7,7 +7,6 @@ use std::sync::Arc;
use anyhow::{anyhow, bail, Context, Result};
use blocking::unblock;
use galaxy_cli::artifact::UploadArtifactArgs;
use mime_guess::from_path;
use super::common::parse_ambient_task_id;
use crate::ai::agent::api::ServerConversationToken;
@@ -17,10 +16,11 @@ use crate::server::server_api::ai::{
AIClient, CreateFileArtifactUploadRequest, CreateFileArtifactUploadResponse,
FileArtifactRecord, FileArtifactUploadTargetInfo,
};
use crate::server::server_api::harness_support::FileUploadBody;
use crate::server::server_api::presigned_upload::upload_file_to_target;
use crate::server::server_api::ServerApi;
use crate::util::image::{infer_mime_type, MIME_SNIFF_BYTES};
const MIME_SNIFF_BYTES: usize = 8 * 1024;
const OZ_RUN_ID_ENV_VAR: &str = "OZ_RUN_ID";
#[derive(Debug, Clone, Eq, PartialEq)]
@@ -165,8 +165,7 @@ impl FileArtifactUploader {
upload_file_to_target(
self.server_api.http_client(),
target,
&artifact.path,
artifact.file_size,
FileUploadBody::new(artifact.path.clone()),
)
.await
}
@@ -234,12 +233,6 @@ fn normalize_artifact_filepath(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn infer_mime_type(path: &Path, file_bytes: &[u8]) -> String {
infer::get(file_bytes)
.map(|kind| kind.mime_type().to_string())
.unwrap_or_else(|| from_path(path).first_or_octet_stream().to_string())
}
fn file_size_and_prefix_for_path(path: &Path, max_bytes: usize) -> Result<(u64, Vec<u8>)> {
let mut file = File::open(path)
.with_context(|| format!("Failed to open artifact file '{}'", path.display()))?;
@@ -1,6 +1,5 @@
use std::env;
use std::fs;
use std::path::PathBuf;
use std::{env, fs};
use chrono::Utc;
use galaxy_cli::artifact::UploadArtifactArgs;
@@ -39,15 +38,14 @@ fn create_conversation_metadata(
was_summarized: false,
context_window_usage: 0.0,
credits_spent: 0.0,
platform_credits_spent: 0.0,
credits_spent_for_last_block: None,
token_usage: vec![],
tool_usage_metadata: Default::default(),
total_cache_read_tokens: 0,
total_cache_write_tokens: 0,
total_cache_miss_tokens: 0,
total_cost_cents: 0.0,
context_window_segments: Vec::new(),
},
metadata: create_mock_server_metadata(),
creator: None,
permissions: ServerPermissions::mock_personal(),
ambient_agent_task_id: ambient_task_id.map(|task_id| task_id.parse().unwrap()),
server_conversation_token: ServerConversationToken::new(conversation_id.to_string()),
+4 -21
View File
@@ -18,7 +18,7 @@ use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::cloud_environments::CloudAmbientAgentEnvironment;
use crate::ai::llms::{LLMId, LLMPreferences};
use crate::auth::auth_state::AuthStateProvider;
use crate::cloud_object::{CloudObject, Owner};
use crate::cloud_object::{CloudObject, CloudObjectLookup as _, Owner};
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::ids::{ServerId, SyncId};
use crate::server::server_api::ai::AIClient;
@@ -37,7 +37,7 @@ pub fn validate_agent_mode_base_model_id(
let llm_id: LLMId = model_id.into();
let valid_ids = llm_prefs
.get_base_llm_choices_for_agent_mode()
.get_base_llm_choices_for_agent_mode(ctx)
.map(|info| info.id.clone())
.collect::<Vec<_>>();
@@ -329,22 +329,5 @@ impl fmt::Display for EnvironmentChoice {
}
#[cfg(test)]
mod tests {
use super::parse_ambient_task_id;
#[test]
fn parse_ambient_task_id_accepts_valid_ids() {
let task_id =
parse_ambient_task_id("550e8400-e29b-41d4-a716-446655440000", "Invalid run ID")
.unwrap();
assert_eq!(task_id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
}
#[test]
fn parse_ambient_task_id_preserves_error_prefix() {
let err = parse_ambient_task_id("not-a-run-id", "Invalid run ID").unwrap_err();
assert!(err.to_string().contains("Invalid run ID 'not-a-run-id'"));
}
}
#[path = "common_tests.rs"]
mod tests;
+16
View File
@@ -0,0 +1,16 @@
use super::parse_ambient_task_id;
#[test]
fn parse_ambient_task_id_accepts_valid_ids() {
let task_id =
parse_ambient_task_id("550e8400-e29b-41d4-a716-446655440000", "Invalid run ID").unwrap();
assert_eq!(task_id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
}
#[test]
fn parse_ambient_task_id_preserves_error_prefix() {
let err = parse_ambient_task_id("not-a-run-id", "Invalid run ID").unwrap_err();
assert!(err.to_string().contains("Invalid run ID 'not-a-run-id'"));
}
+5 -1
View File
@@ -20,6 +20,8 @@ pub struct AgentConfigSnapshotFile {
#[serde(default)]
pub environment_id: Option<String>,
#[serde(default)]
pub runner_id: Option<String>,
#[serde(default)]
pub model_id: Option<String>,
#[serde(default)]
pub base_prompt: Option<String>,
@@ -93,7 +95,7 @@ fn parse_yaml(input: &str) -> anyhow::Result<AgentConfigSnapshotFile> {
}
fn supported_keys_context() -> String {
"Supported keys: name, environment_id, model_id, base_prompt, mcp_servers, host, computer_use_enabled".to_string()
"Supported keys: name, environment_id, runner_id, model_id, base_prompt, mcp_servers, host, computer_use_enabled".to_string()
}
/// Convert an unwrapped `mcp_servers` map into runtime MCP specs for AgentDriver.
@@ -148,6 +150,7 @@ pub fn merge_with_precedence(
let name = cli.name.or_else(|| file.name.clone());
let environment_id = cli.environment_id.or_else(|| file.environment_id.clone());
let runner_id = cli.runner_id.or_else(|| file.runner_id.clone());
let model_id = cli.model_id.or_else(|| file.model_id.clone());
let base_prompt = cli.base_prompt.or_else(|| file.base_prompt.clone());
@@ -158,6 +161,7 @@ pub fn merge_with_precedence(
AgentConfigSnapshot {
name,
environment_id,
runner_id,
model_id,
base_prompt,
mcp_servers,
+2 -1
View File
@@ -3,9 +3,9 @@
use std::io::Write as _;
use serde_json::json;
use warp_cli::mcp::MCPSpec;
use crate::ai::ambient_agents::AgentConfigSnapshot;
use galaxy_cli::mcp::MCPSpec;
fn write_temp(suffix: &str, contents: &str) -> tempfile::NamedTempFile {
let mut file = tempfile::Builder::new().suffix(suffix).tempfile().unwrap();
@@ -96,6 +96,7 @@ fn merge_precedence_cli_over_file_and_merges_mcp() {
let cli = AgentConfigSnapshot {
name: Some("cli-name".to_string()),
environment_id: None,
runner_id: None,
model_id: Some("cli-model".to_string()),
base_prompt: None,
mcp_servers: Some(serde_json::Map::from_iter([(
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -2,7 +2,8 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Context;
use base64::{engine::general_purpose, Engine};
use base64::engine::general_purpose;
use base64::Engine;
use futures::future::join_all;
use futures::TryStreamExt as _;
use galaxy_core::features::FeatureFlag;
@@ -30,6 +31,7 @@ pub const MAX_ATTACHMENT_COUNT_FOR_CLOUD_QUERY: usize = 25;
///
/// Makes a best-effort attempt to download all attachments.
/// Individual download failures are logged but don't cause the entire function to fail.
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true))]
pub(crate) async fn fetch_and_download_attachments(
ai_client: Arc<dyn AIClient>,
http_client: Arc<ServerApi>,
@@ -65,6 +67,7 @@ pub(crate) async fn fetch_and_download_attachments(
/// logged at WARN level inside this function; per-file errors are not surfaced to callers.
///
/// Fatal failures (listing the attachments, creating the handoff dir) return `Err`.
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true))]
pub(crate) async fn fetch_and_download_handoff_snapshot_attachments(
ai_client: Arc<dyn AIClient>,
http_client: &http_client::Client,
@@ -219,13 +222,20 @@ async fn download_handoff_entry(
/// Shared download primitive: GET `download_url`, write the body to `file_path`, and retry
/// transient HTTP failures on the shared bounded-backoff schedule. Non-2xx responses surface
/// an [`HttpStatusError`] so the retry classifier can decide whether to retry.
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true))]
async fn download_attachment(
http_client: &http_client::Client,
download_url: &str,
file_path: &Path,
) -> anyhow::Result<()> {
let operation = format!("download attachment '{}'", file_path.display());
with_bounded_retry(&operation, || async {
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true))]
async fn attempt(
http_client: &http_client::Client,
download_url: &str,
file_path: &Path,
) -> anyhow::Result<()> {
let response = http_client
.get(download_url)
.send()
@@ -254,6 +264,10 @@ async fn download_attachment(
.context("Failed to write file")?;
Ok(())
}
with_bounded_retry(&operation, || async {
attempt(http_client, download_url, file_path).await
})
.await
}
@@ -0,0 +1,170 @@
/// Proactive Bedrock OIDC credential refresh for cloud agent sandboxes.
///
/// The `OidcManaged` Bedrock credential path mints an OIDC token at agent startup,
/// exchanges it for 1-hour STS temporary credentials via `AssumeRoleWithWebIdentity`,
/// and stores them in `ApiKeyManager`. Without a proactive refresh, those credentials
/// expire after ~1 hour and all subsequent Bedrock LLM calls fail.
///
/// This module provides a background `refresh_loop` — modelled on `git_credentials`'s
/// loop — that proactively re-mints the OIDC token and re-calls STS every 50 minutes,
/// well ahead of the 1-hour STS expiry. The loop is raced against the run execution
/// future via `futures::select!` and dropped automatically when the run completes.
use std::time::{Duration, SystemTime};
use ai::api_keys::{ApiKeyManager, AwsCredentials, AwsCredentialsState};
use anyhow::{Context as _, Result};
use vec1::vec1;
use warp_managed_secrets::client::IdentityTokenOptions;
use warp_managed_secrets::ManagedSecretManager;
use warpui::{ModelSpawner, SingletonEntity};
use super::AgentDriver;
use crate::ai::aws_credentials::{
aws_role_session_name, sts_client, AWS_BEDROCK_STS_AUDIENCE, BEDROCK_IDENTITY_TOKEN_DURATION,
};
/// How long to wait between Bedrock credential refresh attempts — well ahead of the
/// 1-hour STS temporary credential expiry, matching the approach used for git credentials.
pub(crate) const BEDROCK_CREDENTIALS_REFRESH_INTERVAL: Duration = Duration::from_secs(50 * 60);
/// Perform one Bedrock OIDC credential refresh attempt.
///
/// Returns `Ok(())` on success. Returns `Err` when token minting or the STS call
/// fails — these are transient failures worth retrying.
#[tracing::instrument(
name = "bedrock_credentials::try_refresh",
skip_all,
err,
fields(tags.cloud_agent = true, task_id)
)]
async fn try_refresh(
task_id: &str,
role_arn: &str,
region: &str,
foreground: &ModelSpawner<AgentDriver>,
) -> Result<()> {
// Step 1: Mint a new OIDC identity token via the model context.
let token_future = foreground
.spawn(|_, ctx| {
ManagedSecretManager::handle(ctx)
.as_ref(ctx)
.issue_task_identity_token(IdentityTokenOptions {
audience: AWS_BEDROCK_STS_AUDIENCE.to_string(),
requested_duration: BEDROCK_IDENTITY_TOKEN_DURATION,
subject_template: vec1!["scoped_principal".to_string()],
})
})
.await
.context("Failed to dispatch OIDC token request for Bedrock refresh")?;
let token = token_future
.await
.context("Failed to mint OIDC identity token for Bedrock refresh")?;
// Step 2: Exchange the OIDC token for fresh STS temporary credentials.
let client = sts_client(region).await;
let session_name = aws_role_session_name(task_id);
let sts_creds = client
.assume_role_with_web_identity()
.role_arn(role_arn)
.role_session_name(&session_name)
.web_identity_token(&token.token)
.send()
.await
.map_err(|err| {
log::error!("Bedrock OIDC refresh: STS AssumeRoleWithWebIdentity error: {err:#?}");
let detail = err
.as_service_error()
.map(|e| e.to_string())
.unwrap_or_else(|| err.to_string());
anyhow::anyhow!("STS AssumeRoleWithWebIdentity failed: {detail}")
})?
.credentials
.context("STS response did not include credentials")?;
let aws_creds = AwsCredentials::new(
sts_creds.access_key_id().to_string(),
sts_creds.secret_access_key().to_string(),
Some(sts_creds.session_token().to_string()),
SystemTime::try_from(*sts_creds.expiration()).ok(),
);
// Step 3: Update ApiKeyManager with the fresh credentials.
foreground
.spawn(move |_, ctx| {
ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| {
manager.set_aws_credentials_state(
AwsCredentialsState::Loaded {
credentials: aws_creds,
loaded_at: SystemTime::now(),
},
ctx,
);
});
})
.await
.context("Failed to dispatch Bedrock credential update to ApiKeyManager")?;
log::info!("Bedrock OIDC: proactive credential refresh succeeded for task {task_id}");
Ok(())
}
/// Infinite async loop that proactively refreshes AWS Bedrock OIDC credentials every
/// [`BEDROCK_CREDENTIALS_REFRESH_INTERVAL`], keeping long-running agents authenticated
/// for their entire duration.
///
/// On each iteration:
/// 1. Issue a new OIDC identity token via warp-server.
/// 2. Call STS `AssumeRoleWithWebIdentity` to get fresh temporary credentials.
/// 3. Update `ApiKeyManager` with the new credentials.
///
/// On transient failure, retries up to three times with exponential backoff
/// (1 min, 2 min, 4 min), keeping all retries well within the buffer before
/// the 1-hour STS credentials expire. If all retries fail, a warning is logged
/// and the next refresh is scheduled after the normal interval.
///
/// This future never resolves — it is designed to be raced with the run execution
/// future via `futures::select!` and dropped automatically when the run completes.
pub(crate) async fn refresh_loop(
task_id: String,
role_arn: String,
region: String,
foreground: &ModelSpawner<AgentDriver>,
) {
loop {
warpui::r#async::Timer::after(BEDROCK_CREDENTIALS_REFRESH_INTERVAL).await;
log::info!("Proactively refreshing AWS Bedrock OIDC credentials for task {task_id}");
let backoff_delays = [
Duration::from_secs(60),
Duration::from_secs(2 * 60),
Duration::from_secs(4 * 60),
];
let mut attempt = 0usize;
loop {
match try_refresh(&task_id, &role_arn, &region, foreground).await {
Ok(()) => break,
Err(e) if attempt < backoff_delays.len() => {
let delay = backoff_delays[attempt];
log::warn!(
"Bedrock credentials refresh failed (attempt {}): {e:#}; \
retrying in {}s",
attempt + 1,
delay.as_secs()
);
warpui::r#async::Timer::after(delay).await;
attempt += 1;
}
Err(e) => {
log::warn!(
"Bedrock credentials refresh failed after {} attempts: {e:#}; \
credentials may expire before next refresh cycle",
attempt + 1
);
break;
}
}
}
}
}
@@ -1,4 +1,7 @@
use std::{collections::HashMap, ffi::OsString, future::Future, pin::Pin};
use std::collections::HashMap;
use std::ffi::OsString;
use std::future::Future;
use std::pin::Pin;
use anyhow::Error;
use galaxyui::ModelSpawner;
@@ -1,4 +1,8 @@
use std::{collections::HashMap, ffi::OsString, future::Future, pin::Pin, time::Duration};
use std::collections::HashMap;
use std::ffi::OsString;
use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
use anyhow::Context;
use galaxy_core::safe_info;
@@ -7,11 +11,10 @@ use galaxyui::{ModelSpawner, SingletonEntity};
use tempfile::{Builder, NamedTempFile};
use vec1::Vec1;
use crate::ai::aws_credentials::aws_role_session_name;
use crate::ai::cloud_environments::AwsProviderConfig;
use super::super::terminal::TerminalDriver;
use super::{CloudProvider, CloudProviderSetupError, Result};
use crate::ai::aws_credentials::aws_role_session_name;
use crate::ai::cloud_environments::AwsProviderConfig;
/// Default duration for OIDC identity tokens issued for cloud provider auth.
/// The AWS CLI doesn't offer a mechanism for refreshing web identity tokens, so we
@@ -1,11 +1,14 @@
use std::{collections::HashMap, ffi::OsString, future::Future, pin::Pin, time::Duration};
use std::collections::HashMap;
use std::ffi::OsString;
use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
use anyhow::Context as _;
use galaxy_managed_secrets::{GcpCredentials, GcpFederationConfig};
use crate::ai::cloud_environments::GcpProviderConfig;
use super::{CloudProvider, CloudProviderSetupError, Result};
use crate::ai::cloud_environments::GcpProviderConfig;
/// Token lifetime for GCP executable-sourced credentials. The GCP client
/// libraries handle refreshing automatically, so we keep this short.
@@ -1,11 +1,12 @@
use std::{collections::HashMap, ffi::OsString, path::PathBuf};
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::PathBuf;
use super::aws::AwsCloudProvider;
use super::gcp::GcpCloudProvider;
use super::{collect_env_vars, load_providers, CloudProvider};
use crate::ai::cloud_environments::{AwsProviderConfig, GcpProviderConfig, ProvidersConfig};
use super::{
aws::AwsCloudProvider, collect_env_vars, gcp::GcpCloudProvider, load_providers, CloudProvider,
};
#[test]
fn aws_provider_env_vars_before_setup() {
let config = AwsProviderConfig {
+376 -188
View File
@@ -1,25 +1,28 @@
use std::{
collections::HashMap,
future::Future,
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::Duration,
};
use std::collections::HashMap;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::ai::cloud_environments::{AmbientAgentEnvironment, GithubRepo};
use crate::terminal::model::session::command_executor::shell_escape_single_quotes;
use crate::terminal::shell::ShellType;
use ai::index::full_source_code_embedding::manager::{
CodebaseIndexManager, CodebaseIndexManagerEvent,
};
use futures::{channel::oneshot, future::join_all};
use galaxy_completer::completer::CommandExitStatus;
use galaxy_core::{command::ExitCode, safe_info, safe_warn};
use galaxyui::{r#async::FutureExt, ModelContext, ModelSpawner, SingletonEntity};
use futures::channel::oneshot;
use futures::future::join_all;
use repo_metadata::repositories::{DetectedRepositories, RepoDetectionSource};
use super::{terminal::TerminalDriver, AgentDriverError};
use galaxy_cli::agent::Harness;
use galaxy_completer::completer::CommandExitStatus;
use galaxy_core::command::ExitCode;
use galaxy_core::{safe_info, safe_warn};
use galaxyui::r#async::FutureExt;
use galaxyui::{ModelContext, ModelSpawner, SingletonEntity};
use super::terminal::TerminalDriver;
use super::AgentDriverError;
use crate::ai::agent_sdk::setup_observability::{SetupClientEventReporter, SetupStep};
use crate::ai::cloud_environments::{AmbientAgentEnvironment, SourceRepo};
use crate::terminal::model::session::command_executor::shell_escape_single_quotes;
use crate::terminal::shell::ShellType;
const CODEBASE_INDEX_SYNC_TIMEOUT: Duration = Duration::from_secs(60);
@@ -54,20 +57,18 @@ pub fn prepare_environment(
working_dir: PathBuf,
is_sandbox: bool,
harness: Harness,
setup_events: SetupClientEventReporter,
ctx: &mut ModelContext<TerminalDriver>,
) -> impl Future<Output = Result<(), PrepareEnvironmentError>> {
let spawner = ctx.spawner();
async move {
let AmbientAgentEnvironment {
github_repos,
setup_commands,
..
} = environment;
let source_repos = environment.effective_repos();
let setup_commands = environment.setup_commands;
// Only index the codebase for the Oz harness; third-party harnesses (e.g. Claude)
// have their own methods for navigating a codebase.
let should_index_codebase = harness == Harness::Oz;
let should_subscribe_to_index_updates = should_index_codebase && !github_repos.is_empty();
let should_subscribe_to_index_updates = should_index_codebase && !source_repos.is_empty();
let repo_channels = Arc::new(Mutex::new(HashMap::<PathBuf, oneshot::Sender<()>>::new()));
if should_subscribe_to_index_updates {
@@ -78,14 +79,15 @@ pub fn prepare_environment(
&spawner,
working_dir.as_path(),
is_sandbox,
&github_repos,
&source_repos,
setup_commands,
should_index_codebase,
Arc::clone(&repo_channels),
setup_events,
)
.await;
if should_subscribe_to_index_updates {
if should_subscribe_to_index_updates && result.is_err() {
let _ = spawner
.spawn(|_, ctx| {
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
@@ -97,14 +99,16 @@ pub fn prepare_environment(
}
}
#[allow(clippy::too_many_arguments)]
async fn prepare_environment_impl(
spawner: &ModelSpawner<TerminalDriver>,
working_dir: &Path,
is_sandbox: bool,
github_repos: &[GithubRepo],
source_repos: &[SourceRepo],
setup_commands: Vec<String>,
should_index_codebase: bool,
repo_channels: Arc<Mutex<HashMap<PathBuf, oneshot::Sender<()>>>>,
setup_events: SetupClientEventReporter,
) -> Result<(), PrepareEnvironmentError> {
let working_dir_string = working_dir.to_string_lossy().to_string();
@@ -120,175 +124,95 @@ async fn prepare_environment_impl(
}
let mut codebase_context_receivers = Vec::new();
for repo in github_repos {
let repo_name = format!("{}/{}", repo.owner, repo.repo);
let repo_url = format!("https://github.com/{repo_name}.git");
// We do a partial clone here to speed up environment setup time.
let command = format!("git clone --filter=tree:0 {repo_url}");
let repo_dir = working_dir.join(&repo.repo);
// Always ask the session whether the repo dir already exists, rather
// than stat'ing from the host. The session knows about sandbox-only
// paths, and this goes through the silent executor so `test -d` is
// not added to the user-visible blocklist. Pass the absolute path
// explicitly so the probe doesn't rely on the session's CWD.
let dir_exists = terminal_directory_exists(&repo_dir.to_string_lossy(), spawner).await?;
if dir_exists {
safe_warn!(
safe: ("We already have a directory with the same repository name in the terminal working directory, skipping clone..."),
full: (
"We already have a directory with the name {} in the terminal working directory, skipping clone...",
repo.repo)
);
} else {
safe_info!(
safe: ("Cloning repository via terminal"),
full: ("Cloning repository via terminal: {repo_name}")
);
let exit_code = execute_command(command, spawner).await?;
if exit_code != 0.into() {
return Err(PrepareEnvironmentError::CloneRepo {
repo_name: repo_name.clone(),
});
}
safe_info!(
safe: ("Successfully cloned repository"),
full: ("Successfully cloned: {repo_name}")
);
}
// Register the repo with DetectedRepositories so that the skill watcher
// and other repo-aware subsystems can discover it before the first query.
//
// TODO(advait): When the remote code server lands for Docker sandboxes,
// sandbox-only working directories will be reachable from the host and
// we should register + index them here too (likely via a remote-aware
// path instead of `detect_possible_git_repo`/`index_directory`, which
// both assume a local filesystem). For now, skip so we don't try to
// stat paths that only exist inside the sandbox.
if is_sandbox {
safe_info!(
safe: ("Skipping local repo detection for sandbox-only working directory"),
full: (
"Skipping local repo detection and indexing for sandbox-only working directory {}",
working_dir.display()
)
);
} else {
let repo_dir_str = repo_dir.to_string_lossy().to_string();
let detect_future = spawner
.spawn(move |_, ctx| {
DetectedRepositories::handle(ctx).update(ctx, |repos, ctx| {
repos.detect_possible_git_repo(
&repo_dir_str,
RepoDetectionSource::CloudEnvironmentPrep,
ctx,
if !source_repos.is_empty() {
setup_events
.record_result(SetupStep::EnvironmentRepoClone, async {
clone_repos(source_repos, working_dir, spawner).await?;
for repo in source_repos {
register_cloned_repo(repo, working_dir, is_sandbox, spawner).await?;
if !is_sandbox && should_index_codebase {
let receiver = index_repo_codebase(
&repo.repo,
working_dir,
Arc::clone(&repo_channels),
spawner,
)
})
})
.await
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)?;
// Await detection so the repo is registered in DirectoryWatcher
// before the agent's first query.
if detect_future.await.is_none() {
safe_warn!(
safe: ("Repository detection returned no path"),
full: ("Repository detection returned no path for {}", repo_dir.display())
);
}
if should_index_codebase {
let receiver = index_repo_codebase(
&repo.repo,
working_dir,
Arc::clone(&repo_channels),
spawner,
)
.await?;
if let Some(receiver) = receiver {
codebase_context_receivers.push(receiver);
.await?;
if let Some(receiver) = receiver {
codebase_context_receivers.push(receiver);
}
}
}
}
Ok::<(), PrepareEnvironmentError>(())
})
.await?;
if should_index_codebase {
record_codebase_indexing(
setup_events.clone(),
spawner.clone(),
codebase_context_receivers,
);
}
}
let has_setup_commands = !setup_commands.is_empty();
if has_setup_commands {
// Set CI=true so setup commands run in a CI-like environment. This should help us run
// non-interactive versions of setup commands, as many command line tools recognize the CI
// environment variable.
execute_command("export CI=true".to_string(), spawner).await?;
setup_events
.record_result(SetupStep::EnvironmentSetupCommands, async {
// Set CI=true so setup commands run in a CI-like environment. This should help us run
// non-interactive versions of setup commands, as many command line tools recognize the CI
// environment variable.
execute_command("export CI=true".to_string(), spawner).await?;
for command in setup_commands {
let command_for_error = command.clone();
safe_info!(
safe: ("Running setup command"),
full: ("Running setup command: {command}")
);
let exit_code = execute_command(command, spawner).await?;
if exit_code != 0.into() {
return Err(PrepareEnvironmentError::SetupCommand {
command: command_for_error,
});
}
let working_dir_string = working_dir.to_string_lossy().to_string();
if let Err(error) = cd_in_terminal(working_dir_string, spawner).await {
log::warn!(
"Failed to reset working directory after setup command: {error}"
);
}
safe_info!(
safe: ("Successfully completed setup command"),
full: ("Successfully completed setup command: {command_for_error}")
);
}
// Unset CI after setup commands complete so the agent session
// does not run with CI=true.
execute_command("unset CI".to_string(), spawner).await?;
Ok::<(), PrepareEnvironmentError>(())
})
.await?;
} else if should_index_codebase && source_repos.is_empty() {
let _ = spawner
.spawn(|_, ctx| {
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
})
.await;
}
for command in setup_commands {
let command_for_error = command.clone();
safe_info!(
safe: ("Running setup command"),
full: ("Running setup command: {command}")
);
let exit_code = execute_command(command, spawner).await?;
if exit_code != 0.into() {
return Err(PrepareEnvironmentError::SetupCommand {
command: command_for_error,
});
}
let working_dir_string = working_dir.to_string_lossy().to_string();
if let Err(error) = cd_in_terminal(working_dir_string, spawner).await {
log::warn!("Failed to reset working directory after setup command: {error}");
}
safe_info!(
safe: ("Successfully completed setup command"),
full: ("Successfully completed setup command: {command_for_error}")
);
}
if has_setup_commands {
// Unset CI after setup commands complete so the agent session
// does not run with CI=true.
execute_command("unset CI".to_string(), spawner).await?;
}
if !github_repos.is_empty() {
// Wait for codebase indexing for all repositories after running setup commands.
// We skip this if running in Docker sandboxes since they don't have a cache volume.
// We also skip this in Namespace to reduce startup time.
#[cfg(not(target_family = "wasm"))]
let should_wait_for_indexing = !matches!(
galaxy_isolation_platform::detect(),
Some(
galaxy_isolation_platform::IsolationPlatformType::DockerSandbox
| galaxy_isolation_platform::IsolationPlatformType::Namespace
)
);
#[cfg(target_family = "wasm")]
let should_wait_for_indexing = true;
if should_wait_for_indexing {
let repos_indexed = join_all(codebase_context_receivers);
if repos_indexed
.with_timeout(CODEBASE_INDEX_SYNC_TIMEOUT)
.await
.is_err()
{
log::warn!(
"Timed out waiting for codebase index sync; continuing without guaranteed codebase context",
);
}
} else {
drop(codebase_context_receivers);
log::info!("Not waiting for codebase index sync");
}
if should_index_codebase && source_repos.is_empty() {
log::info!("No repositories to index for codebase context");
}
// If there's only one repo in the environment, start the agent in that repo.
// This way, it doesn't have to locate the correct repo to work on.
if let Some(repo_name) = single_repo_name(github_repos) {
if let Some(repo_name) = single_repo_name(source_repos) {
safe_info!(
safe: ("Changing directory into single repository"),
full: ("Changing directory into single repository: {repo_name}")
@@ -302,6 +226,269 @@ async fn prepare_environment_impl(
Ok(())
}
fn record_codebase_indexing(
setup_events: SetupClientEventReporter,
spawner: ModelSpawner<TerminalDriver>,
codebase_context_receivers: Vec<oneshot::Receiver<()>>,
) {
if codebase_context_receivers.is_empty() {
setup_events.record_value_detached(SetupStep::EnvironmentCodebaseIndexing, async move {
let _ = spawner
.spawn(|_, ctx| {
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
})
.await;
});
return;
}
setup_events.record_value_detached(SetupStep::EnvironmentCodebaseIndexing, async move {
let repos_indexed = join_all(codebase_context_receivers);
if repos_indexed
.with_timeout(CODEBASE_INDEX_SYNC_TIMEOUT)
.await
.is_err()
{
log::warn!(
"Timed out waiting for codebase index sync; continuing without guaranteed codebase context",
);
}
let _ = spawner
.spawn(|_, ctx| {
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
})
.await;
});
}
fn build_parallel_clone_command(repos: &[SourceRepo], shell_type: ShellType) -> String {
let mut script = String::from(
r#"set +e
failed=0
pids=""
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/warp-clone-logs.XXXXXX")"
cleanup_clone_logs() {
rm -rf "$tmp_dir"
}
trap cleanup_clone_logs EXIT
clone_repo() {
repo_name="$1"
repo_url="$2"
target="$3"
if [ -d "$target" ]; then
printf '%s\n' "Repository directory $target already exists, skipping clone..."
return 0
fi
printf '%s\n' "Cloning repository $repo_name..."
git clone --filter=tree:0 "$repo_url" "$target"
}
"#,
);
let mut log_outputs = String::new();
for (index, repo) in repos.iter().enumerate() {
let repo_name = format!("{}/{}", repo.owner, repo.repo);
let repo_url = repo.https_clone_url();
let escaped_repo_name = shell_escape_single_quotes(&repo_name, ShellType::Bash);
let escaped_repo_url = shell_escape_single_quotes(&repo_url, ShellType::Bash);
let escaped_target = shell_escape_single_quotes(&repo.repo, ShellType::Bash);
let log_var = format!("log_file_{index}");
script.push_str(&format!(
"{log_var}=\"$tmp_dir/repo-{index}.log\"\n\
clone_repo '{escaped_repo_name}' '{escaped_repo_url}' '{escaped_target}' >\"${log_var}\" 2>&1 &\n"
));
script.push_str("pids=\"$pids $!\"\n");
log_outputs.push_str(&format!(
"printf '%s\\n' '===== {escaped_repo_name} ====='\n\
if [ -s \"${log_var}\" ]; then\n\
\tcat \"${log_var}\"\n\
else\n\
\tprintf '%s\\n' '(no output)'\n\
fi\n"
));
}
script.push_str(
r#"for pid in $pids; do
if ! wait "$pid"; then
failed=1
fi
done
"#,
);
script.push_str(&log_outputs);
script.push_str(
r#"
exit "$failed"
"#,
);
let escaped_script = shell_escape_single_quotes(&script, shell_type);
format!("sh -c '{escaped_script}'")
}
/// Clone all source repositories to `{working_dir}/{repo.repo}` if they do not already exist.
/// Multiple repositories are cloned in parallel to reduce environment setup time.
pub(super) async fn clone_repos(
repos: &[SourceRepo],
working_dir: &Path,
spawner: &ModelSpawner<TerminalDriver>,
) -> Result<(), PrepareEnvironmentError> {
match repos {
[] => Ok(()),
[repo] => clone_repo(repo, working_dir, spawner).await,
repos => {
let shell_type = spawner
.spawn(|driver, ctx| {
driver
.active_session_shell_type(ctx)
.unwrap_or(ShellType::Bash)
})
.await
.unwrap_or(ShellType::Bash);
let repo_names = repos
.iter()
.map(|repo| format!("{}/{}", repo.owner, repo.repo))
.collect::<Vec<_>>();
safe_info!(
safe: ("Cloning repositories via terminal"),
full: ("Cloning repositories via terminal: {}", repo_names.join(", "))
);
let command = build_parallel_clone_command(repos, shell_type);
let exit_code = execute_command(command, spawner).await?;
if exit_code != 0.into() {
return Err(PrepareEnvironmentError::CloneRepo {
repo_name: repo_names.join(", "),
});
}
safe_info!(
safe: ("Successfully cloned repositories"),
full: ("Successfully cloned repositories: {}", repo_names.join(", "))
);
Ok(())
}
}
}
/// Clone a source repository to `{working_dir}/{repo.repo}` if it does not already exist.
/// This only performs the clone -- it does NOT register the repo with `DetectedRepositories`.
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, repo = %repo))]
pub(super) async fn clone_repo(
repo: &SourceRepo,
working_dir: &Path,
spawner: &ModelSpawner<TerminalDriver>,
) -> Result<(), PrepareEnvironmentError> {
let repo_name = format!("{}/{}", repo.owner, repo.repo);
let repo_url = repo.https_clone_url();
// Get the session's shell type for proper escaping, falling back to Bash
// when the session is not yet bootstrapped or the spawn fails.
let shell_type = spawner
.spawn(|driver, ctx| {
driver
.active_session_shell_type(ctx)
.unwrap_or(ShellType::Bash)
})
.await
.unwrap_or(ShellType::Bash);
let escaped_url = shell_escape_single_quotes(&repo_url, shell_type);
// We do a partial clone here to speed up environment setup time.
let command = format!("git clone --filter=tree:0 '{escaped_url}'");
let repo_dir = working_dir.join(&repo.repo);
// Always ask the session whether the repo dir already exists, rather
// than stat'ing from the host. The session knows about sandbox-only
// paths, and this goes through the silent executor so `test -d` is
// not added to the user-visible blocklist. Pass the absolute path
// explicitly so the probe doesn't rely on the session's CWD.
let dir_exists = terminal_directory_exists(&repo_dir.to_string_lossy(), spawner).await?;
if dir_exists {
safe_warn!(
safe: ("We already have a directory with the same repository name in the terminal working directory, skipping clone..."),
full: (
"We already have a directory with the name {} in the terminal working directory, skipping clone...",
repo.repo)
);
} else {
safe_info!(
safe: ("Cloning repository via terminal"),
full: ("Cloning repository via terminal: {repo_name}")
);
let exit_code = execute_command(command, spawner).await?;
if exit_code != 0.into() {
return Err(PrepareEnvironmentError::CloneRepo {
repo_name: repo_name.clone(),
});
}
safe_info!(
safe: ("Successfully cloned repository"),
full: ("Successfully cloned: {repo_name}")
);
}
Ok(())
}
/// Register a cloned source repository with `DetectedRepositories` so that the
/// skill watcher and other repo-aware subsystems can discover it.
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, repo = %repo, is_sandbox = is_sandbox))]
pub(super) async fn register_cloned_repo(
repo: &SourceRepo,
working_dir: &Path,
is_sandbox: bool,
spawner: &ModelSpawner<TerminalDriver>,
) -> Result<(), PrepareEnvironmentError> {
let repo_dir = working_dir.join(&repo.repo);
// Register the repo with DetectedRepositories so that the skill watcher
// and other repo-aware subsystems can discover it before the first query.
//
// TODO(advait): When the remote code server lands for Docker sandboxes,
// sandbox-only working directories will be reachable from the host and
// we should register + index them here too (likely via a remote-aware
// path instead of `detect_possible_local_git_repo`/`index_directory`, which
// both assume a local filesystem). For now, skip so we don't try to
// stat paths that only exist inside the sandbox.
if is_sandbox {
safe_info!(
safe: ("Skipping local repo detection for sandbox-only working directory"),
full: (
"Skipping local repo detection and indexing for sandbox-only working directory {}",
working_dir.display()
)
);
} else {
let repo_dir_str = repo_dir.to_string_lossy().to_string();
let detect_future = spawner
.spawn(move |_, ctx| {
DetectedRepositories::handle(ctx).update(ctx, |repos, ctx| {
repos.detect_possible_local_git_repo(
&repo_dir_str,
RepoDetectionSource::CloudEnvironmentPrep,
ctx,
)
})
})
.await
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)?;
// Await detection so the repo is registered in DirectoryWatcher
// before the agent's first query.
if detect_future.await.is_none() {
safe_warn!(
safe: ("Repository detection returned no path"),
full: ("Repository detection returned no path for {}", repo_dir.display())
);
}
}
Ok(())
}
async fn subscribe_to_codebase_index_events(
spawner: &ModelSpawner<TerminalDriver>,
repo_channels: Arc<Mutex<HashMap<PathBuf, oneshot::Sender<()>>>>,
@@ -309,10 +496,11 @@ async fn subscribe_to_codebase_index_events(
spawner
.spawn(move |_, ctx| {
let repo_channels = Arc::clone(&repo_channels);
ctx.subscribe_to_model(
&CodebaseIndexManager::handle(ctx),
move |_, event, ctx| {
if !matches!(event, CodebaseIndexManagerEvent::SyncStateUpdated) {
ctx.subscribe_to_model(&CodebaseIndexManager::handle(ctx), move |_, _, event, ctx| {
if !matches!(
event,
CodebaseIndexManagerEvent::SyncStateUpdated { .. }
) {
return;
}
@@ -348,13 +536,13 @@ async fn subscribe_to_codebase_index_events(
let _ = tx.send(());
}
}
},
);
});
})
.await
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)
}
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, repo = %repo_name))]
async fn index_repo_codebase(
repo_name: &str,
working_dir: &Path,
@@ -454,7 +642,7 @@ async fn cd_in_terminal(
})
}
fn single_repo_name(repos: &[GithubRepo]) -> Option<String> {
fn single_repo_name(repos: &[SourceRepo]) -> Option<String> {
if repos.len() != 1 {
return None;
}
@@ -1,9 +1,13 @@
use super::single_repo_name;
use crate::ai::cloud_environments::GithubRepo;
use cloud_object_models::CodeForge;
use super::{build_parallel_clone_command, single_repo_name};
use crate::ai::cloud_environments::SourceRepo;
use crate::terminal::shell::ShellType;
#[test]
fn single_repo_name_returns_repo_when_exactly_one_repo() {
let repos = vec![GithubRepo::new(
let repos = vec![SourceRepo::new(
CodeForge::GitHub,
"warpdotdev".to_string(),
"warp-internal".to_string(),
)];
@@ -13,12 +17,60 @@ fn single_repo_name_returns_repo_when_exactly_one_repo() {
#[test]
fn single_repo_name_returns_none_for_zero_or_many_repos() {
let no_repos = Vec::<GithubRepo>::new();
let no_repos = Vec::<SourceRepo>::new();
assert_eq!(single_repo_name(&no_repos), None);
let two_repos = vec![
GithubRepo::new("warpdotdev".to_string(), "warp-internal".to_string()),
GithubRepo::new("warpdotdev".to_string(), "warp-server".to_string()),
SourceRepo::new(
CodeForge::GitHub,
"warpdotdev".to_string(),
"warp-internal".to_string(),
),
SourceRepo::new(
CodeForge::GitHub,
"warpdotdev".to_string(),
"warp-server".to_string(),
),
];
assert_eq!(single_repo_name(&two_repos), None);
}
#[test]
fn parallel_clone_command_runs_repos_in_background_and_waits() {
let repos = vec![
SourceRepo::new(
CodeForge::GitHub,
"warpdotdev".to_string(),
"warp".to_string(),
),
SourceRepo::new(
CodeForge::GitLab,
"platform/backend".to_string(),
"api".to_string(),
),
];
let command = build_parallel_clone_command(&repos, ShellType::Bash);
assert!(command.starts_with("sh -c '"));
assert!(command.contains("warpdotdev/warp"));
assert!(command.contains("https://github.com/warpdotdev/warp.git"));
assert!(command.contains("platform/backend/api"));
assert!(command.contains("https://gitlab.com/platform/backend/api.git"));
assert_eq!(command.matches("clone_repo").count(), 3);
assert_eq!(command.matches("2>&1 &").count(), 2);
assert!(command.contains("mktemp -d"));
assert!(command.contains("warp-clone-logs"));
assert!(command.contains("trap cleanup_clone_logs EXIT"));
assert!(command.contains("repo-0.log"));
assert!(command.contains("repo-1.log"));
assert!(command.contains(">\"$log_file_0\" 2>&1 &"));
assert!(command.contains(">\"$log_file_1\" 2>&1 &"));
assert!(command.contains("pids=\"$pids $!\""));
assert!(command.contains("wait \"$pid\""));
assert!(command.contains("===== warpdotdev/warp ====="));
assert!(command.contains("cat \"$log_file_0\""));
assert!(command.contains("===== platform/backend/api ====="));
assert!(command.contains("cat \"$log_file_1\""));
assert!(command.contains("exit \"$failed\""));
}
@@ -1,9 +1,9 @@
use crate::ai::blocklist::task_status_sync_model::classify_renderable_error;
use crate::server::server_api::ai::TaskStatusUpdate;
use galaxy_graphql::ai::{AgentTaskState, PlatformErrorCode};
use super::terminal::ShareSessionError;
use super::AgentDriverError;
use crate::ai::blocklist::local_agent_task_sync_model::classify_renderable_error;
use crate::server::server_api::ai::TaskStatusUpdate;
/// Classify an `AgentDriverError` into a task state and a `TaskStatusUpdate`
/// suitable for reporting via `update_agent_task`.
@@ -17,10 +17,10 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
PlatformErrorCode::InternalError,
),
),
AgentDriverError::BootstrapFailed => (
AgentDriverError::BootstrapFailed { error } => (
AgentTaskState::Error,
TaskStatusUpdate::with_error_code(
"Terminal session failed to start. Please try running your task again.",
format!("Terminal session failed to start: {error}"),
PlatformErrorCode::InternalError,
),
),
@@ -100,13 +100,29 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
PlatformErrorCode::EnvironmentSetupFailed,
),
),
AgentDriverError::MCPStartupFailed => (
AgentDriverError::ManagedMcpResolutionFailed { uid, message } => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
"One or more MCP servers failed to start. Check that your MCP server configuration is valid and the server process is runnable.",
format!("Managed MCP server {uid} could not be resolved: {message}"),
PlatformErrorCode::EnvironmentSetupFailed,
),
),
AgentDriverError::MCPStartupFailed { details } => {
let server_lines = details
.iter()
.map(|detail| format!("- {detail}"))
.collect::<Vec<_>>()
.join("\n");
(
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!(
"One or more MCP servers failed to start:\n\n{server_lines}\n\nCheck that each server's configuration is valid and that it is reachable from the agent's environment."
),
PlatformErrorCode::EnvironmentSetupFailed,
),
)
}
AgentDriverError::MCPJsonParseError(msg) => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
@@ -171,7 +187,7 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
// --- Conversation errors ---
// Delegate to classify_renderable_error for proper ERROR vs FAILED
// distinction and PlatformErrorCode. This is a belt-and-suspenders
// fallback — TaskStatusSyncModel handles most conversation errors,
// fallback — LocalAgentTaskSyncModel handles most conversation errors,
// but the driver catches them too if the conversation ends with an error.
AgentDriverError::ConversationError { error } => {
let (state, update) = classify_renderable_error(error);
@@ -248,7 +264,11 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
PlatformErrorCode::InternalError,
),
),
AgentDriverError::ConversationHarnessMismatch { conversation_id, expected, got } => (
AgentDriverError::ConversationHarnessMismatch {
conversation_id,
expected,
got,
} => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!(
@@ -258,7 +278,11 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
PlatformErrorCode::EnvironmentSetupFailed,
),
),
AgentDriverError::TaskHarnessMismatch { task_id, expected, got } => (
AgentDriverError::TaskHarnessMismatch {
task_id,
expected,
got,
} => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!(
@@ -268,7 +292,10 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
PlatformErrorCode::EnvironmentSetupFailed,
),
),
AgentDriverError::ConversationResumeStateMissing { harness, conversation_id } => (
AgentDriverError::ConversationResumeStateMissing {
harness,
conversation_id,
} => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!(
@@ -299,6 +326,41 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
PlatformErrorCode::EnvironmentSetupFailed,
),
),
AgentDriverError::HarnessAuthCheckFailed { harness, detail } => {
let message = format!(
"Harness '{harness}' authentication check failed: login credentials \
are invalid or expired. Verify that the authentication secret \
configured for this harness is correct."
);
log::error!("Preflight detail for {harness}: {detail}");
(
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
message,
PlatformErrorCode::AuthenticationRequired,
),
)
}
AgentDriverError::HarnessRuntimeFailureDetected {
harness,
pattern,
excerpt,
} => {
let message = format!(
"Harness '{harness}' could not make a successful API request. \
Matched failure pattern '{pattern}' in harness output: \"{excerpt}\". \
This usually means the API key is invalid, out of credits, or the \
account is misconfigured."
);
log::error!("Runtime failure for {harness}: pattern={pattern}, excerpt={excerpt}");
(
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
message,
PlatformErrorCode::AuthenticationRequired,
),
)
}
}
}
@@ -1,7 +1,7 @@
use galaxy_graphql::ai::{AgentTaskState, PlatformErrorCode};
use super::classify_driver_error;
use crate::ai::agent_sdk::driver::terminal::ShareSessionError;
use crate::ai::agent_sdk::driver::terminal::{BootstrapError, ShareSessionError};
use crate::ai::agent_sdk::driver::AgentDriverError;
fn assert_state_and_code(
@@ -20,14 +20,58 @@ fn assert_state_and_code(
// --- Infrastructure errors → ERROR ---
#[test]
fn bootstrap_failed_is_error_with_internal() {
assert_state_and_code(
AgentDriverError::BootstrapFailed,
AgentTaskState::Error,
Some(PlatformErrorCode::InternalError),
fn bootstrap_pty_spawn_failed_with_reason_includes_reason_in_message() {
let (state, update) = classify_driver_error(&AgentDriverError::BootstrapFailed {
error: BootstrapError::PtySpawnFailed {
reason: Some("Argument list too long (os error 7)".to_string()),
},
});
assert_eq!(state, AgentTaskState::Error);
assert_eq!(update.error_code, Some(PlatformErrorCode::InternalError));
assert!(
update.message.contains("Argument list too long"),
"message should include the specific failure reason: {:?}",
update.message
);
}
#[test]
fn bootstrap_pty_spawn_failed_without_reason_is_generic() {
let (state, update) = classify_driver_error(&AgentDriverError::BootstrapFailed {
error: BootstrapError::PtySpawnFailed { reason: None },
});
assert_eq!(state, AgentTaskState::Error);
assert_eq!(update.error_code, Some(PlatformErrorCode::InternalError));
assert!(
update.message.contains("Shell spawn failed"),
"message should describe the spawn failure: {:?}",
update.message
);
}
#[test]
fn bootstrap_timed_out_is_error_with_internal() {
let (state, update) = classify_driver_error(&AgentDriverError::BootstrapFailed {
error: BootstrapError::TimedOut,
});
assert_eq!(state, AgentTaskState::Error);
assert_eq!(update.error_code, Some(PlatformErrorCode::InternalError));
assert!(
update.message.contains("did not start within"),
"message should describe the timeout: {:?}",
update.message
);
}
#[test]
fn bootstrap_internal_error_is_error_with_internal() {
let (state, update) = classify_driver_error(&AgentDriverError::BootstrapFailed {
error: BootstrapError::InternalError,
});
assert_eq!(state, AgentTaskState::Error);
assert_eq!(update.error_code, Some(PlatformErrorCode::InternalError));
}
#[test]
fn terminal_unavailable_is_error_with_internal() {
assert_state_and_code(
@@ -72,6 +116,40 @@ fn mcp_server_not_found_is_failed_with_env_setup() {
);
}
#[test]
fn managed_mcp_resolution_failed_is_failed_with_env_setup() {
assert_state_and_code(
AgentDriverError::ManagedMcpResolutionFailed {
uid: uuid::Uuid::nil(),
message: "not active".into(),
},
AgentTaskState::Failed,
Some(PlatformErrorCode::EnvironmentSetupFailed),
);
}
#[test]
fn mcp_startup_failed_is_failed_with_env_setup_and_per_server_details() {
let (state, update) = classify_driver_error(&AgentDriverError::MCPStartupFailed {
details: vec![
"'devin' failed to start: connection refused".to_string(),
"'datadog' did not start within 20s".to_string(),
],
});
assert_eq!(state, AgentTaskState::Failed);
assert_eq!(
update.error_code,
Some(PlatformErrorCode::EnvironmentSetupFailed)
);
// Each unavailable server is rendered as its own bullet line.
assert!(update
.message
.contains("- 'devin' failed to start: connection refused"));
assert!(update
.message
.contains("- 'datadog' did not start within 20s"));
}
#[test]
fn environment_setup_failed_is_failed() {
assert_state_and_code(
@@ -99,6 +177,35 @@ fn environment_not_found_is_failed_with_resource_not_found() {
);
}
#[test]
fn conversation_harness_mismatch_is_failed_with_env_setup() {
let (state, update) = classify_driver_error(&AgentDriverError::ConversationHarnessMismatch {
conversation_id: "conv-123".into(),
expected: "claude".into(),
got: "oz".into(),
});
assert_eq!(state, AgentTaskState::Failed);
assert_eq!(
update.error_code,
Some(PlatformErrorCode::EnvironmentSetupFailed)
);
assert!(update.message.contains("conv-123"));
assert!(update.message.contains("--harness claude"));
}
#[test]
fn conversation_resume_state_missing_is_failed_with_resource_not_found() {
let (state, update) =
classify_driver_error(&AgentDriverError::ConversationResumeStateMissing {
harness: "claude".into(),
conversation_id: "conv-123".into(),
});
assert_eq!(state, AgentTaskState::Failed);
assert_eq!(update.error_code, Some(PlatformErrorCode::ResourceNotFound));
assert!(update.message.contains("conv-123"));
assert!(update.message.contains("claude"));
}
// --- ShareSessionFailed variants ---
#[test]
@@ -153,3 +260,41 @@ fn conversation_blocked_is_blocked() {
assert_eq!(state, AgentTaskState::Blocked);
assert!(update.message.contains("rm -rf /"));
}
// --- Harness auth preflight errors ---
#[test]
fn harness_auth_check_failed_is_failed_with_auth_required() {
let (state, update) = classify_driver_error(&AgentDriverError::HarnessAuthCheckFailed {
harness: "claude".into(),
detail: "exit code 1".into(),
});
assert_eq!(state, AgentTaskState::Failed);
assert_eq!(
update.error_code,
Some(PlatformErrorCode::AuthenticationRequired)
);
assert!(update.message.contains("authentication check failed"));
assert!(update.message.contains("claude"));
}
// --- Runtime failure detection ---
#[test]
fn harness_runtime_failure_detected_is_failed_with_auth_required() {
let (state, update) = classify_driver_error(&AgentDriverError::HarnessRuntimeFailureDetected {
harness: "claude".into(),
pattern: "credit balance is too low".into(),
excerpt: "Error: Your credit balance is too low to make this request.".into(),
});
assert_eq!(state, AgentTaskState::Failed);
assert_eq!(
update.error_code,
Some(PlatformErrorCode::AuthenticationRequired)
);
// The user-visible message must surface both the matched pattern and
// the excerpt so on-call/users have actionable context.
assert!(update.message.contains("claude"));
assert!(update.message.contains("credit balance is too low"));
assert!(update.message.contains("Your credit balance is too low"));
}
@@ -0,0 +1,417 @@
/// Git credentials management for cloud agent sandboxes.
///
/// This module handles:
/// - Writing provider credentials to `~/.git-credentials`, plus GitHub
/// credentials to `~/.config/gh/hosts.yml`, without requiring environment
/// variables.
/// - One-time git configuration (`credential.helper store`, SSH→HTTPS URL
/// rewrites).
/// - Configuring the git user identity from the server-returned username/email.
/// - An async refresh loop that periodically fetches a fresh token from the
/// server and overwrites the credential files, keeping long-running agents
/// authenticated for their entire duration.
use std::{path::PathBuf, sync::Arc, time::Duration};
use anyhow::{Context, Result};
// Use the project's allowed Command wrapper (not std::process::Command, which is
// disallowed by clippy rules because it flashes a terminal window on Windows).
use command::blocking::Command as BlockingCommand;
use crate::server::server_api::ai::{AIClient, GitCredential};
/// How long to wait between credential refresh attempts (~50 minutes, staying
/// well ahead of the shortest-lived one-hour token expiry).
pub(crate) const GIT_CREDENTIALS_REFRESH_INTERVAL: Duration = Duration::from_secs(50 * 60);
const DEFAULT_GIT_NAME: &str = "Oz";
const DEFAULT_GIT_EMAIL: &str = "oz-agent@warp.dev";
const GITHUB_HOST: &str = "github.com";
const GH_HOSTS_FILENAME: &str = "hosts.yml";
const GLAB_HOST: &str = "gitlab.com";
const GLAB_CONFIG_FILENAME: &str = "config.yml";
fn home_dir() -> Result<PathBuf> {
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))
}
/// Write `content` to `path` using owner-only (0600) permissions.
///
/// On Unix the file is created with mode 0600 so no other user can read the
/// credential material. On non-Unix platforms the function falls back to the
/// standard write, relying on OS default permissions.
fn write_secret_file(path: &std::path::Path, content: &str) -> Result<()> {
#[cfg(unix)]
{
use std::io::Write as _;
use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("Failed to open {} for writing", path.display()))?;
file.set_permissions(std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("Failed to set permissions on {}", path.display()))?;
file.write_all(content.as_bytes())
.with_context(|| format!("Failed to write {}", path.display()))?;
}
#[cfg(not(unix))]
{
std::fs::write(path, content)
.with_context(|| format!("Failed to write {}", path.display()))?;
}
Ok(())
}
fn git_credentials_file_content(credentials: &[GitCredential]) -> String {
let mut content = String::new();
for cred in credentials {
let userinfo = match &cred.username {
Some(username) => format!("{username}:{}", cred.token),
None => format!("x-access-token:{}", cred.token),
};
content.push_str(&format!("https://{}@{}\n", userinfo, cred.host));
}
content
}
/// Write `~/.git-credentials` with the given credentials.
///
/// Each credential entry is formatted as:
/// - `https://{username}:{token}@{host}` when a username is present
/// - `https://x-access-token:{token}@{host}` for service-account tokens
///
/// The write is done atomically: a temporary file is written then renamed.
fn write_git_credentials_file(credentials: &[GitCredential]) -> Result<()> {
if credentials.is_empty() {
return Ok(());
}
let home = home_dir()?;
let path = home.join(".git-credentials");
let tmp_path = home.join(".git-credentials.tmp");
let content = git_credentials_file_content(credentials);
write_secret_file(&tmp_path, &content)?;
std::fs::rename(&tmp_path, &path).with_context(|| {
format!(
"Failed to rename {} to {}",
tmp_path.display(),
path.display()
)
})?;
Ok(())
}
/// Write `~/.config/gh/hosts.yml` so the `gh` CLI is authenticated.
///
/// The YAML format is stable for `gh` v2+:
/// ```yaml
/// github.com:
/// oauth_token: TOKEN
/// git_protocol: https
/// user: USERNAME
/// ```
///
/// The write is atomic: a temporary file is written then renamed.
fn write_gh_hosts_yml(credentials: &[GitCredential], home: &std::path::Path) -> Result<()> {
let github_credentials = credentials
.iter()
.filter(|credential| credential.host == GITHUB_HOST)
.collect::<Vec<_>>();
if github_credentials.is_empty() {
return Ok(());
}
let gh_config_dir = home.join(".config").join("gh");
std::fs::create_dir_all(&gh_config_dir)
.with_context(|| format!("Failed to create {}", gh_config_dir.display()))?;
let path = gh_config_dir.join(GH_HOSTS_FILENAME);
let tmp_path = gh_config_dir.join(format!("{GH_HOSTS_FILENAME}.tmp"));
let mut yaml = String::new();
for cred in github_credentials {
yaml.push_str(&format!("{}:\n", cred.host));
yaml.push_str(&format!(" oauth_token: {}\n", cred.token));
yaml.push_str(" git_protocol: https\n");
if let Some(username) = &cred.username {
yaml.push_str(&format!(" user: {username}\n"));
}
}
write_secret_file(&tmp_path, &yaml)?;
std::fs::rename(&tmp_path, &path).with_context(|| {
format!(
"Failed to rename {} to {}",
tmp_path.display(),
path.display()
)
})?;
Ok(())
}
/// Write `~/.config/glab-cli/config.yml` so the `glab` CLI is authenticated.
///
/// The YAML format for glab is:
/// ```yaml
/// hosts:
/// gitlab.com:
/// token: TOKEN
/// git_protocol: https
/// api_protocol: https
/// ```
///
/// The write is atomic: a temporary file is written then renamed.
fn write_glab_config(credentials: &[GitCredential], home: &std::path::Path) -> Result<()> {
let gitlab_credentials = credentials
.iter()
.filter(|credential| credential.host == GLAB_HOST)
.collect::<Vec<_>>();
if gitlab_credentials.is_empty() {
return Ok(());
}
let glab_config_dir = home.join(".config").join("glab-cli");
std::fs::create_dir_all(&glab_config_dir)
.with_context(|| format!("Failed to create {}", glab_config_dir.display()))?;
let path = glab_config_dir.join(GLAB_CONFIG_FILENAME);
let tmp_path = glab_config_dir.join(format!("{GLAB_CONFIG_FILENAME}.tmp"));
let mut yaml = String::new();
yaml.push_str("hosts:\n");
for cred in gitlab_credentials {
yaml.push_str(&format!(" {}:\n", cred.host));
yaml.push_str(&format!(" token: {}\n", cred.token));
yaml.push_str(" git_protocol: https\n");
yaml.push_str(" api_protocol: https\n");
}
write_secret_file(&tmp_path, &yaml)?;
std::fs::rename(&tmp_path, &path).with_context(|| {
format!(
"Failed to rename {} to {}",
tmp_path.display(),
path.display()
)
})?;
Ok(())
}
/// Formats non-sensitive metadata for verifying local credential injection.
pub(crate) fn credential_diagnostics(credentials: &[GitCredential]) -> String {
credentials
.iter()
.map(|credential| {
format!(
"{}(token_present={}, username_present={})",
credential.host,
!credential.token.is_empty(),
credential.username.is_some()
)
})
.collect::<Vec<_>>()
.join(", ")
}
pub(crate) fn write_git_credentials(credentials: &[GitCredential]) -> Result<()> {
if credentials.is_empty() {
return Ok(());
}
write_git_credentials_file(credentials)?;
let home = home_dir()?;
write_gh_hosts_yml(credentials, &home)?;
write_glab_config(credentials, &home)?;
log::info!(
"Wrote {} git credential(s) to the local credential store: {}",
credentials.len(),
credential_diagnostics(credentials)
);
Ok(())
}
pub(crate) fn configure_git_credentials(credentials: &[GitCredential]) -> Result<()> {
if credentials.is_empty() {
return Ok(());
}
setup_git_config(credentials);
configure_git_identity(credentials);
write_git_credentials(credentials)
}
/// Run a git config command, logging a warning on failure rather than
/// propagating the error (git may not be installed in all sandboxes).
fn run_git_config(key: &str, value: &str) {
match BlockingCommand::new("git")
.args(["config", "--global", key, value])
.output()
{
Ok(output) if output.status.success() => {}
Ok(output) => {
log::warn!(
"git config --global {key} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Err(e) => {
log::warn!("Failed to run git config --global {key}: {e}");
}
}
}
/// Like [`run_git_config`] but passes `--add` so the new value is appended to
/// any existing values for `key` rather than replacing them.
fn run_git_config_add(key: &str, value: &str) {
match BlockingCommand::new("git")
.args(["config", "--global", "--add", key, value])
.output()
{
Ok(output) if output.status.success() => {}
Ok(output) => {
log::warn!(
"git config --global --add {key} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Err(e) => {
log::warn!("Failed to run git config --global --add {key}: {e}");
}
}
}
/// Run one-time git configuration that is set at startup and never needs to
/// be refreshed:
/// - `credential.helper store` so git reads `~/.git-credentials`
/// - SSH→HTTPS URL rewrites for each credential host, covering both the
/// scp-style (`git@{host}:`) and explicit-protocol (`ssh://git@{host}/`)
/// URL forms, so operations on either form use HTTPS credentials instead
/// of looking for an SSH key.
pub(crate) fn setup_git_config(credentials: &[GitCredential]) {
run_git_config("credential.helper", "store");
// Use --add for both forms per host so all values coexist as a
// multi-value key rather than each entry overwriting the previous one.
for cred in credentials {
let host = &cred.host;
run_git_config_add(
&format!("url.https://{host}/.insteadOf"),
&format!("ssh://git@{host}/"),
);
run_git_config_add(
&format!("url.https://{host}/.insteadOf"),
&format!("git@{host}:"),
);
}
}
/// Configure the git user identity from the server-returned credential.
///
/// Uses the first credential's `username`/`email` fields, falling back to the
/// Oz defaults when either is absent (e.g. service-account principals).
pub(crate) fn configure_git_identity(credentials: &[GitCredential]) {
let (name, email) = credentials
.first()
.map(|c| {
(
c.username.as_deref().unwrap_or(DEFAULT_GIT_NAME),
c.email.as_deref().unwrap_or(DEFAULT_GIT_EMAIL),
)
})
.unwrap_or((DEFAULT_GIT_NAME, DEFAULT_GIT_EMAIL));
run_git_config("user.name", name);
run_git_config("user.email", email);
}
/// Perform one git credentials refresh attempt.
///
/// Returns `Ok(())` on success (including when the server returns no
/// credentials). Returns `Err` when the workload-token issuance or the server
/// API call fails — these are transient failures worth retrying.
#[tracing::instrument(name = "git_credentials::try_refresh", skip_all, err, fields(
tags.cloud_agent = true,
task_id,
))]
async fn try_refresh(task_id: &str, ai_client: &Arc<dyn AIClient>) -> Result<()> {
let workload_token =
warp_isolation_platform::issue_workload_token(Some(Duration::from_secs(5 * 60)))
.await
.context("Failed to issue workload token for git credentials refresh")?
.token;
let credentials = ai_client
.get_task_git_credentials(task_id.to_string(), workload_token)
.await
.context("Failed to fetch git credentials from server")?;
if credentials.is_empty() {
log::debug!("No git credentials returned during refresh; skipping file write");
return Ok(());
}
if let Err(e) = write_git_credentials(&credentials) {
log::warn!("Failed to write refreshed git credentials: {e:#}");
} else {
log::info!("Git credentials refreshed successfully");
}
Ok(())
}
/// Infinite async loop that refreshes git credentials every
/// [`GIT_CREDENTIALS_REFRESH_INTERVAL`].
///
/// On each iteration:
/// 1. Issue a short-lived workload token.
/// 2. Call `taskGitCredentials` to get a fresh token from the server.
/// 3. Overwrite `~/.git-credentials` and refresh GitHub credentials in
/// `~/.config/gh/hosts.yml`.
///
/// On transient failure, the refresh is retried up to three times with
/// exponential backoff (1 min, 2 min, 4 min), keeping all retries within the
/// ~10-minute buffer before the one-hour token expires. If all retries fail,
/// a warning is logged and the next refresh is scheduled after the normal
/// interval.
///
/// This future never resolves — it is designed to be raced with the harness
/// execution future via `futures::select!` and dropped when the harness
/// completes.
pub(crate) async fn refresh_loop(task_id: String, ai_client: Arc<dyn AIClient>) {
loop {
warpui::r#async::Timer::after(GIT_CREDENTIALS_REFRESH_INTERVAL).await;
log::info!("Refreshing git credentials for task {task_id}");
let backoff_delays = [
Duration::from_secs(60),
Duration::from_secs(2 * 60),
Duration::from_secs(4 * 60),
];
let mut attempt = 0usize;
loop {
match try_refresh(&task_id, &ai_client).await {
Ok(()) => break,
Err(e) if attempt < backoff_delays.len() => {
let delay = backoff_delays[attempt];
log::warn!(
"Git credentials refresh failed (attempt {}): {e:#}; retrying in {}s",
attempt + 1,
delay.as_secs()
);
warpui::r#async::Timer::after(delay).await;
attempt += 1;
}
Err(e) => {
log::warn!(
"Git credentials refresh failed after {} attempts: {e:#}; \
credentials may expire before next refresh cycle",
attempt + 1
);
break;
}
}
}
}
}
#[cfg(test)]
#[path = "git_credentials_tests.rs"]
mod tests;
@@ -0,0 +1,204 @@
use super::*;
#[test]
fn write_gh_hosts_yml_uses_gh_cli_filename() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let gh_config_dir = temp_dir.path().join(".config").join("gh");
write_gh_hosts_yml(
&[GitCredential {
token: "token".to_string(),
username: Some("octocat".to_string()),
email: Some("octocat@example.com".to_string()),
host: "github.com".to_string(),
}],
temp_dir.path(),
)?;
let hosts_path = gh_config_dir.join(GH_HOSTS_FILENAME);
assert!(hosts_path.exists());
assert!(!gh_config_dir
.join(format!("{GH_HOSTS_FILENAME}.tmp"))
.exists());
let hosts = std::fs::read_to_string(hosts_path)?;
assert!(hosts.contains("github.com:"));
assert!(hosts.contains(" oauth_token: token"));
assert!(hosts.contains(" git_protocol: https"));
assert!(hosts.contains(" user: octocat"));
Ok(())
}
#[test]
fn write_gh_hosts_yml_excludes_gitlab_credentials() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let gh_config_dir = temp_dir.path().join(".config").join("gh");
write_gh_hosts_yml(
&[
GitCredential {
token: "github-token".to_string(),
username: Some("octocat".to_string()),
email: None,
host: "github.com".to_string(),
},
GitCredential {
token: "gitlab-token".to_string(),
username: Some("oauth2".to_string()),
email: None,
host: "gitlab.com".to_string(),
},
],
temp_dir.path(),
)?;
let hosts = std::fs::read_to_string(gh_config_dir.join(GH_HOSTS_FILENAME))?;
assert!(hosts.contains("github.com:"));
assert!(!hosts.contains("gitlab.com:"));
assert!(!hosts.contains("gitlab-token"));
Ok(())
}
#[test]
fn write_gh_hosts_yml_skips_gitlab_only_credentials() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
write_gh_hosts_yml(
&[GitCredential {
token: "gitlab-token".to_string(),
username: Some("oauth2".to_string()),
email: None,
host: "gitlab.com".to_string(),
}],
temp_dir.path(),
)?;
assert!(!temp_dir.path().join(".config").join("gh").exists());
Ok(())
}
#[test]
fn git_credentials_file_content_includes_each_provider_host() {
let content = git_credentials_file_content(&[
GitCredential {
token: "github-token".to_string(),
username: None,
email: None,
host: "github.com".to_string(),
},
GitCredential {
token: "gitlab-token".to_string(),
username: Some("oauth2".to_string()),
email: None,
host: "gitlab.com".to_string(),
},
]);
assert_eq!(
content,
"https://x-access-token:github-token@github.com\n\
https://oauth2:gitlab-token@gitlab.com\n"
);
}
#[test]
fn credential_diagnostics_reports_presence_without_values() {
let diagnostics = credential_diagnostics(&[GitCredential {
token: "secret-token".to_string(),
username: Some("oauth2".to_string()),
email: Some("user@example.com".to_string()),
host: "gitlab.com".to_string(),
}]);
assert_eq!(
diagnostics,
"gitlab.com(token_present=true, username_present=true)"
);
assert!(!diagnostics.contains("secret-token"));
assert!(!diagnostics.contains("oauth2"));
assert!(!diagnostics.contains("user@example.com"));
}
#[test]
fn write_glab_config_uses_glab_cli_filename() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let glab_config_dir = temp_dir.path().join(".config").join("glab-cli");
write_glab_config(
&[GitCredential {
token: "gitlab-token".to_string(),
username: Some("oauth2".to_string()),
email: Some("user@example.com".to_string()),
host: "gitlab.com".to_string(),
}],
temp_dir.path(),
)?;
let config_path = glab_config_dir.join(GLAB_CONFIG_FILENAME);
assert!(config_path.exists());
assert!(!glab_config_dir
.join(format!("{GLAB_CONFIG_FILENAME}.tmp"))
.exists());
let config = std::fs::read_to_string(config_path)?;
assert!(config.contains("hosts:"));
assert!(config.contains(" gitlab.com:"));
assert!(config.contains(" token: gitlab-token"));
assert!(config.contains(" git_protocol: https"));
assert!(config.contains(" api_protocol: https"));
Ok(())
}
#[test]
fn write_glab_config_excludes_github_credentials() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let glab_config_dir = temp_dir.path().join(".config").join("glab-cli");
write_glab_config(
&[
GitCredential {
token: "github-token".to_string(),
username: Some("octocat".to_string()),
email: None,
host: "github.com".to_string(),
},
GitCredential {
token: "gitlab-token".to_string(),
username: Some("oauth2".to_string()),
email: None,
host: "gitlab.com".to_string(),
},
],
temp_dir.path(),
)?;
let config = std::fs::read_to_string(glab_config_dir.join(GLAB_CONFIG_FILENAME))?;
assert!(config.contains("gitlab.com:"));
assert!(!config.contains("github.com:"));
assert!(!config.contains("github-token"));
Ok(())
}
#[test]
fn write_glab_config_skips_github_only_credentials() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
write_glab_config(
&[GitCredential {
token: "github-token".to_string(),
username: Some("octocat".to_string()),
email: None,
host: "github.com".to_string(),
}],
temp_dir.path(),
)?;
assert!(!temp_dir.path().join(".config").join("glab-cli").exists());
Ok(())
}
+281 -127
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -12,42 +13,55 @@ use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tempfile::NamedTempFile;
use uuid::Uuid;
use galaxy_managed_secrets::ManagedSecretValue;
use super::super::terminal::{CommandHandle, TerminalDriver};
use super::super::{AgentDriver, AgentDriverError};
use super::claude_transcript::{
claude_config_dir, home_dir_for_claude_config, read_envelope, rehydrate_claude_transcript,
ClaudeResumeInfo, ClaudeTranscriptEnvelope,
};
use super::json_utils::{read_json_file_or_default, write_json_file};
use super::{
cli_agent_session_status, write_temp_file, HarnessCleanupDisposition, HarnessRunner,
JSONMCPServer, ResumePayload, SavePoint, ThirdPartyHarness,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_sdk::setup_observability::{
OzRunTimelineEvent, SetupClientEventReporter, SetupStep,
};
use crate::ai::ambient_agents::task::HarnessModelConfig;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::mcp::JSONTransportType;
use crate::server::server_api::harness_support::{upload_to_target, HarnessSupportClient};
use crate::server::server_api::ServerApi;
use crate::terminal::model::block::BlockId;
use crate::terminal::model::session::ExecuteCommandOptions;
use crate::terminal::CLIAgent;
use super::super::terminal::{CommandHandle, TerminalDriver};
use super::super::{AgentDriver, AgentDriverError};
use super::claude_transcript::{
claude_config_dir, read_envelope, write_envelope, write_session_index_entry, ClaudeResumeInfo,
ClaudeTranscriptEnvelope,
};
use super::json_utils::{read_json_file_or_default, write_json_file};
use super::{
write_temp_file, HarnessRunner, ManagedSecretValue, ResumePayload, SavePoint, ThirdPartyHarness,
};
mod parent_bridge;
mod wake_driver;
#[cfg(test)]
use super::super::OZ_MESSAGE_LISTENER_STATE_ROOT_ENV;
use parent_bridge::MessageBridge;
#[cfg(test)]
use parent_bridge::{
acknowledge_parent_bridge_hook_output, ensure_parent_bridge_state_dir,
parent_bridge_char_count, parent_bridge_hook_output_ack_file, parent_bridge_hook_output_file,
parent_bridge_root, parent_bridge_staged_message_path, parent_bridge_surfaced_message_path,
prepare_parent_bridge_hook_output, render_parent_bridge_message_block,
stage_parent_bridge_message, MessageBridgeHookOutput, MessageBridgeMessageRecord,
parent_bridge_char_count, parent_bridge_event_cursor_file, parent_bridge_hook_output_ack_file,
parent_bridge_hook_output_file, parent_bridge_root, parent_bridge_staged_message_path,
parent_bridge_surfaced_message_path, prepare_parent_bridge_hook_output,
prime_parent_bridge_staged_for_self_managed_wake, read_parent_bridge_event_cursor,
render_parent_bridge_message_block, stage_parent_bridge_message,
write_parent_bridge_event_cursor, MessageBridgeHookOutput, MessageBridgeMessageRecord,
MESSAGE_BRIDGE_CONTEXT_PREAMBLE,
};
use parent_bridge::{MessageBridge, MessageBridgeCleanupDisposition};
#[cfg(test)]
use shell_words::quote as shell_quote;
#[cfg(test)]
use wake_driver::{ClaudeWakeRemoteContext, CLAUDE_WAKE_PROMPT_FILE_NAME};
#[cfg(test)]
use super::super::OZ_MESSAGE_LISTENER_STATE_ROOT_ENV;
pub(crate) struct ClaudeHarness;
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl ThirdPartyHarness for ClaudeHarness {
@@ -63,18 +77,33 @@ impl ThirdPartyHarness for ClaudeHarness {
Some("https://code.claude.com/docs/en/quickstart")
}
fn prepare_environment_config(
&self,
working_dir: &Path,
_system_prompt: Option<&str>,
secrets: &HashMap<String, ManagedSecretValue>,
) -> Result<(), AgentDriverError> {
prepare_claude_environment_config(working_dir, secrets).map_err(|error| {
AgentDriverError::HarnessConfigSetupFailed {
harness: self.cli_agent().command_prefix().to_owned(),
error,
}
})
fn auth_check_command(&self) -> Option<String> {
let cli = self.cli_agent().command_prefix();
Some(format!("{cli} auth status --json"))
}
fn runtime_error_patterns(&self) -> &'static [&'static str] {
&[
// Out-of-credits / billing.
"Credit balance too low",
// Plan/usage limits emitted as `You've hit your <kind> limit`.
// We match on the common prefix so the variants (session,
// weekly, Opus, etc.) all hit.
"You've hit your",
// Invalid or malformed API key.
"Invalid API key",
"This organization has been disabled",
"belongs to a disabled organization",
// OAuth / login state.
"Not logged in",
"OAuth token revoked",
"OAuth token has expired",
// Routines disabled by org policy.
"Routines are disabled by your organization's policy",
// Generic upstream API failures Claude Code surfaces verbatim.
"API Error: Request rejected (429)",
"authentication_error",
]
}
/// Fetch the Claude Code transcript for the current task's conversation and wrap it
@@ -86,28 +115,9 @@ impl ThirdPartyHarness for ClaudeHarness {
conversation_id: &AIConversationId,
harness_support_client: Arc<dyn HarnessSupportClient>,
) -> Result<Option<ResumePayload>, AgentDriverError> {
let conversation_id_str = conversation_id.to_string();
let bytes = harness_support_client
.fetch_transcript()
.await
.map_err(|err| {
// A 404 from the server maps to "no stored transcript" so the CLI can tell
// the user the prior run never saved state.
let message = format!("{err:#}").to_lowercase();
if message.contains("status 404") {
AgentDriverError::ConversationResumeStateMissing {
harness: "claude".to_string(),
conversation_id: conversation_id_str.clone(),
}
} else {
AgentDriverError::ConversationLoadFailed(format!("{err:#}"))
}
})?;
let envelope: ClaudeTranscriptEnvelope = serde_json::from_slice(&bytes).map_err(|err| {
AgentDriverError::ConversationLoadFailed(format!(
"Failed to deserialize Claude transcript for {conversation_id_str}: {err:#}"
))
})?;
let envelope: ClaudeTranscriptEnvelope =
super::fetch_transcript_envelope("claude", conversation_id, harness_support_client)
.await?;
let session_id = envelope.uuid;
Ok(Some(ResumePayload::Claude(ClaudeResumeInfo {
conversation_id: *conversation_id,
@@ -121,24 +131,43 @@ impl ThirdPartyHarness for ClaudeHarness {
prompt: &str,
system_prompt: Option<&str>,
resumption_prompt: Option<&str>,
context: Option<&str>,
working_dir: &Path,
task_id: Option<AmbientAgentTaskId>,
server_api: Arc<ServerApi>,
terminal_driver: ModelHandle<TerminalDriver>,
resume: Option<ResumePayload>,
resolved_env_vars: &HashMap<OsString, OsString>,
_resolved_secrets: &HashMap<String, ManagedSecretValue>,
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
_third_party_harness_model_config: Option<&HarnessModelConfig>,
) -> Result<Box<dyn HarnessRunner>, AgentDriverError> {
// Extract the Claude variant; any other variant is ignored since it belongs to a
// different harness. Today there are no other variants, but this keeps the shape
// ready for future CLI-specific payloads.
let claude_resume = resume.map(|payload| match payload {
ResumePayload::Claude(info) => info,
});
// Prepare the environment config files.
prepare_claude_environment_config(working_dir, resolved_env_vars).map_err(|error| {
AgentDriverError::HarnessConfigSetupFailed {
harness: self.cli_agent().command_prefix().to_owned(),
error,
}
})?;
// The ResumePayload shouldn't contain non-Claude information, error if it does.
let claude_resume = resume.map(ClaudeResumeInfo::try_from).transpose()?;
// Claude treats the user-turn message as immediate intent, so the resumption preamble
// is most reliable when prepended directly to the prompt that gets piped into the CLI.
let owned_prompt = match resumption_prompt {
Some(preamble) if !preamble.is_empty() => format!("{preamble}\n\n{prompt}"),
_ => prompt.to_string(),
};
// and server context are most reliable when prepended directly to the prompt that gets
// piped into the CLI. Order: resumption_prompt → context → prompt
let mut parts: Vec<&str> = Vec::new();
if let Some(preamble) = resumption_prompt {
if !preamble.is_empty() {
parts.push(preamble);
}
}
if let Some(ctx) = context {
if !ctx.is_empty() {
parts.push(ctx);
}
}
parts.push(prompt);
let owned_prompt = parts.join("\n\n");
Ok(Box::new(ClaudeHarnessRunner::new(
self.cli_agent().command_prefix(),
&owned_prompt,
@@ -148,8 +177,13 @@ impl ThirdPartyHarness for ClaudeHarness {
server_api,
terminal_driver,
claude_resume,
resolved_mcp_servers,
)?))
}
fn requires_verified_platform_plugin(&self) -> bool {
true
}
}
/// Format slug sent to the server when creating a Claude Code conversation.
@@ -169,6 +203,7 @@ fn claude_command(
session_id: &Uuid,
prompt_path: &str,
system_prompt_path: Option<&str>,
mcp_config_path: Option<&str>,
resuming: bool,
) -> String {
let flag = if resuming { "--resume" } else { "--session-id" };
@@ -176,6 +211,9 @@ fn claude_command(
if let Some(sp_path) = system_prompt_path {
let _ = write!(cmd, " --append-system-prompt-file '{sp_path}'");
}
if let Some(mcp_path) = mcp_config_path {
let _ = write!(cmd, " --mcp-config '{mcp_path}'");
}
format!("{cmd} < '{prompt_path}'")
}
@@ -198,6 +236,8 @@ struct ClaudeHarnessRunner {
_temp_prompt_file: NamedTempFile,
/// Held so the system prompt temp file is cleaned up when the runner is dropped.
_temp_system_prompt_file: Option<NamedTempFile>,
/// Held so the MCP config temp file lives until the CLI exits.
_temp_mcp_config_file: Option<NamedTempFile>,
client: Arc<dyn HarnessSupportClient>,
server_api: Arc<ServerApi>,
terminal_driver: ModelHandle<TerminalDriver>,
@@ -224,49 +264,44 @@ impl ClaudeHarnessRunner {
server_api: Arc<ServerApi>,
terminal_driver: ModelHandle<TerminalDriver>,
resume: Option<ClaudeResumeInfo>,
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
) -> Result<Self, AgentDriverError> {
// Write the prompt to a temp file so we can feed it via stdin redirect,
// avoiding shell-quoting issues with complex content (e.g. skill instructions).
let temp_file = write_temp_file("oz_prompt_", prompt)?;
let temp_file = write_temp_file("oz_prompt_", prompt, ".txt")?;
let prompt_path = temp_file.path().display().to_string();
let (session_id, preexisting_conversation_id, resuming) = match resume {
let (session_id, preexisting_conversation_id) = match resume {
Some(ClaudeResumeInfo {
conversation_id,
session_id,
mut envelope,
}) => {
// Rehydrate the stored envelope under the current working directory so
// `claude --resume <uuid>` finds the jsonl under ~/.claude/projects/<encoded_cwd>/.
// The original envelope's cwd usually points at the cloud sandbox path, which
// doesn't exist locally.
envelope.cwd = working_dir.to_path_buf();
let config_root = claude_config_dir().map_err(|e| {
AgentDriverError::ConfigBuildFailed(
e.context("Failed to resolve Claude config dir"),
)
})?;
write_envelope(&envelope, &config_root).map_err(|e| {
AgentDriverError::ConfigBuildFailed(
e.context("Failed to rehydrate Claude transcript"),
)
})?;
// Index write is best-effort: upstream Claude versions vary in how they use
// `sessions-index.json`, so losing the index entry shouldn't abort the run.
if let Err(e) = write_session_index_entry(session_id, working_dir, &config_root) {
log::warn!("Failed to update Claude sessions-index.json: {e:#}");
}
(session_id, Some(conversation_id), true)
rehydrate_claude_transcript(&mut envelope, working_dir)
.map_err(AgentDriverError::ConfigBuildFailed)?;
(session_id, Some(conversation_id))
}
None => (Uuid::new_v4(), None, false),
None => (Uuid::new_v4(), None),
};
let temp_system_prompt_file = system_prompt
.map(|sp| write_temp_file("oz_system_prompt_", sp))
.map(|sp| write_temp_file("oz_system_prompt_", sp, ".txt"))
.transpose()?;
let system_prompt_path = temp_system_prompt_file
.as_ref()
.map(|f| f.path().display().to_string());
let temp_mcp_config_file = (!resolved_mcp_servers.is_empty())
.then(|| {
let mcp_json = serialize_claude_mcp_config(resolved_mcp_servers)
.map_err(AgentDriverError::ConfigBuildFailed)?;
write_temp_file("oz_mcp_config_", &mcp_json, ".json")
})
.transpose()?;
let mcp_config_path = temp_mcp_config_file
.as_ref()
.map(|f| f.path().display().to_string());
let parent_bridge = task_id
.map(|task_id| MessageBridge::new(task_id.to_string(), session_id))
.transpose()
@@ -279,11 +314,13 @@ impl ClaudeHarnessRunner {
&session_id,
&prompt_path,
system_prompt_path.as_deref(),
resuming,
mcp_config_path.as_deref(),
preexisting_conversation_id.is_some(),
),
cli_name: cli_command.to_string(),
_temp_prompt_file: temp_file,
_temp_system_prompt_file: temp_system_prompt_file,
_temp_mcp_config_file: temp_mcp_config_file,
client,
server_api,
terminal_driver,
@@ -362,9 +399,33 @@ impl ClaudeHarnessRunner {
.await
}
fn cleanup_parent_bridge(&self) -> Result<()> {
async fn should_preserve_parent_bridge(
&self,
cleanup_disposition: HarnessCleanupDisposition,
foreground: &ModelSpawner<AgentDriver>,
) -> bool {
if !matches!(
cleanup_disposition,
HarnessCleanupDisposition::PreserveResumptionStateIfSupported
) {
return false;
}
!matches!(
cli_agent_session_status(&self.terminal_driver, foreground).await,
Some(crate::terminal::cli_agent_sessions::CLIAgentSessionStatus::Blocked { .. })
| Some(crate::terminal::cli_agent_sessions::CLIAgentSessionStatus::InProgress)
)
}
fn cleanup_parent_bridge(&self, preserve_state: bool) -> Result<()> {
if let Some(parent_bridge) = self.parent_bridge.as_ref() {
parent_bridge.cleanup()?;
let cleanup_disposition = if preserve_state {
MessageBridgeCleanupDisposition::PreserveState
} else {
MessageBridgeCleanupDisposition::RemoveState
};
parent_bridge.cleanup(cleanup_disposition)?;
}
Ok(())
}
@@ -373,9 +434,14 @@ impl ClaudeHarnessRunner {
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl HarnessRunner for ClaudeHarnessRunner {
fn harness_name(&self) -> &str {
&self.cli_name
}
async fn start(
&self,
foreground: &ModelSpawner<AgentDriver>,
setup_events: &SetupClientEventReporter,
) -> Result<CommandHandle, AgentDriverError> {
// When resuming, we already have a server conversation id from the prior run.
// Otherwise create a fresh external conversation record for this run.
@@ -387,14 +453,17 @@ impl HarnessRunner for ClaudeHarnessRunner {
id
}
None => {
let id = self
.client
.create_external_conversation(CLAUDE_CODE_FORMAT)
.await
.map_err(|e| {
log::error!("Failed to create external conversation: {e}");
AgentDriverError::ConfigBuildFailed(e)
})?;
let id = setup_events
.record_result(SetupStep::ThirdPartyHarnessExternalConversation, async {
self.client
.create_external_conversation(CLAUDE_CODE_FORMAT)
.await
.map_err(|e| {
log::error!("Failed to create external conversation: {e}");
AgentDriverError::ConfigBuildFailed(e)
})
})
.await?;
log::info!("Created external conversation {id}");
id
}
@@ -414,7 +483,7 @@ impl HarnessRunner for ClaudeHarnessRunner {
{
Ok(command_handle) => command_handle,
Err(err) => {
self.cleanup_parent_bridge()
self.cleanup_parent_bridge(false)
.map_err(AgentDriverError::ConfigBuildFailed)?;
return Err(err);
}
@@ -426,6 +495,10 @@ impl HarnessRunner for ClaudeHarnessRunner {
block_id: command_handle.block_id().clone(),
};
setup_events
.post_timeline_event(OzRunTimelineEvent::AgentStarted)
.await;
Ok(command_handle)
}
@@ -494,9 +567,16 @@ impl HarnessRunner for ClaudeHarnessRunner {
Ok(())
}
async fn cleanup(&self, _foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
async fn cleanup(
&self,
cleanup_disposition: HarnessCleanupDisposition,
foreground: &ModelSpawner<AgentDriver>,
) -> Result<()> {
self.flush_parent_bridge_acks().await?;
self.cleanup_parent_bridge()
let preserve_state = self
.should_preserve_parent_bridge(cleanup_disposition, foreground)
.await;
self.cleanup_parent_bridge(preserve_state)
}
}
@@ -526,21 +606,31 @@ async fn upload_transcript(
.with_context(|| format!("Failed to get transcript upload target for {conversation_id}"))?;
upload_to_target(client.http_client(), &target, body).await
}
fn prepare_claude_environment_config(
pub(crate) fn prepare_claude_environment_config(
working_dir: &Path,
secrets: &HashMap<String, ManagedSecretValue>,
resolved_env_vars: &HashMap<OsString, OsString>,
) -> Result<()> {
let home_dir =
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?;
let claude_json_path = home_dir.join(CLAUDE_JSON_FILE_NAME);
let claude_json_path = claude_global_config_path()?;
let claude_settings_path = claude_config_dir()?.join(CLAUDE_SETTINGS_FILE_NAME);
let api_key_suffix = resolve_anthropic_api_key_suffix(secrets);
let api_key_suffix = resolve_anthropic_api_key_suffix(resolved_env_vars);
prepare_claude_config(&claude_json_path, working_dir, api_key_suffix.as_deref())?;
prepare_claude_settings(&claude_settings_path)?;
Ok(())
}
// This function is used specifically for determining where to land `.claude.json`.
fn claude_global_config_path() -> Result<PathBuf> {
if let Ok(dir) = std::env::var("CLAUDE_CONFIG_DIR") {
if !dir.is_empty() {
return Ok(PathBuf::from(dir).join(CLAUDE_JSON_FILE_NAME));
}
}
home_dir_for_claude_config()
.map(|home| home.join(CLAUDE_JSON_FILE_NAME))
.ok_or_else(|| anyhow::anyhow!("could not determine home directory"))
}
fn prepare_claude_config(
claude_json_path: &Path,
working_dir: &Path,
@@ -628,27 +718,23 @@ struct ClaudeSettings {
extra: Map<String, Value>,
}
/// Try to get the last 20 chars of the ANTHROPIC_API_KEY from the secrets map,
/// where 20 chars is the suffix length that Claude Code truncates keys to.
/// Falls back to the environment variable.
/// Try to get the last 20 chars of the ANTHROPIC_API_KEY, where 20 chars is the
/// suffix length that Claude Code truncates keys to.
fn resolve_anthropic_api_key_suffix(
secrets: &HashMap<String, ManagedSecretValue>,
resolved_env_vars: &HashMap<OsString, OsString>,
) -> Option<String> {
// First, check for an AnthropicApiKey variant anywhere in the secrets map,
// since the secret name doesn't necessarily match the env var.
for secret in secrets.values() {
if let ManagedSecretValue::AnthropicApiKey { api_key } = secret {
return suffix_of(api_key).map(str::to_owned);
// Worker-injected process env wins.
if let Ok(key) = std::env::var(ANTHROPIC_API_KEY_ENV) {
if !key.is_empty() {
return suffix_of(&key).map(str::to_owned);
}
}
// Then check for a RawValue stored under the env var name.
if let Some(ManagedSecretValue::RawValue { value }) = secrets.get(ANTHROPIC_API_KEY_ENV) {
return suffix_of(value).map(str::to_owned);
}
// Fall back to the environment variable, which a user may have set separately in the env.
std::env::var(ANTHROPIC_API_KEY_ENV)
.ok()
.and_then(|k| suffix_of(&k).map(str::to_owned))
// Otherwise use the resolved value from the secrets map.
resolved_env_vars
.get(OsStr::new(ANTHROPIC_API_KEY_ENV))
.and_then(|v| v.to_str())
.and_then(suffix_of)
.map(str::to_owned)
}
fn suffix_of(key: &str) -> Option<&str> {
@@ -659,6 +745,74 @@ fn suffix_of(key: &str) -> Option<&str> {
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ClaudeMcpConfig {
mcp_servers: HashMap<String, ClaudeMcpServerEntry>,
}
#[derive(Serialize)]
#[serde(tag = "type")]
enum ClaudeMcpServerEntry {
#[serde(rename = "stdio")]
Stdio {
command: String,
args: Vec<String>,
#[serde(skip_serializing_if = "HashMap::is_empty")]
env: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
cwd: Option<String>,
},
#[serde(rename = "http")]
Http {
url: String,
#[serde(skip_serializing_if = "HashMap::is_empty")]
headers: HashMap<String, String>,
},
}
impl ClaudeMcpServerEntry {
fn from_json_mcp_server(server: &JSONMCPServer) -> Self {
match &server.transport_type {
JSONTransportType::CLIServer {
command,
args,
env,
working_directory,
} => Self::Stdio {
command: command.clone(),
args: args.clone(),
env: env.clone(),
cwd: working_directory.clone(),
},
JSONTransportType::SSEServer { url, headers } => Self::Http {
url: url.clone(),
headers: headers.clone(),
},
}
}
}
/// Serialize resolved MCP servers into Claude Code's `--mcp-config` JSON format.
///
/// Produces `{ "mcpServers": { "name": { "type": "stdio"|"http", ... }, ... } }`.
pub(crate) fn serialize_claude_mcp_config(
servers: &HashMap<String, JSONMCPServer>,
) -> Result<String> {
let config = ClaudeMcpConfig {
mcp_servers: servers
.iter()
.map(|(name, server)| {
(
name.clone(),
ClaudeMcpServerEntry::from_json_mcp_server(server),
)
})
.collect(),
};
serde_json::to_string_pretty(&config).context("Failed to serialize Claude MCP config")
}
#[cfg(test)]
#[path = "claude_code_tests.rs"]
mod tests;
@@ -25,22 +25,25 @@ use uuid::Uuid;
use crate::ai::agent_events::{
run_agent_event_driver, AgentEventConsumer, AgentEventConsumerControlFlow,
AgentEventDriverConfig, MessageHydrator, ServerApiAgentEventSource,
AgentEventDriverConfig, AgentMessageEventMetadata, MessageHydrator, ServerApiAgentEventSource,
};
use crate::ai::agent_sdk::driver::{AgentDriver, OZ_MESSAGE_LISTENER_STATE_ROOT_ENV};
use crate::server::server_api::ai::AgentRunEvent;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::server::server_api::ai::{AIClient, AgentRunEvent};
use crate::server::server_api::ServerApi;
const LEGACY_MESSAGE_LISTENER_STATE_ROOT_ENV: &str = "OZ_PARENT_STATE_ROOT";
const PARENT_BRIDGE_DEFAULT_STATE_ROOT: &str = ".claude-code/oz-parent-bridge";
const PARENT_BRIDGE_SURFACED_DIR_NAME: &str = "surfaced";
const PARENT_BRIDGE_EVENT_CURSOR_FILE_NAME: &str = "event-cursor.json";
const PARENT_BRIDGE_HOOK_OUTPUT_FILE_NAME: &str = "pending-hook-output.json";
const PARENT_BRIDGE_HOOK_OUTPUT_ACK_FILE_NAME: &str = "pending-hook-output.ack";
const PARENT_BRIDGE_MAX_CONTEXT_CHARS_ENV: &str = "OZ_PARENT_MAX_CONTEXT_CHARS";
const PARENT_BRIDGE_DEFAULT_MAX_CONTEXT_CHARS: usize = 6000;
pub(super) const MESSAGE_BRIDGE_CONTEXT_PREAMBLE: &str = "Lead-agent updates arrived from Oz. Treat the latest lead-agent instructions below as authoritative.\n";
pub(super) const MESSAGE_BRIDGE_CONTEXT_PREAMBLE: &str =
"Oz mailbox update for this child run.\nSource: lead agent\nContext type: user-level coordination messages\n";
const PARENT_BRIDGE_REMAINING_MESSAGES_NOTE: &str =
"\n\nMore lead-agent messages are still staged and will be surfaced on a later turn.";
"\n\nAdditional lead agent mailbox messages remain queued and may be surfaced later.";
pub(super) struct MessageBridge {
run_id: String,
@@ -48,6 +51,16 @@ pub(super) struct MessageBridge {
runtime: Mutex<Option<MessageBridgeRuntime>>,
state_lock: AsyncMutex<()>,
}
pub(super) enum MessageBridgeCleanupDisposition {
RemoveState,
PreserveState,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct MessageBridgeEventCursor {
since_sequence: i64,
}
struct MessageBridgeRuntime {
task: SpawnedFutureHandle,
}
@@ -55,6 +68,7 @@ struct MessageBridgeRuntime {
struct MessageBridgeEventConsumer {
run_id: String,
state_dir: PathBuf,
server_api: Arc<ServerApi>,
}
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
@@ -91,6 +105,13 @@ impl AgentEventConsumer for MessageBridgeEventConsumer {
Ok(AgentEventConsumerControlFlow::Continue)
}
async fn persist_cursor(&mut self, sequence: i64) -> anyhow::Result<()> {
write_parent_bridge_event_cursor(&self.state_dir, sequence)?;
self.server_api
.update_event_sequence_on_server(&self.run_id, sequence)
.await
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -130,6 +151,12 @@ struct SelectedMessageBridgeMessages {
}
impl MessageBridge {
fn hydrator(&self, server_api: Arc<ServerApi>) -> MessageHydrator {
match self.run_id.parse::<AmbientAgentTaskId>() {
Ok(task_id) => MessageHydrator::for_task(server_api, task_id),
Err(_) => MessageHydrator::new(server_api),
}
}
pub(super) fn new(run_id: String, session_id: Uuid) -> Result<Self> {
Ok(Self {
run_id,
@@ -177,8 +204,7 @@ impl MessageBridge {
if !self.state_dir.exists() {
return Ok(());
}
let hydrator = MessageHydrator::new(server_api);
let hydrator = self.hydrator(server_api);
let _guard = self.state_lock.lock().await;
acknowledge_parent_bridge_hook_output(&hydrator, &self.state_dir).await?;
prepare_parent_bridge_hook_output(
@@ -193,16 +219,18 @@ impl MessageBridge {
if !self.state_dir.exists() {
return Ok(());
}
let hydrator = MessageHydrator::new(server_api);
let hydrator = self.hydrator(server_api);
let _guard = self.state_lock.lock().await;
acknowledge_parent_bridge_hook_output(&hydrator, &self.state_dir).await
}
pub(super) fn cleanup(&self) -> Result<()> {
pub(super) fn cleanup(&self, disposition: MessageBridgeCleanupDisposition) -> Result<()> {
if let Some(runtime) = self.runtime.lock().take() {
runtime.task.abort();
}
if matches!(disposition, MessageBridgeCleanupDisposition::PreserveState) {
return Ok(());
}
match fs::remove_dir_all(&self.state_dir) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
@@ -249,6 +277,10 @@ pub(super) fn parent_bridge_hook_output_ack_file(state_dir: &Path) -> PathBuf {
state_dir.join(PARENT_BRIDGE_HOOK_OUTPUT_ACK_FILE_NAME)
}
pub(super) fn parent_bridge_event_cursor_file(state_dir: &Path) -> PathBuf {
state_dir.join(PARENT_BRIDGE_EVENT_CURSOR_FILE_NAME)
}
fn parent_bridge_message_path(dir: &Path, sequence: i64, message_id: &str) -> PathBuf {
dir.join(format!("{sequence:020}-{message_id}.json"))
}
@@ -277,6 +309,28 @@ pub(super) fn ensure_parent_bridge_state_dir(state_dir: &Path) -> Result<()> {
Ok(())
}
pub(super) fn read_parent_bridge_event_cursor(state_dir: &Path) -> Result<i64> {
let path = parent_bridge_event_cursor_file(state_dir);
if !path.exists() {
return Ok(0);
}
let cursor = serde_json::from_slice::<MessageBridgeEventCursor>(
&fs::read(&path).with_context(|| format!("Failed to read {}", path.display()))?,
)
.with_context(|| format!("Failed to parse {}", path.display()))?;
Ok(cursor.since_sequence)
}
pub(super) fn write_parent_bridge_event_cursor(state_dir: &Path, sequence: i64) -> Result<()> {
write_parent_bridge_json_atomically(
&parent_bridge_event_cursor_file(state_dir),
&MessageBridgeEventCursor {
since_sequence: sequence,
},
)
}
pub(super) fn stage_parent_bridge_message(
state_dir: &Path,
record: &MessageBridgeMessageRecord,
@@ -288,7 +342,57 @@ pub(super) fn stage_parent_bridge_message(
Ok(())
}
fn parent_bridge_max_context_chars() -> usize {
pub(super) async fn prime_parent_bridge_staged_for_self_managed_wake(
hydrator: &MessageHydrator,
state_dir: &Path,
wake_message: Option<&AgentMessageEventMetadata>,
) -> Result<()> {
remove_file_if_exists(&parent_bridge_hook_output_file(state_dir))?;
remove_file_if_exists(&parent_bridge_hook_output_ack_file(state_dir))?;
move_parent_bridge_surfaced_messages_to_staged(state_dir)?;
let Some(wake_message) = wake_message else {
return Ok(());
};
let record = hydrate_parent_bridge_message_record(
hydrator,
&MessageBridgeMessageRecord {
sequence: wake_message.sequence,
message_id: wake_message.message_id.clone(),
sender_run_id: String::new(),
subject: String::new(),
body: String::new(),
occurred_at: wake_message.occurred_at.clone(),
},
)
.await?;
stage_parent_bridge_message(state_dir, &record)?;
write_parent_bridge_event_cursor(state_dir, wake_message.sequence)
}
fn move_parent_bridge_surfaced_messages_to_staged(state_dir: &Path) -> Result<()> {
let surfaced_records = parent_bridge_message_records(&parent_bridge_surfaced_dir(state_dir))?;
for (path, record) in surfaced_records {
let target =
parent_bridge_staged_message_path(state_dir, record.sequence, &record.message_id);
if target.exists() {
write_parent_bridge_json_atomically(&target, &record)?;
remove_file_if_exists(&path)?;
} else {
fs::rename(&path, &target).with_context(|| {
format!(
"Failed to move message bridge record {} back to {}",
path.display(),
target.display()
)
})?;
}
}
Ok(())
}
pub(super) fn parent_bridge_max_context_chars() -> usize {
std::env::var(PARENT_BRIDGE_MAX_CONTEXT_CHARS_ENV)
.ok()
.and_then(|value| value.trim().parse::<usize>().ok())
@@ -338,7 +442,7 @@ pub(super) fn render_parent_bridge_message_block(record: &MessageBridgeMessageRe
record.subject.as_str()
};
let mut block = String::from("---\nLead-agent message");
let mut block = String::from("---\nLead agent mailbox message");
if record.sequence != 0 {
let _ = write!(block, " #{}", record.sequence);
}
@@ -579,15 +683,50 @@ async fn run_parent_bridge_forever(
state_dir: PathBuf,
) -> Result<()> {
ensure_parent_bridge_state_dir(&state_dir)?;
let since_sequence =
read_parent_bridge_resume_cursor(server_api.as_ref(), &run_id, &state_dir).await?;
// The shared driver keeps `since_sequence` in memory across its own retry
// loop, which is all this per-session bridge needs because the state dir is
// not reused across sessions.
let config = AgentEventDriverConfig::retry_forever(vec![run_id.clone()], 0);
let source = ServerApiAgentEventSource::new(server_api);
let mut consumer = MessageBridgeEventConsumer { run_id, state_dir };
// loop and we also persist it inside the session state dir so dormant runs
// can resume without replaying already handled events.
let config =
AgentEventDriverConfig::retry_forever_run_ids(vec![run_id.clone()], since_sequence);
let source = ServerApiAgentEventSource::new(server_api.clone());
let mut consumer = MessageBridgeEventConsumer {
run_id,
state_dir,
server_api,
};
run_agent_event_driver(source, config, &mut consumer).await
}
async fn read_parent_bridge_resume_cursor(
server_api: &ServerApi,
run_id: &str,
state_dir: &Path,
) -> Result<i64> {
// The server cursor is the durable cross-client source of truth, but the
// bridge also keeps a local cursor for same-machine recovery. If Warp or
// Claude restarts after the bridge has staged events locally but before the
// server cursor update is visible, the local cursor prevents replaying
// messages already handed to this Claude session.
let local_sequence = read_parent_bridge_event_cursor(state_dir)?;
let Ok(task_id) = run_id.parse() else {
return Ok(local_sequence);
};
let server_sequence = match server_api.get_ambient_agent_task(&task_id).await {
Ok(task) => task.last_event_sequence.unwrap_or(0),
Err(err) => {
log::warn!(
"Failed to read server-backed event cursor for Claude message bridge run {run_id}: {err:#}"
);
0
}
};
Ok(local_sequence.max(server_sequence))
}
fn write_parent_bridge_json_atomically<T: Serialize>(path: &Path, value: &T) -> Result<()> {
write_parent_bridge_bytes_atomically(path, &serde_json::to_vec(value)?)
}
@@ -0,0 +1,296 @@
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result};
use shell_words::quote as shell_quote;
use uuid::Uuid;
use warp_cli::agent::Harness;
use warp_graphql::ai::AgentTaskState;
use super::super::claude_transcript::{
claude_config_dir, write_envelope, write_session_index_entry, ClaudeTranscriptEnvelope,
};
use super::super::{remove_claude_externally_managed_listener_env_vars, task_env_vars};
use super::parent_bridge::{
ensure_parent_bridge_state_dir, parent_bridge_root,
prime_parent_bridge_staged_for_self_managed_wake,
};
use super::{claude_command, prepare_claude_environment_config, ClaudeHarness};
use crate::ai::agent::conversation::{AIConversation, ConversationStatus};
use crate::ai::agent_events::{AgentMessageEventMetadata, MessageHydrator};
use crate::ai::ambient_agents::{AmbientAgentTaskId, AmbientAgentTaskState};
use crate::server::server_api::ai::AIClient;
use crate::server::server_api::harness_support::ResolvePromptRequest;
use crate::server::server_api::ServerApi;
use crate::terminal::CLIAgent;
const CLAUDE_WAKE_PROMPT: &str =
"A lead agent mailbox message is available for this child run. Review the mailbox context and continue the task.";
pub(super) const CLAUDE_WAKE_PROMPT_FILE_NAME: &str = "wake-turn-prompt.txt";
#[derive(Debug)]
pub(super) struct ClaudeWakeRemoteContext {
pub(super) session_id: Uuid,
pub(super) envelope: ClaudeTranscriptEnvelope,
pub(super) wake_prompt: String,
}
struct ClaudeWakeCandidate {
task_id: AmbientAgentTaskId,
parent_run_id: Option<String>,
working_dir: Option<PathBuf>,
}
impl ClaudeHarness {
pub(crate) async fn wake_dormant_session(
server_api: Arc<ServerApi>,
conversation: AIConversation,
parent_conversation: Option<AIConversation>,
working_dir: Option<PathBuf>,
wake_message: Option<AgentMessageEventMetadata>,
) -> Result<Option<String>> {
let Some(candidate) =
Self::local_wake_candidate(&conversation, parent_conversation.as_ref(), working_dir)
else {
return Ok(None);
};
let ClaudeWakeCandidate {
task_id,
parent_run_id,
working_dir,
} = candidate;
let task = server_api.get_ambient_agent_task(&task_id).await?;
let harness = task
.agent_config_snapshot
.as_ref()
.and_then(|snapshot| snapshot.harness.as_ref())
.map(|config| config.harness_type);
log::info!(
"Evaluating dormant Claude wake: task_id={task_id} server_task_state={:?} harness={harness:?}",
task.state
);
if !is_local_wake_task_state_ready(task.state.clone()) || harness != Some(Harness::Claude) {
log::info!(
"Skipping dormant Claude wake: task_id={task_id} server_task_state={:?} harness={harness:?}",
task.state
);
return Ok(None);
}
let remote = Self::fetch_local_wake_remote_context(task_id, server_api.clone()).await?;
let command = Self::prepare_local_wake_command(
server_api.clone(),
task_id,
parent_run_id,
working_dir,
remote,
wake_message,
)
.await?;
log::info!("Reopening dormant Claude task before wake command: task_id={task_id}");
server_api
.update_agent_task(task_id, Some(AgentTaskState::InProgress), None, None, None)
.await
.map_err(|err| {
anyhow::anyhow!(
"Failed to reopen dormant Claude task {task_id} before wake: {err:#}"
)
})?;
log::info!("Reopened dormant Claude task before wake command: task_id={task_id}");
Ok(Some(command))
}
fn local_wake_candidate(
conversation: &AIConversation,
parent_conversation: Option<&AIConversation>,
working_dir: Option<PathBuf>,
) -> Option<ClaudeWakeCandidate> {
let conversation_id = conversation.id();
if !matches!(conversation.status(), ConversationStatus::Success) {
log::info!(
"Skipping dormant Claude wake candidate: conversation_id={conversation_id:?} reason=not_success status={:?}",
conversation.status()
);
return None;
}
if !conversation.is_child_agent_conversation() || conversation.is_remote_child() {
log::info!(
"Skipping dormant Claude wake candidate: conversation_id={conversation_id:?} reason=not_local_child is_child_agent_conversation={} is_remote_child={}",
conversation.is_child_agent_conversation(),
conversation.is_remote_child()
);
return None;
}
let Some(task_id) = conversation.task_id() else {
log::info!(
"Skipping dormant Claude wake candidate: conversation_id={conversation_id:?} reason=missing_task_id"
);
return None;
};
let parent_run_id = conversation
.parent_agent_id()
.map(str::to_owned)
.or_else(|| parent_conversation.and_then(AIConversation::run_id));
Some(ClaudeWakeCandidate {
task_id,
parent_run_id,
working_dir,
})
}
async fn fetch_local_wake_remote_context(
task_id: AmbientAgentTaskId,
server_api: Arc<ServerApi>,
) -> Result<ClaudeWakeRemoteContext> {
let resolved = server_api
.resolve_prompt_for_task(
&task_id,
ResolvePromptRequest {
skill: None,
attachments_dir: None,
},
)
.await
.with_context(|| format!("Failed to resolve Claude wake prompt for task {task_id}"))?;
let bytes = server_api
.fetch_transcript_for_task(&task_id)
.await
.with_context(|| format!("Failed to fetch Claude transcript for task {task_id}"))?;
let envelope: ClaudeTranscriptEnvelope =
serde_json::from_slice(&bytes).with_context(|| {
format!("Failed to deserialize Claude transcript for wake task {task_id}")
})?;
let wake_prompt = match resolved.resumption_prompt {
Some(resumption_prompt) if !resumption_prompt.is_empty() => {
format!(
"{resumption_prompt}
{CLAUDE_WAKE_PROMPT}"
)
}
_ => CLAUDE_WAKE_PROMPT.to_string(),
};
Ok(ClaudeWakeRemoteContext {
session_id: envelope.uuid,
envelope,
wake_prompt,
})
}
pub(super) async fn prepare_local_wake_command(
server_api: Arc<ServerApi>,
task_id: AmbientAgentTaskId,
parent_run_id: Option<String>,
working_dir: Option<PathBuf>,
mut remote: ClaudeWakeRemoteContext,
wake_message: Option<AgentMessageEventMetadata>,
) -> Result<String> {
let working_dir = working_dir.unwrap_or_else(|| remote.envelope.cwd.clone());
prepare_claude_environment_config(&working_dir, &HashMap::new())
.context("Failed to prepare Claude environment for wake")?;
remote.envelope.cwd = working_dir.clone();
let config_root = claude_config_dir().context("Failed to resolve Claude config dir")?;
write_envelope(&remote.envelope, &config_root)
.context("Failed to rehydrate Claude transcript for wake")?;
if let Err(error) = write_session_index_entry(remote.session_id, &working_dir, &config_root)
{
log::warn!("Failed to update Claude sessions-index.json for wake: {error:#}");
}
let state_dir = parent_bridge_root()?.join(remote.session_id.to_string());
ensure_parent_bridge_state_dir(&state_dir)?;
let hydrator = MessageHydrator::for_task(server_api, task_id);
prime_parent_bridge_staged_for_self_managed_wake(
&hydrator,
&state_dir,
wake_message.as_ref(),
)
.await?;
let prompt_path = state_dir.join(CLAUDE_WAKE_PROMPT_FILE_NAME);
std::fs::write(&prompt_path, remote.wake_prompt.as_bytes())
.with_context(|| format!("Failed to write {}", prompt_path.display()))?;
let command = claude_command(
CLIAgent::Claude.command_prefix(),
&remote.session_id,
&prompt_path.display().to_string(),
None,
None,
true,
);
let env_vars = local_wake_task_env_vars(Some(&task_id), parent_run_id.as_deref());
Ok(prefix_command_with_env_vars(command, env_vars))
}
}
fn local_wake_task_env_vars(
task_id: Option<&AmbientAgentTaskId>,
parent_run_id: Option<&str>,
) -> HashMap<OsString, OsString> {
let mut env_vars = task_env_vars(task_id, parent_run_id, Harness::Claude);
// The local wake command is executed directly in the existing child
// terminal, not through `AgentDriver::run_harness`, so Warp does not start
// `MessageBridge` for this resumed Claude process. Leave the listener in
// the Claude plugin's self-managed mode; otherwise the hook waits for
// state files that no managed bridge is producing and the wake message is
// never surfaced to Claude.
remove_claude_externally_managed_listener_env_vars(&mut env_vars);
env_vars
}
fn is_local_wake_task_state_ready(state: AmbientAgentTaskState) -> bool {
match state {
AmbientAgentTaskState::Succeeded => true,
// The local conversation status is already gated on `Success` before
// this function is called. The server task update is fire-and-forget,
// so it can still report `InProgress` for a short window after the
// local Claude process has actually stopped. Treat that stale server
// state as wakeable for local children.
AmbientAgentTaskState::InProgress => true,
AmbientAgentTaskState::Queued
| AmbientAgentTaskState::Pending
| AmbientAgentTaskState::Claimed
| AmbientAgentTaskState::Failed
| AmbientAgentTaskState::Error
| AmbientAgentTaskState::Blocked
| AmbientAgentTaskState::Cancelled
| AmbientAgentTaskState::Unknown => false,
}
}
fn prefix_command_with_env_vars(command: String, env_vars: HashMap<OsString, OsString>) -> String {
if env_vars.is_empty() {
return command;
}
let mut env_pairs = env_vars
.into_iter()
.map(|(key, value)| {
(
key.to_string_lossy().into_owned(),
value.to_string_lossy().into_owned(),
)
})
.collect::<Vec<_>>();
env_pairs.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
let assignments = env_pairs
.into_iter()
.map(|(key, value)| format!("{key}={}", shell_quote(&value)))
.collect::<Vec<_>>()
.join(" ");
format!("env {assignments} {command}")
}
#[cfg(test)]
#[path = "wake_driver_tests.rs"]
mod tests;
@@ -0,0 +1,24 @@
use super::*;
#[test]
fn local_wake_task_state_ready_allows_success_and_stale_in_progress() {
assert!(is_local_wake_task_state_ready(
AmbientAgentTaskState::Succeeded
));
assert!(is_local_wake_task_state_ready(
AmbientAgentTaskState::InProgress
));
for state in [
AmbientAgentTaskState::Queued,
AmbientAgentTaskState::Pending,
AmbientAgentTaskState::Claimed,
AmbientAgentTaskState::Failed,
AmbientAgentTaskState::Error,
AmbientAgentTaskState::Blocked,
AmbientAgentTaskState::Cancelled,
AmbientAgentTaskState::Unknown,
] {
assert!(!is_local_wake_task_state_ready(state));
}
}
@@ -1,15 +1,21 @@
use mockall::predicate::eq;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::sync::Arc;
use mockall::predicate::eq;
use tempfile::TempDir;
use uuid::Uuid;
use warp_cli::{OZ_HARNESS_ENV, OZ_PARENT_RUN_ID_ENV, OZ_RUN_ID_ENV};
use super::*;
use crate::ai::agent_events::MessageHydrator;
use crate::server::server_api::ai::{MockAIClient, ReadAgentMessageResponse};
use crate::ai::agent_events::{AgentMessageEventMetadata, MessageHydrator};
use crate::ai::agent_sdk::driver::harness::claude_transcript::{
encode_cwd, write_session_index_entry,
};
use crate::ai::agent_sdk::driver::OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV;
use crate::server::server_api::ai::{AIClient, MockAIClient, ReadAgentMessageResponse};
use crate::server::server_api::ServerApiProvider;
fn sample_parent_bridge_message(
sequence: i64,
@@ -52,7 +58,7 @@ fn write_surfaced_parent_bridge_message(state_dir: &Path, record: &MessageBridge
#[test]
fn claude_command_uses_session_id_when_not_resuming() {
let uuid = Uuid::new_v4();
let cmd = claude_command("claude", &uuid, "/tmp/prompt.txt", None, false);
let cmd = claude_command("claude", &uuid, "/tmp/prompt.txt", None, None, false);
assert!(
cmd.contains(&format!("--session-id {uuid}")),
"expected --session-id flag in non-resume command, got: {cmd}"
@@ -66,7 +72,7 @@ fn claude_command_uses_session_id_when_not_resuming() {
#[test]
fn claude_command_uses_resume_flag_when_resuming() {
let uuid = Uuid::new_v4();
let cmd = claude_command("claude", &uuid, "/tmp/prompt.txt", None, true);
let cmd = claude_command("claude", &uuid, "/tmp/prompt.txt", None, None, true);
assert!(
cmd.contains(&format!("--resume {uuid}")),
"expected --resume flag in resume command, got: {cmd}"
@@ -80,7 +86,14 @@ fn claude_command_uses_resume_flag_when_resuming() {
#[test]
fn claude_command_pipes_prompt_path() {
let uuid = Uuid::new_v4();
let cmd = claude_command("claude", &uuid, "/tmp/prompt with spaces.txt", None, true);
let cmd = claude_command(
"claude",
&uuid,
"/tmp/prompt with spaces.txt",
None,
None,
true,
);
assert!(
cmd.contains("< '/tmp/prompt with spaces.txt'"),
"expected single-quoted stdin redirect of the prompt path, got: {cmd}"
@@ -91,6 +104,111 @@ fn claude_command_pipes_prompt_path() {
);
}
#[test]
fn write_session_index_entry_creates_expected_entry() {
let tmp = TempDir::new().unwrap();
let cwd = Path::new("/my/project");
let session_id = Uuid::new_v4();
write_session_index_entry(session_id, cwd, tmp.path()).unwrap();
let index_path = tmp.path().join("sessions-index.json");
let index: Value = serde_json::from_slice(&fs::read(index_path).unwrap()).unwrap();
let session_key = session_id.to_string();
let entry = &index[&session_key];
let encoded = encode_cwd(cwd);
assert_eq!(entry["sessionId"], Value::String(session_key.clone()));
assert_eq!(
entry["cwd"],
Value::String(cwd.to_string_lossy().into_owned())
);
assert_eq!(entry["projectPath"], Value::String(encoded.clone()));
assert_eq!(
entry["transcriptPath"],
Value::String(format!("projects/{encoded}/{session_id}.jsonl"))
);
}
#[test]
fn serialize_claude_mcp_config_cli_server() {
let servers = HashMap::from([(
"test-server".to_string(),
JSONMCPServer {
transport_type: JSONTransportType::CLIServer {
command: "node".to_string(),
args: vec!["server.js".to_string()],
env: HashMap::from([("API_KEY".to_string(), "secret".to_string())]),
working_directory: None,
},
},
)]);
let json = serialize_claude_mcp_config(&servers).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let server = &parsed["mcpServers"]["test-server"];
assert_eq!(server["type"], "stdio");
assert_eq!(server["command"], "node");
assert_eq!(server["args"][0], "server.js");
assert_eq!(server["env"]["API_KEY"], "secret");
}
#[test]
fn serialize_claude_mcp_config_cli_server_with_cwd() {
let servers = HashMap::from([(
"test-server".to_string(),
JSONMCPServer {
transport_type: JSONTransportType::CLIServer {
command: "node".to_string(),
args: vec!["server.js".to_string()],
env: HashMap::new(),
working_directory: Some("/opt/mcp".to_string()),
},
},
)]);
let json = serialize_claude_mcp_config(&servers).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let server = &parsed["mcpServers"]["test-server"];
assert_eq!(server["cwd"], "/opt/mcp");
}
#[test]
fn serialize_claude_mcp_config_cli_server_omits_cwd_when_none() {
let servers = HashMap::from([(
"test-server".to_string(),
JSONMCPServer {
transport_type: JSONTransportType::CLIServer {
command: "node".to_string(),
args: vec![],
env: HashMap::new(),
working_directory: None,
},
},
)]);
let json = serialize_claude_mcp_config(&servers).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let server = &parsed["mcpServers"]["test-server"];
assert!(server.get("cwd").is_none());
}
#[test]
fn serialize_claude_mcp_config_sse_server() {
let servers = HashMap::from([(
"remote".to_string(),
JSONMCPServer {
transport_type: JSONTransportType::SSEServer {
url: "https://mcp.example.com".to_string(),
headers: HashMap::from([("Authorization".to_string(), "Bearer tok".to_string())]),
},
},
)]);
let json = serialize_claude_mcp_config(&servers).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let server = &parsed["mcpServers"]["remote"];
assert_eq!(server["type"], "http");
assert_eq!(server["url"], "https://mcp.example.com");
assert_eq!(server["headers"]["Authorization"], "Bearer tok");
}
#[test]
#[serial_test::serial]
fn parent_bridge_root_prefers_environment_override() {
@@ -119,6 +237,77 @@ fn stage_parent_bridge_message_writes_message_record() {
assert!(staged_record.sender_run_id.is_empty());
}
#[tokio::test]
async fn parent_bridge_event_cursor_defaults_to_zero_when_missing() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("session-123");
ensure_parent_bridge_state_dir(&state_dir).unwrap();
assert_eq!(read_parent_bridge_event_cursor(&state_dir).unwrap(), 0);
assert!(!parent_bridge_event_cursor_file(&state_dir).exists());
}
#[tokio::test]
async fn parent_bridge_event_cursor_round_trips() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("session-123");
ensure_parent_bridge_state_dir(&state_dir).unwrap();
write_parent_bridge_event_cursor(&state_dir, 42).unwrap();
assert_eq!(read_parent_bridge_event_cursor(&state_dir).unwrap(), 42);
assert!(parent_bridge_event_cursor_file(&state_dir).exists());
}
#[test]
#[serial_test::serial]
fn message_bridge_cleanup_preserves_state_for_wakeable_runs() {
let tmp = TempDir::new().unwrap();
std::env::set_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV, tmp.path());
let session_id = Uuid::new_v4();
let bridge = MessageBridge::new("run-123".to_string(), session_id).unwrap();
let state_dir = tmp.path().join(session_id.to_string());
ensure_parent_bridge_state_dir(&state_dir).unwrap();
let record = sample_staged_parent_bridge_message(42, "msg-123");
stage_parent_bridge_message(&state_dir, &record).unwrap();
write_parent_bridge_event_cursor(&state_dir, 42).unwrap();
bridge
.cleanup(MessageBridgeCleanupDisposition::PreserveState)
.unwrap();
std::env::remove_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV);
assert!(state_dir.exists());
assert!(parent_bridge_staged_message_path(&state_dir, 42, "msg-123").exists());
assert_eq!(read_parent_bridge_event_cursor(&state_dir).unwrap(), 42);
}
#[test]
#[serial_test::serial]
fn message_bridge_cleanup_removes_state_for_non_wakeable_runs() {
let tmp = TempDir::new().unwrap();
std::env::set_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV, tmp.path());
let session_id = Uuid::new_v4();
let bridge = MessageBridge::new("run-123".to_string(), session_id).unwrap();
let state_dir = tmp.path().join(session_id.to_string());
ensure_parent_bridge_state_dir(&state_dir).unwrap();
stage_parent_bridge_message(
&state_dir,
&sample_staged_parent_bridge_message(42, "msg-123"),
)
.unwrap();
write_parent_bridge_event_cursor(&state_dir, 42).unwrap();
bridge
.cleanup(MessageBridgeCleanupDisposition::RemoveState)
.unwrap();
std::env::remove_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV);
assert!(!state_dir.exists());
}
#[tokio::test]
async fn prepare_parent_bridge_hook_output_moves_selected_messages_to_surfaced_dir() {
let tmp = TempDir::new().unwrap();
@@ -481,75 +670,262 @@ fn prepare_claude_config_none_suffix_preserves_existing_responses() {
}
#[test]
fn resolve_suffix_from_raw_value_secret() {
#[serial_test::serial]
fn prepare_claude_environment_config_without_config_dir_uses_home_global_config() {
let home_dir = TempDir::new().unwrap();
let old_home = std::env::var_os("HOME");
let old_config_dir = std::env::var_os("CLAUDE_CONFIG_DIR");
std::env::set_var("HOME", home_dir.path());
std::env::remove_var("CLAUDE_CONFIG_DIR");
let working_dir = home_dir.path().join("workspace/project");
prepare_claude_environment_config(&working_dir, &HashMap::new()).unwrap();
assert!(home_dir.path().join(CLAUDE_JSON_FILE_NAME).exists());
assert!(home_dir
.path()
.join(".claude")
.join(CLAUDE_SETTINGS_FILE_NAME)
.exists());
assert!(!home_dir
.path()
.join(".claude")
.join(CLAUDE_JSON_FILE_NAME)
.exists());
match old_home {
Some(home) => std::env::set_var("HOME", home),
None => std::env::remove_var("HOME"),
}
match old_config_dir {
Some(dir) => std::env::set_var("CLAUDE_CONFIG_DIR", dir),
None => std::env::remove_var("CLAUDE_CONFIG_DIR"),
}
}
#[test]
#[serial_test::serial]
fn prepare_claude_environment_config_with_config_dir_uses_dir_global_config() {
let home_dir = TempDir::new().unwrap();
let claude_config_dir = TempDir::new().unwrap();
let old_home = std::env::var_os("HOME");
let old_config_dir = std::env::var_os("CLAUDE_CONFIG_DIR");
std::env::set_var("HOME", home_dir.path());
std::env::set_var("CLAUDE_CONFIG_DIR", claude_config_dir.path());
let working_dir = home_dir.path().join("workspace/project");
prepare_claude_environment_config(&working_dir, &HashMap::new()).unwrap();
assert!(claude_config_dir
.path()
.join(CLAUDE_JSON_FILE_NAME)
.exists());
assert!(claude_config_dir
.path()
.join(CLAUDE_SETTINGS_FILE_NAME)
.exists());
assert!(!home_dir.path().join(CLAUDE_JSON_FILE_NAME).exists());
match old_home {
Some(home) => std::env::set_var("HOME", home),
None => std::env::remove_var("HOME"),
}
match old_config_dir {
Some(dir) => std::env::set_var("CLAUDE_CONFIG_DIR", dir),
None => std::env::remove_var("CLAUDE_CONFIG_DIR"),
}
}
#[test]
#[serial_test::serial]
fn resolve_suffix_from_resolved_env_vars() {
std::env::remove_var(ANTHROPIC_API_KEY_ENV);
let key = "sk-ant-api03-abcdefghij1234567890ABCDEFGHIJ1234567890abcdefghij1234567890QLWn-dUnuwQ-hIhDiAAA";
let secrets = HashMap::from([(
"ANTHROPIC_API_KEY".to_string(),
ManagedSecretValue::raw_value(key),
)]);
let suffix = resolve_anthropic_api_key_suffix(&secrets);
let resolved = HashMap::from([(OsString::from("ANTHROPIC_API_KEY"), OsString::from(key))]);
let suffix = resolve_anthropic_api_key_suffix(&resolved);
assert_eq!(suffix.as_deref(), Some("QLWn-dUnuwQ-hIhDiAAA"));
}
#[test]
fn resolve_suffix_from_anthropic_api_key_secret() {
let key = "sk-ant-api03-abcdefghij1234567890ABCDEFGHIJ1234567890abcdefghij1234567890QLWn-dUnuwQ-hIhDiAAA";
let secrets = HashMap::from([(
"ANTHROPIC_API_KEY".to_string(),
ManagedSecretValue::anthropic_api_key(key),
)]);
let suffix = resolve_anthropic_api_key_suffix(&secrets);
assert_eq!(suffix.as_deref(), Some("QLWn-dUnuwQ-hIhDiAAA"));
}
#[test]
fn resolve_suffix_from_anthropic_api_key_with_different_secret_name() {
let key = "sk-ant-api03-abcdefghij1234567890ABCDEFGHIJ1234567890abcdefghij1234567890QLWn-dUnuwQ-hIhDiAAA";
// Secret name doesn't match the env var, but the AnthropicApiKey variant
// should still be found by iterating all secrets.
let secrets = HashMap::from([(
"my-anthropic-key".to_string(),
ManagedSecretValue::anthropic_api_key(key),
)]);
let suffix = resolve_anthropic_api_key_suffix(&secrets);
assert_eq!(suffix.as_deref(), Some("QLWn-dUnuwQ-hIhDiAAA"));
}
#[test]
fn resolve_suffix_prefers_anthropic_api_key_variant_over_raw_value() {
let anthropic_key = "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-anthropic-suffix";
let raw_key = "sk-ant-api03-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB-raw-suffix";
let secrets = HashMap::from([
(
"my-anthropic-key".to_string(),
ManagedSecretValue::anthropic_api_key(anthropic_key),
),
(
"ANTHROPIC_API_KEY".to_string(),
ManagedSecretValue::raw_value(raw_key),
),
]);
let suffix = resolve_anthropic_api_key_suffix(&secrets);
// AnthropicApiKey variant should be preferred.
assert_eq!(suffix.as_deref(), Some("AAA-anthropic-suffix"));
}
#[test]
#[serial_test::serial]
fn resolve_suffix_returns_none_for_short_key() {
let secrets = HashMap::from([(
"ANTHROPIC_API_KEY".to_string(),
ManagedSecretValue::raw_value("short"),
)]);
assert_eq!(resolve_anthropic_api_key_suffix(&secrets), None);
std::env::remove_var(ANTHROPIC_API_KEY_ENV);
let resolved = HashMap::from([(OsString::from("ANTHROPIC_API_KEY"), OsString::from("short"))]);
assert_eq!(resolve_anthropic_api_key_suffix(&resolved), None);
}
#[test]
fn resolve_suffix_returns_none_for_short_anthropic_api_key() {
let secrets = HashMap::from([(
"ANTHROPIC_API_KEY".to_string(),
ManagedSecretValue::anthropic_api_key("short"),
#[serial_test::serial]
fn resolve_suffix_returns_none_when_empty() {
std::env::remove_var(ANTHROPIC_API_KEY_ENV);
assert_eq!(resolve_anthropic_api_key_suffix(&HashMap::new()), None);
}
#[test]
#[serial_test::serial]
fn prepare_local_wake_command_rehydrates_transcript_with_self_managed_listener() {
let home_dir = TempDir::new().unwrap();
let claude_config_dir = TempDir::new().unwrap();
let bridge_state_root = TempDir::new().unwrap();
let working_dir = home_dir.path().join("workspace/project");
fs::create_dir_all(&working_dir).unwrap();
std::env::set_var("HOME", home_dir.path());
std::env::set_var("CLAUDE_CONFIG_DIR", claude_config_dir.path());
std::env::set_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV, bridge_state_root.path());
let session_id = Uuid::new_v4();
let remote = ClaudeWakeRemoteContext {
session_id,
envelope: ClaudeTranscriptEnvelope {
cwd: Path::new("/stale/cwd").to_path_buf(),
uuid: session_id,
claude_version: None,
entries: vec![serde_json::json!({"type": "assistant", "text": "done"})],
subagents: HashMap::new(),
todos: HashMap::new(),
},
wake_prompt: "resume prompt\n\nwake prompt".to_string(),
};
let task_id: AmbientAgentTaskId = "550e8400-e29b-41d4-a716-446655440010".parse().unwrap();
let parent_run_id = "parent-run-456".to_string();
let command = futures::executor::block_on(ClaudeHarness::prepare_local_wake_command(
ServerApiProvider::new_for_test().get(),
task_id,
Some(parent_run_id.clone()),
Some(working_dir.clone()),
remote,
None,
))
.unwrap();
let state_dir = bridge_state_root.path().join(session_id.to_string());
let prompt_path = state_dir.join(CLAUDE_WAKE_PROMPT_FILE_NAME);
assert!(command.contains("--resume"));
assert!(command.starts_with("env "));
assert!(command.contains(&session_id.to_string()));
assert!(command.contains(CLAUDE_WAKE_PROMPT_FILE_NAME));
assert!(command.contains(&format!(
"{OZ_RUN_ID_ENV}={}",
shell_quote(&task_id.to_string())
)));
assert!(command.contains(&format!(
"{OZ_PARENT_RUN_ID_ENV}={}",
shell_quote(&parent_run_id)
)));
assert!(command.contains(&format!("{OZ_HARNESS_ENV}={}", shell_quote("claude"))));
assert!(!command.contains(OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV));
assert!(!command.contains("OZ_PARENT_LISTENER_MANAGED_EXTERNALLY"));
assert_eq!(
fs::read_to_string(&prompt_path).unwrap(),
"resume prompt\n\nwake prompt"
);
assert!(!parent_bridge_hook_output_file(&state_dir).exists());
let restored_envelope =
read_envelope(session_id, &working_dir, claude_config_dir.path()).unwrap();
assert_eq!(restored_envelope.cwd, working_dir);
assert_eq!(
restored_envelope.entries,
vec![serde_json::json!({"type": "assistant", "text": "done"})]
);
assert!(claude_config_dir
.path()
.join(CLAUDE_JSON_FILE_NAME)
.exists());
assert!(claude_config_dir
.path()
.join(CLAUDE_SETTINGS_FILE_NAME)
.exists());
std::env::remove_var("HOME");
std::env::remove_var("CLAUDE_CONFIG_DIR");
std::env::remove_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV);
}
#[tokio::test]
async fn prime_parent_bridge_staged_for_self_managed_wake_keeps_message_in_staged() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("session-123");
ensure_parent_bridge_state_dir(&state_dir).unwrap();
let stale = sample_parent_bridge_message(
41,
"stale-msg",
"Old direction",
"This message should be returned to staged.",
);
write_surfaced_parent_bridge_message(&state_dir, &stale);
fs::write(parent_bridge_hook_output_file(&state_dir), "stale context").unwrap();
fs::write(parent_bridge_hook_output_ack_file(&state_dir), "").unwrap();
let wake_message = AgentMessageEventMetadata {
sequence: 42,
message_id: "msg-123".to_string(),
occurred_at: "2026-04-17T15:47:00Z".to_string(),
};
let expected = sample_parent_bridge_message(
42,
"msg-123",
"Please pivot",
"Inspect the failing tests first.",
);
let mut ai_client = MockAIClient::new();
let expected_message = expected.clone();
ai_client
.expect_read_agent_message()
.with(eq("msg-123"))
.times(1)
.returning(move |_| {
Ok(ReadAgentMessageResponse {
message_id: expected_message.message_id.clone(),
sender_run_id: expected_message.sender_run_id.clone(),
subject: expected_message.subject.clone(),
body: expected_message.body.clone(),
sent_at: "2026-04-17T15:46:00Z".to_string(),
delivered_at: None,
read_at: Some("2026-04-17T15:46:02Z".to_string()),
})
});
let hydrator = MessageHydrator::new(Arc::new(ai_client) as Arc<dyn AIClient>);
prime_parent_bridge_staged_for_self_managed_wake(&hydrator, &state_dir, Some(&wake_message))
.await
.unwrap();
assert_eq!(read_parent_bridge_event_cursor(&state_dir).unwrap(), 42);
assert!(!parent_bridge_hook_output_file(&state_dir).exists());
assert!(!parent_bridge_hook_output_ack_file(&state_dir).exists());
assert!(parent_bridge_staged_message_path(&state_dir, 41, "stale-msg").exists());
assert!(!parent_bridge_surfaced_message_path(&state_dir, 41, "stale-msg").exists());
let staged_path = parent_bridge_staged_message_path(&state_dir, 42, "msg-123");
assert!(staged_path.exists());
assert!(!parent_bridge_surfaced_message_path(&state_dir, 42, "msg-123").exists());
let staged_record: MessageBridgeMessageRecord =
serde_json::from_slice(&fs::read(&staged_path).unwrap()).unwrap();
assert_eq!(staged_record.subject, expected.subject);
assert_eq!(staged_record.body, expected.body);
assert_eq!(staged_record.occurred_at, wake_message.occurred_at);
}
#[test]
#[serial_test::serial]
fn suffix_uses_worker_injected_env_when_present() {
let worker_key = "sk-ant-api03-WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW-worker-suffix!";
std::env::set_var(ANTHROPIC_API_KEY_ENV, worker_key);
// Even when the resolved map has a different value, the worker env wins.
let resolved = HashMap::from([(
OsString::from("ANTHROPIC_API_KEY"),
OsString::from(
"sk-ant-api03-RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR-resolved-val!",
),
)]);
assert_eq!(resolve_anthropic_api_key_suffix(&secrets), None);
let suffix = resolve_anthropic_api_key_suffix(&resolved);
let expected = &worker_key[worker_key.len() - 20..];
assert_eq!(suffix.as_deref(), Some(expected));
std::env::remove_var(ANTHROPIC_API_KEY_ENV);
}
#[test]
@@ -15,7 +15,8 @@
//! Split out from `claude_code.rs` so the `AIClient` transcript-fetch impl can deserialize
//! envelopes without pulling in the rest of the harness runner.
use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::fs::{create_dir_all, write};
use std::io::{BufRead, BufReader, Read};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
@@ -24,6 +25,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use super::json_utils::entries_to_jsonl;
use crate::ai::agent::conversation::AIConversationId;
/// JSON envelope sent to the server representing a complete Claude Code session.
@@ -66,6 +68,11 @@ pub(crate) struct ClaudeResumeInfo {
pub(crate) envelope: ClaudeTranscriptEnvelope,
}
#[derive(Debug)]
pub(crate) struct ClaudeLocalContinuation {
pub(crate) command: String,
}
/// Encode a filesystem path as a Claude config directory name, matching the
/// Claude CLI convention of replacing every `/` with `-`.
///
@@ -83,11 +90,23 @@ pub(crate) fn claude_config_dir() -> Result<PathBuf> {
if let Ok(dir) = std::env::var("CLAUDE_CONFIG_DIR") {
return Ok(PathBuf::from(dir));
}
dirs::home_dir()
home_dir_for_claude_config()
.map(|h| h.join(".claude"))
.ok_or_else(|| anyhow::anyhow!("could not determine home directory"))
}
/// In tests on Windows, `dirs::home_dir()` ignores `HOME`, so we check it
/// manually so that tests can override the home directory.
pub(super) fn home_dir_for_claude_config() -> Option<PathBuf> {
#[cfg(test)]
if let Some(home) = std::env::var_os("HOME") {
if !home.is_empty() {
return Some(PathBuf::from(home));
}
}
dirs::home_dir()
}
/// Assemble a [`ClaudeTranscriptEnvelope`] from the Claude config directory.
///
/// Reads:
@@ -184,12 +203,12 @@ pub(crate) fn write_envelope(
) -> Result<()> {
let encoded = encode_cwd(&envelope.cwd);
let projects_dir = config_root.join("projects").join(&encoded);
std::fs::create_dir_all(&projects_dir)
create_dir_all(&projects_dir)
.with_context(|| format!("Failed to create {}", projects_dir.display()))?;
// Main session JSONL.
let session_file = projects_dir.join(format!("{}.jsonl", envelope.uuid));
std::fs::write(&session_file, entries_to_jsonl(&envelope.entries)?)
write(&session_file, entries_to_jsonl(&envelope.entries)?)
.with_context(|| format!("Failed to write {}", session_file.display()))?;
// Subagent JSONLs.
@@ -197,11 +216,11 @@ pub(crate) fn write_envelope(
let subagents_dir = projects_dir
.join(envelope.uuid.to_string())
.join("subagents");
std::fs::create_dir_all(&subagents_dir)
create_dir_all(&subagents_dir)
.with_context(|| format!("Failed to create {}", subagents_dir.display()))?;
for (stem, entries) in &envelope.subagents {
let path = subagents_dir.join(format!("{stem}.jsonl"));
std::fs::write(&path, entries_to_jsonl(entries)?)
write(&path, entries_to_jsonl(entries)?)
.with_context(|| format!("Failed to write {}", path.display()))?;
}
}
@@ -209,11 +228,11 @@ pub(crate) fn write_envelope(
// Per-agent todo lists.
if !envelope.todos.is_empty() {
let todos_dir = config_root.join("todos");
std::fs::create_dir_all(&todos_dir)
create_dir_all(&todos_dir)
.with_context(|| format!("Failed to create {}", todos_dir.display()))?;
for (stem, value) in &envelope.todos {
let path = todos_dir.join(format!("{stem}.json"));
std::fs::write(&path, serde_json::to_vec(value)?)
write(&path, serde_json::to_vec(value)?)
.with_context(|| format!("Failed to write {}", path.display()))?;
}
}
@@ -221,10 +240,108 @@ pub(crate) fn write_envelope(
Ok(())
}
pub(crate) fn rehydrate_claude_transcript(
envelope: &mut ClaudeTranscriptEnvelope,
local_cwd: &Path,
) -> Result<ClaudeLocalContinuation> {
envelope.cwd = local_cwd.to_path_buf();
let session_id = envelope.uuid;
let config_root = claude_config_dir().context("Failed to resolve Claude config dir")?;
write_envelope(envelope, &config_root).context("Failed to rehydrate Claude transcript")?;
if let Err(e) = write_session_index_entry(session_id, local_cwd, &config_root) {
log::warn!("Failed to update Claude sessions-index.json: {e:#}");
}
Ok(ClaudeLocalContinuation {
command: format!("claude --resume {session_id}"),
})
}
/// Write a [`ClaudeTranscriptEnvelope`] to a project directory derived from `storage_cwd`,
/// without mutating `envelope.cwd`.
///
/// Used by the local continuation path so the transcript's recorded working directory
/// (the original cloud cwd) is preserved as-is while the file is placed under
/// `~/.claude/projects/<encoded(storage_cwd)>/` where Claude's per-project lookup can find it.
/// Cloud resume uses [`write_envelope`] instead, which derives the path from `envelope.cwd`.
///
/// Creates:
/// - `<config_root>/projects/<encoded(storage_cwd)>/<uuid>.jsonl` — main transcript
/// - `<config_root>/projects/<encoded(storage_cwd)>/<uuid>/subagents/<stem>.jsonl` — subagents
/// - `<config_root>/todos/<stem>.json` — per-agent todo lists (same location as cloud resume)
pub(crate) fn write_envelope_for_local_continuation(
envelope: &ClaudeTranscriptEnvelope,
storage_cwd: &Path,
config_root: &Path,
) -> Result<()> {
let projects_dir = config_root.join("projects").join(encode_cwd(storage_cwd));
create_dir_all(&projects_dir)
.with_context(|| format!("Failed to create {}", projects_dir.display()))?;
// Main session JSONL.
let session_file = projects_dir.join(format!("{}.jsonl", envelope.uuid));
write(&session_file, entries_to_jsonl(&envelope.entries)?)
.with_context(|| format!("Failed to write {}", session_file.display()))?;
// Subagent JSONLs — same relative layout as write_envelope.
if !envelope.subagents.is_empty() {
let subagents_dir = projects_dir
.join(envelope.uuid.to_string())
.join("subagents");
create_dir_all(&subagents_dir)
.with_context(|| format!("Failed to create {}", subagents_dir.display()))?;
for (stem, entries) in &envelope.subagents {
let path = subagents_dir.join(format!("{stem}.jsonl"));
write(&path, entries_to_jsonl(entries)?)
.with_context(|| format!("Failed to write {}", path.display()))?;
}
}
// Per-agent todo lists are written to the same global location as cloud resume.
if !envelope.todos.is_empty() {
let todos_dir = config_root.join("todos");
create_dir_all(&todos_dir)
.with_context(|| format!("Failed to create {}", todos_dir.display()))?;
for (stem, value) in &envelope.todos {
let path = todos_dir.join(format!("{stem}.json"));
write(&path, serde_json::to_vec(value)?)
.with_context(|| format!("Failed to write {}", path.display()))?;
}
}
Ok(())
}
/// Rehydrate a Claude transcript downloaded from a remote cloud run for local continuation.
///
/// Unlike [`rehydrate_claude_transcript`] (used by the cloud resume harness runner), this
/// function does **not** mutate the envelope's `cwd` field — the remote session's original
/// working directory is preserved as-is in the transcript. The session file is stored under
/// `~/.claude/projects/<encoded(home_dir)>/` so Claude's per-project session lookup finds it
/// when the user runs `claude --resume <uuid>` from their home directory.
pub(crate) fn rehydrate_claude_transcript_from_reader(
reader: impl Read,
) -> Result<ClaudeLocalContinuation> {
let envelope: ClaudeTranscriptEnvelope =
serde_json::from_reader(reader).context("Failed to parse Claude transcript envelope")?;
let session_id = envelope.uuid;
let config_root = claude_config_dir().context("Failed to resolve Claude config dir")?;
let home_dir = home_dir_for_claude_config()
.ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?;
write_envelope_for_local_continuation(&envelope, &home_dir, &config_root)
.context("Failed to rehydrate Claude transcript for local continuation")?;
if let Err(e) = write_session_index_entry(session_id, &home_dir, &config_root) {
log::warn!("Failed to update Claude sessions-index.json: {e:#}");
}
Ok(ClaudeLocalContinuation {
command: format!("claude --resume {session_id}"),
})
}
/// Filename of Claude's global session index.
const SESSIONS_INDEX_FILENAME: &str = "sessions-index.json";
/// Upsert an entry for `session_uuid` into `<config_root>/sessions-index.json` so Claude's
/// Upsert an entry for `session_uuid` into `<config_root>/sessions-index.json` so Claude's`
/// `claude --resume <uuid>` lookup can find the rehydrated jsonl.
///
/// Upstream Claude versions vary in how the index is keyed and what fields they read; this
@@ -243,7 +360,7 @@ pub(crate) fn write_session_index_entry(
let index_path = config_root.join(SESSIONS_INDEX_FILENAME);
// Read the existing index if present. Missing or malformed files are treated as empty —
// we'd rather clobber an unparseable file than fail the whole resume.
// we'd rather clobber an unparsable file than fail the whole resume.
let mut index: serde_json::Map<String, Value> = match std::fs::read_to_string(&index_path) {
Ok(content) => match serde_json::from_str::<Value>(&content) {
Ok(Value::Object(map)) => map,
@@ -281,10 +398,9 @@ pub(crate) fn write_session_index_entry(
index.insert(session_uuid.to_string(), entry);
if let Some(parent) = index_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
create_dir_all(parent).with_context(|| format!("Failed to create {}", parent.display()))?;
}
std::fs::write(
write(
&index_path,
serde_json::to_vec_pretty(&Value::Object(index))
.context("Failed to serialize sessions-index.json")?,
@@ -293,16 +409,6 @@ pub(crate) fn write_session_index_entry(
Ok(())
}
/// Serialize a slice of JSON values as a JSONL byte string (one value per line).
fn entries_to_jsonl(entries: &[Value]) -> Result<Vec<u8>> {
let mut buf = Vec::new();
for entry in entries {
serde_json::to_writer(&mut buf, entry)?;
buf.push(b'\n');
}
Ok(buf)
}
/// Read a JSONL file, returning one parsed [`Value`] per non-blank line.
///
/// Lines that fail to parse as JSON are skipped with a warning rather than
@@ -0,0 +1,939 @@
use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use anyhow::{Context, Result};
use async_trait::async_trait;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tempfile::NamedTempFile;
use uuid::Uuid;
use warp_cli::agent::Harness;
use galaxy_core::features::FeatureFlag;
use warp_managed_secrets::ManagedSecretValue;
use warpui::{ModelHandle, ModelSpawner, SingletonEntity};
use super::super::terminal::{CommandHandle, TerminalDriver};
use super::super::{AgentDriver, AgentDriverError};
use super::claude_transcript::read_jsonl;
use super::codex_transcript::{
codex_sessions_root, find_session_file, parse_session_meta, rehydrate_codex_transcript,
CodexResumeInfo, CodexTranscriptEnvelope,
};
use super::json_utils::read_json_file_or_default;
use super::{
write_temp_file, HarnessRunner, JSONMCPServer, ResumePayload, SavePoint, ThirdPartyHarness,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_sdk::setup_observability::{
OzRunTimelineEvent, SetupClientEventReporter, SetupStep,
};
use crate::ai::ambient_agents::task::HarnessModelConfig;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::mcp::JSONTransportType;
use crate::server::server_api::harness_support::{upload_to_target, HarnessSupportClient};
use crate::server::server_api::ServerApi;
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
use crate::terminal::model::block::BlockId;
use crate::terminal::CLIAgent;
pub(crate) struct CodexHarness;
/// Format slug sent to the server when creating a Codex conversation.
const CODEX_CLI_FORMAT: &str = "codex_cli";
/// Slash command Codex's TUI recognises as a graceful shutdown.
const CODEX_EXIT_COMMAND: &str = "/exit";
/// Allow the Warp-installed Codex plugin hooks to run in vetted driver sessions
/// without requiring an unattended `/hooks` review step.
const CODEX_BYPASS_HOOK_TRUST_FLAG: &str = "--dangerously-bypass-hook-trust";
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl ThirdPartyHarness for CodexHarness {
fn harness(&self) -> Harness {
Harness::Codex
}
fn cli_agent(&self) -> CLIAgent {
CLIAgent::Codex
}
fn install_docs_url(&self) -> Option<&'static str> {
Some("https://developers.openai.com/codex/cli")
}
fn auth_check_command(&self) -> Option<String> {
let cli = self.cli_agent().command_prefix();
Some(format!("{cli} login status"))
}
fn runtime_error_patterns(&self) -> &'static [&'static str] {
&[
// Quota / billing.
"Quota exceeded. Check your plan and billing details.",
"You've hit your usage limit",
// Upstream HTTP failures Codex surfaces verbatim. The 401 form
// matches invalid-API-key and wrong-endpoint variants.
"unexpected status 401",
"Incorrect API key provided",
"invalid API key",
// Region/endpoint block (Anthropic-style global vs US-only
// routing surfaced through Codex's upstream client).
"Access blocked by Cloudflare",
// OAuth refresh failures — all five Codex variants share this
// substring (see upstream session/token messages).
"could not be refreshed",
]
}
fn requires_verified_platform_plugin(&self) -> bool {
FeatureFlag::CodexPlugin.is_enabled()
}
/// Fetch the codex transcript for the current task's conversation and wrap it into a
/// [`ResumePayload::Codex`].
async fn fetch_resume_payload(
&self,
conversation_id: &AIConversationId,
harness_support_client: Arc<dyn HarnessSupportClient>,
) -> Result<Option<ResumePayload>, AgentDriverError> {
let envelope: CodexTranscriptEnvelope =
super::fetch_transcript_envelope("codex", conversation_id, harness_support_client)
.await?;
let session_id = envelope.session_id;
Ok(Some(ResumePayload::Codex(CodexResumeInfo {
conversation_id: *conversation_id,
session_id,
envelope,
})))
}
fn build_runner(
&self,
prompt: &str,
system_prompt: Option<&str>,
resumption_prompt: Option<&str>,
context: Option<&str>,
working_dir: &Path,
_task_id: Option<AmbientAgentTaskId>,
server_api: Arc<ServerApi>,
terminal_driver: ModelHandle<TerminalDriver>,
resume: Option<ResumePayload>,
resolved_env_vars: &HashMap<OsString, OsString>,
resolved_secrets: &HashMap<String, ManagedSecretValue>,
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
third_party_harness_model_config: Option<&HarnessModelConfig>,
) -> Result<Box<dyn HarnessRunner>, AgentDriverError> {
// Prepare the environment config files.
prepare_codex_environment_config(
working_dir,
system_prompt,
resolved_env_vars,
resolved_secrets,
resolved_mcp_servers,
third_party_harness_model_config,
)
.map_err(|error| AgentDriverError::HarnessConfigSetupFailed {
harness: self.cli_agent().command_prefix().to_owned(),
error,
})?;
// The ResumePayload shouldn't contain non-Codex information, error if it does.
let codex_resume = resume.map(CodexResumeInfo::try_from).transpose()?;
// Mirror Claude harness behavior: prepend the resumption preamble and server context
// to the user-turn prompt so codex treats it as immediate intent.
// Order: resumption_prompt → context → prompt
let mut parts: Vec<&str> = Vec::new();
if let Some(preamble) = resumption_prompt {
if !preamble.is_empty() {
parts.push(preamble);
}
}
if let Some(ctx) = context {
if !ctx.is_empty() {
parts.push(ctx);
}
}
parts.push(prompt);
let owned_prompt = parts.join("\n\n");
let client: Arc<dyn HarnessSupportClient> = server_api;
Ok(Box::new(CodexHarnessRunner::new(
self.cli_agent().command_prefix(),
&owned_prompt,
system_prompt,
working_dir,
client,
terminal_driver,
codex_resume,
)?))
}
}
/// Build the shell command that launches the Codex TUI.
///
/// `--dangerously-bypass-approvals-and-sandbox` disables both the sandbox and approval
/// prompts so the agent can run autonomously.
/// `--dangerously-bypass-hook-trust` allows the orchestration plugin hooks installed by
/// Warp to run without a manual hook review in unattended driver sessions. Driver setup
/// verifies the Codex platform plugin before launching commands with this flag.
/// `Some(session_id)` indicates that we want to resume that prior session. Unlike claude,
/// codex does not support assigning a session_id to a new conversation.
fn codex_command(cli_name: &str, session_id: Option<&Uuid>, prompt_path: &str) -> String {
match session_id {
Some(session_id) => format!(
"{cli_name} resume --dangerously-bypass-approvals-and-sandbox {CODEX_BYPASS_HOOK_TRUST_FLAG} {session_id} \
\"$(cat '{prompt_path}')\""
),
None => {
format!(
"{cli_name} --dangerously-bypass-approvals-and-sandbox {CODEX_BYPASS_HOOK_TRUST_FLAG} \"$(cat '{prompt_path}')\""
)
}
}
}
enum CodexRunnerState {
Preexec,
Running {
conversation_id: AIConversationId,
block_id: BlockId,
},
}
struct CodexHarnessRunner {
command: String,
cli_name: String,
/// Held so the temp file is cleaned up when the runner is dropped.
_temp_prompt_file: NamedTempFile,
client: Arc<dyn HarnessSupportClient>,
terminal_driver: ModelHandle<TerminalDriver>,
state: Mutex<CodexRunnerState>,
/// Codex session UUID. Populated lazily by [`HarnessRunner::handle_session_update`]
/// once the codex hooks emit `SessionStart`. Set once (using `OnceLock`).
session_id: OnceLock<Uuid>,
/// Path to the codex session rollout JSONL file. Populated by the first
/// successful [`find_session_file`] walk so that subsequent saves skip the YYYY/MM/DD
/// directory walk and read the JSONL file directly.
transcript_path: OnceLock<PathBuf>,
/// Optionally supply an existing conversation ID.
preexisting_conversation_id: Option<AIConversationId>,
}
impl CodexHarnessRunner {
#[allow(clippy::too_many_arguments)]
fn new(
cli_command: &str,
prompt: &str,
_system_prompt: Option<&str>,
_working_dir: &Path,
client: Arc<dyn HarnessSupportClient>,
terminal_driver: ModelHandle<TerminalDriver>,
resume: Option<CodexResumeInfo>,
) -> Result<Self, AgentDriverError> {
let temp_file = write_temp_file("oz_prompt_", prompt, ".txt")?;
let prompt_path = temp_file.path().display().to_string();
let (session_id, preexisting_conversation_id, transcript_path) = match resume {
Some(CodexResumeInfo {
conversation_id,
session_id,
mut envelope,
}) => {
let continuation = rehydrate_codex_transcript(&mut envelope, _working_dir)
.map_err(AgentDriverError::ConfigBuildFailed)?;
(
Some(session_id),
Some(conversation_id),
Some(continuation.transcript_path),
)
}
None => (None, None, None),
};
let command = codex_command(cli_command, session_id.as_ref(), &prompt_path);
let session_id_cell: OnceLock<Uuid> = OnceLock::new();
if let Some(id) = session_id {
let _ = session_id_cell.set(id);
}
let transcript_path_cell: OnceLock<PathBuf> = OnceLock::new();
if let Some(p) = transcript_path {
let _ = transcript_path_cell.set(p);
}
Ok(Self {
command,
cli_name: cli_command.to_string(),
_temp_prompt_file: temp_file,
client,
terminal_driver,
state: Mutex::new(CodexRunnerState::Preexec),
session_id: session_id_cell,
transcript_path: transcript_path_cell,
preexisting_conversation_id,
})
}
/// Return the filepath for the session transcript, walking the codex sessions tree to find it on the
/// first save call.
async fn resolve_transcript_path(&self) -> Option<PathBuf> {
if let Some(cached) = self.transcript_path.get() {
return Some(cached.clone());
}
let session_id = self.session_id.get().copied()?;
let resolved = tokio::task::spawn_blocking(move || -> Option<PathBuf> {
let root = codex_sessions_root().ok()?;
find_session_file(&root, session_id)
})
.await
.ok()
.flatten()?;
let _ = self.transcript_path.set(resolved.clone());
Some(resolved)
}
}
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl HarnessRunner for CodexHarnessRunner {
fn harness_name(&self) -> &str {
&self.cli_name
}
async fn start(
&self,
foreground: &ModelSpawner<AgentDriver>,
setup_events: &SetupClientEventReporter,
) -> Result<CommandHandle, AgentDriverError> {
// Resume runs reuse the prior server conversation id; fresh runs mint a new one.
let conversation_id = match self.preexisting_conversation_id {
Some(id) => {
log::info!("Resuming external conversation {id}");
id
}
None => {
let id = setup_events
.record_result(SetupStep::ThirdPartyHarnessExternalConversation, async {
self.client
.create_external_conversation(CODEX_CLI_FORMAT)
.await
.map_err(|e| {
log::error!("Failed to create external conversation: {e}");
AgentDriverError::ConfigBuildFailed(e)
})
})
.await?;
log::info!("Created external conversation {id}");
id
}
};
let command = self.command.clone();
let terminal_driver = self.terminal_driver.clone();
let command_handle = foreground
.spawn(move |_, ctx| {
terminal_driver.update(ctx, |driver, ctx| driver.execute_command(&command, ctx))
})
.await??
.await?;
*self.state.lock() = CodexRunnerState::Running {
conversation_id,
block_id: command_handle.block_id().clone(),
};
setup_events
.post_timeline_event(OzRunTimelineEvent::AgentStarted)
.await;
Ok(command_handle)
}
async fn exit(&self, foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
log::info!("Sending /exit to Codex CLI");
let terminal_driver = self.terminal_driver.clone();
foreground
.spawn(move |_, ctx| {
terminal_driver.update(ctx, |driver, ctx| {
driver.send_text_to_cli(CODEX_EXIT_COMMAND.to_string(), ctx);
});
})
.await
.map_err(|_| anyhow::anyhow!("Agent driver dropped while sending /exit"))
}
/// Capture the codex session ID from the `SessionStart` event picked up by the `CLIAgentSessionsModel`.
///
/// Relies on codex hooks being set up to emit this event correctly.
async fn handle_session_update(&self, foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
if self.session_id.get().is_some() {
return Ok(());
}
let terminal_driver = self.terminal_driver.clone();
let session_id_str = foreground
.spawn(move |_, ctx| {
let terminal_view_id = terminal_driver.as_ref(ctx).terminal_view().id();
CLIAgentSessionsModel::handle(ctx)
.as_ref(ctx)
.session(terminal_view_id)
.and_then(|s| s.session_context.session_id.clone())
})
.await
.ok()
.flatten();
let Some(session_id_str) = session_id_str else {
return Ok(());
};
match Uuid::parse_str(&session_id_str) {
Ok(uuid) => {
log::info!("Captured codex session id {uuid}");
let _ = self.session_id.set(uuid);
}
Err(e) => log::warn!("Failed to parse codex session id '{session_id_str}': {e}"),
}
Ok(())
}
async fn save_conversation(
&self,
save_point: SavePoint,
foreground: &ModelSpawner<AgentDriver>,
) -> Result<()> {
if matches!(save_point, SavePoint::Periodic)
&& !super::has_running_cli_agent(&self.terminal_driver, foreground).await
{
log::debug!("Will not save conversation, Codex not in progress");
return Ok(());
}
let (conversation_id, block_id) = match &*self.state.lock() {
CodexRunnerState::Preexec => {
log::warn!("save_conversation called before start");
return Ok(());
}
CodexRunnerState::Running {
conversation_id,
block_id,
} => (*conversation_id, block_id.clone()),
};
let session_id = self.session_id.get().copied();
let rollout_path = self.resolve_transcript_path().await;
let client = self.client.as_ref();
let is_final = matches!(save_point, SavePoint::Final);
futures::try_join!(
super::upload_current_block_snapshot(
foreground,
&self.terminal_driver,
client,
conversation_id,
block_id,
),
upload_transcript(client, conversation_id, session_id, rollout_path, is_final),
)?;
Ok(())
}
}
/// Upload the codex session transcript to the server. No-ops if the session UUID hasn't
/// been captured yet or no rollout file is on disk yet.
async fn upload_transcript(
client: &dyn HarnessSupportClient,
conversation_id: AIConversationId,
session_id: Option<Uuid>,
transcript_path: Option<PathBuf>,
is_final: bool,
) -> Result<()> {
let Some(session_id) = session_id else {
if is_final {
log::warn!(
"Codex session id still unknown at final save; transcript was never uploaded"
);
} else {
log::debug!("Codex session id not yet known; skipping transcript upload");
}
return Ok(());
};
let Some(transcript_path) = transcript_path else {
if is_final {
log::warn!(
"No codex rollout file found at final save for session {session_id}; transcript was never uploaded"
);
} else {
log::debug!("No codex rollout file yet for session {session_id}");
}
return Ok(());
};
log::info!("Uploading codex transcript to conversation {conversation_id}");
let body = tokio::task::spawn_blocking(move || -> Result<Vec<u8>> {
let entries = read_jsonl(&transcript_path)?;
let metadata = parse_session_meta(entries.first()).unwrap_or_default();
let envelope = CodexTranscriptEnvelope::new(session_id, metadata, entries);
serde_json::to_vec(&envelope).context("Failed to serialize codex transcript")
})
.await
.context("read_envelope task panicked")??;
let target = client
.get_transcript_upload_target(&conversation_id)
.await
.with_context(|| format!("Failed to get transcript upload target for {conversation_id}"))?;
upload_to_target(client.http_client(), &target, body).await?;
Ok(())
}
const CODEX_CONFIG_DIR: &str = ".codex";
const CODEX_HOME_ENV: &str = "CODEX_HOME";
const CODEX_AGENTS_OVERRIDE_FILE_NAME: &str = "AGENTS.override.md";
const CODEX_AUTH_FILE_NAME: &str = "auth.json";
const CODEX_CONFIG_TOML_FILE_NAME: &str = "config.toml";
const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
const CODEX_AUTH_MODE_API_KEY: &str = "apikey";
/// Lowercase string Codex's `TrustLevel` enum serializes to (codex
/// `protocol/src/config_types.rs::TrustLevel`).
const CODEX_TRUST_LEVEL_TRUSTED: &str = "trusted";
/// Top-level config key codex reads to override the built-in `openai` provider's base URL
/// (codex `core/src/config/mod.rs`).
const CODEX_OPENAI_BASE_URL_KEY: &str = "openai_base_url";
const CODEX_CHECK_FOR_UPDATE_ON_STARTUP_KEY: &str = "check_for_update_on_startup";
const CODEX_MODEL_KEY: &str = "model";
const CODEX_MODEL_REASONING_EFFORT_KEY: &str = "model_reasoning_effort";
/// Target model for the `[notice.model_migrations]` table that suppresses Codex's
/// "choose a newer model" upgrade prompt at session launch. We stamp this for any
/// pinned model id (even when it already matches the target) so the unattended
/// cloud run never blocks on the prompt.
///
/// TODO: Ideally, we would make this server-driven so we don't depend on a client
/// release to change this.
const CODEX_MODEL_MIGRATIONS_TARGET: &str = "gpt-5.4";
fn prepare_codex_environment_config(
working_dir: &Path,
system_prompt: Option<&str>,
resolved_env_vars: &HashMap<OsString, OsString>,
resolved_secrets: &HashMap<String, ManagedSecretValue>,
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
third_party_harness_model_config: Option<&HarnessModelConfig>,
) -> Result<()> {
let codex_dir = codex_config_dir()?;
if let Some(prompt) = system_prompt {
write_codex_agents_override(&codex_dir, prompt)?;
}
match resolve_openai_api_key(resolved_env_vars) {
Some(api_key) => prepare_codex_auth(&codex_dir.join(CODEX_AUTH_FILE_NAME), &api_key)?,
None => log::info!("No OPENAI_API_KEY available; skipping Codex auth.json seed"),
}
// Resolve the base URL directly from the typed OpenAI secret. This avoids
// leaking base_url into the child process environment and ensures we only
// apply it when the typed secret is the active API key source.
let openai_base_url = resolve_openai_base_url_from_secret(resolved_secrets, resolved_env_vars);
prepare_codex_config_toml(
&codex_dir.join(CODEX_CONFIG_TOML_FILE_NAME),
working_dir,
resolved_mcp_servers,
third_party_harness_model_config,
openai_base_url.as_deref(),
)?;
Ok(())
}
fn codex_config_dir() -> Result<PathBuf> {
if let Ok(dir) = std::env::var(CODEX_HOME_ENV) {
if !dir.is_empty() {
return Ok(PathBuf::from(dir));
}
}
dirs::home_dir()
.map(|home| home.join(CODEX_CONFIG_DIR))
.ok_or_else(|| anyhow::anyhow!("could not determine home directory"))
}
fn write_codex_agents_override(codex_dir: &Path, system_prompt: &str) -> Result<()> {
fs::create_dir_all(codex_dir).with_context(|| {
format!(
"Failed to create Codex config dir at {}",
codex_dir.display()
)
})?;
// Note: this currently works because we are only doing this for cloud agents; if we enable
// this for local runs we'll want to make sure we don't clobber any existing file overrides.
let prompt_path = codex_dir.join(CODEX_AGENTS_OVERRIDE_FILE_NAME);
fs::write(&prompt_path, system_prompt).with_context(|| {
format!(
"Failed to write Codex system prompt to {}",
prompt_path.display()
)
})
}
/// Mirrors the subset of Codex's `AuthDotJson` (codex `login/src/auth/storage.rs`) that we
/// need to seed. Unknown fields (`tokens`, `last_refresh`, `agent_identity`, ...) are
/// preserved via `extra` so we don't clobber an existing login.
#[derive(Default, Deserialize, Serialize, Debug)]
struct CodexAuthDotJson {
#[serde(default, skip_serializing_if = "Option::is_none")]
auth_mode: Option<String>,
#[serde(
rename = "OPENAI_API_KEY",
default,
skip_serializing_if = "Option::is_none"
)]
openai_api_key: Option<String>,
#[serde(flatten)]
extra: Map<String, Value>,
}
fn prepare_codex_auth(auth_path: &Path, api_key: &str) -> Result<()> {
let mut auth: CodexAuthDotJson = read_json_file_or_default(auth_path)?;
auth.openai_api_key = Some(api_key.to_owned());
if auth.auth_mode.is_none() {
auth.auth_mode = Some(CODEX_AUTH_MODE_API_KEY.to_owned());
}
write_codex_auth_json(auth_path, &auth)
}
/// Write Codex's `auth.json` with restrictive (0o600) permissions, mirroring how
/// codex sets up this file itself.
fn write_codex_auth_json(path: &Path, auth: &CodexAuthDotJson) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
let bytes = serde_json::to_vec_pretty(auth).context("Failed to serialize Codex auth.json")?;
#[cfg(unix)]
{
use std::io::Write as _;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("Failed to open {} for writing", path.display()))?;
file.set_permissions(fs::Permissions::from_mode(0o600))
.with_context(|| format!("Failed to set permissions on {}", path.display()))?;
file.write_all(&bytes)
.with_context(|| format!("Failed to write {}", path.display()))?;
}
#[cfg(not(unix))]
fs::write(path, &bytes).with_context(|| format!("Failed to write {}", path.display()))?;
Ok(())
}
/// Returns the OpenAI API key for Codex auth.
///
/// Checks the worker-injected process env first (not in the resolved map since
/// `build_secret_env_vars` skips env vars already present in the process env),
/// then falls back to the resolved secret env vars map.
fn resolve_openai_api_key(resolved_env_vars: &HashMap<OsString, OsString>) -> Option<String> {
// Worker-injected process env wins.
if let Ok(value) = std::env::var(OPENAI_API_KEY_ENV) {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_owned());
}
}
// Otherwise use the resolved value from the secrets map.
resolved_env_vars
.get(OsStr::new(OPENAI_API_KEY_ENV))
.and_then(|v| v.to_str())
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
}
/// Returns the OpenAI base URL from the typed secret, if applicable.
///
/// The base URL is only used when the typed `OpenaiApiKey` secret is the active
/// source of `OPENAI_API_KEY`. If a worker-injected process env already provides
/// the API key, the typed-secret base URL is not applied (the worker controls
/// both the key and endpoint).
fn resolve_openai_base_url_from_secret(
secrets: &HashMap<String, ManagedSecretValue>,
resolved_env_vars: &HashMap<OsString, OsString>,
) -> Option<String> {
// If the worker already injected an API key, the typed secret lost
// precedence — do not apply its base URL.
if std::env::var(OPENAI_API_KEY_ENV)
.ok()
.is_some_and(|v| !v.trim().is_empty())
{
return None;
}
// Only apply when the resolved env vars actually contain OPENAI_API_KEY
// from the typed secret (i.e. the secret was not skipped).
resolved_env_vars.get(OsStr::new(OPENAI_API_KEY_ENV))?;
secrets.values().find_map(|secret| match secret {
ManagedSecretValue::OpenaiApiKey { base_url, .. } => base_url
.as_ref()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty()),
_ => None,
})
}
/// Edit `~/.codex/config.toml` via `toml_edit` to seed the harness defaults
/// while preserving anything that might already exist there. We handle:
/// - project trust: for a working dir and all of its git repo subdirectories,
/// set the projects to `trusted`.
/// - base URL: when `openai_base_url` is provided (from the secret's `base_url`
/// field), write it to config.toml. When absent, skip the key entirely so
/// Codex uses the provider's default global endpoint.
/// - update checks: disable Codex's startup update prompt for unattended runs.
/// - model override: when a non-default harness model config is
/// supplied, write the top-level `model` key so Codex pins the chosen model
/// for new sessions.
fn prepare_codex_config_toml(
config_toml_path: &Path,
working_dir: &Path,
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
third_party_harness_model_config: Option<&HarnessModelConfig>,
openai_base_url: Option<&str>,
) -> Result<()> {
let existing = match fs::read_to_string(config_toml_path) {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => {
return Err(anyhow::Error::from(e).context(format!(
"Failed to read Codex config.toml at {}",
config_toml_path.display()
)));
}
};
let mut doc: toml_edit::DocumentMut = existing.parse().with_context(|| {
format!(
"Failed to parse Codex config.toml at {}",
config_toml_path.display()
)
})?;
// Only write openai_base_url when the secret specifies one.
if let Some(url) = openai_base_url {
set_codex_openai_base_url(&mut doc, url);
}
set_codex_check_for_update_on_startup(&mut doc, false);
set_codex_model(&mut doc, third_party_harness_model_config);
set_codex_model_reasoning_effort(&mut doc, third_party_harness_model_config);
let canonical = working_dir.canonicalize().with_context(|| {
format!(
"Failed to canonicalize Codex working dir at {}",
working_dir.display()
)
})?;
let project_key = canonical.to_string_lossy().into_owned();
set_codex_project_trust_level(&mut doc, &project_key, CODEX_TRUST_LEVEL_TRUSTED);
// Codex's trust check is not recursive (see openai/codex#19426) -- since we
// clone the git repos into workspace/ for cloud agents, we usually have git
// repo children that we also want to trust.
for child_repo in find_child_git_repos(&canonical) {
let key = child_repo.to_string_lossy().into_owned();
set_codex_project_trust_level(&mut doc, &key, CODEX_TRUST_LEVEL_TRUSTED);
}
write_codex_mcp_servers(&mut doc, resolved_mcp_servers);
if let Some(parent) = config_toml_path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("Failed to create Codex config dir at {}", parent.display())
})?;
}
fs::write(config_toml_path, doc.to_string()).with_context(|| {
format!(
"Failed to write Codex config.toml at {}",
config_toml_path.display()
)
})
}
/// Set the top-level `openai_base_url` key, overwriting any existing value.
fn set_codex_openai_base_url(doc: &mut toml_edit::DocumentMut, base_url: &str) {
doc[CODEX_OPENAI_BASE_URL_KEY] = toml_edit::value(base_url);
}
fn set_codex_check_for_update_on_startup(doc: &mut toml_edit::DocumentMut, enabled: bool) {
doc[CODEX_CHECK_FOR_UPDATE_ON_STARTUP_KEY] = toml_edit::value(enabled);
}
fn set_codex_model_reasoning_effort(
doc: &mut toml_edit::DocumentMut,
third_party_harness_model_config: Option<&HarnessModelConfig>,
) {
let Some(reasoning_level) = third_party_harness_model_config
.and_then(|config| config.reasoning_level.as_deref())
.filter(|level| !level.is_empty())
else {
doc.remove(CODEX_MODEL_REASONING_EFFORT_KEY);
return;
};
doc[CODEX_MODEL_REASONING_EFFORT_KEY] = toml_edit::value(reasoning_level);
}
fn set_codex_model(
doc: &mut toml_edit::DocumentMut,
third_party_harness_model_config: Option<&HarnessModelConfig>,
) {
let Some(model_id) = third_party_harness_model_config
.map(|config| config.model_id.as_str())
.filter(|id| !id.is_empty() && *id != "default")
else {
// No model specified or "default" selected — remove any pre-existing
// key so Codex uses its own default.
doc.remove(CODEX_MODEL_KEY);
return;
};
doc[CODEX_MODEL_KEY] = toml_edit::value(model_id);
// Codex's TUI prompts the user to upgrade older models on session launch even when
// a `model` key has been pinned. Stamping a migration entry keyed on the chosen
// model id suppresses that prompt for the unattended cloud run. We do this
// unconditionally rather than enumerating a list of "old" models on the client:
// mapping the migration target to itself (e.g. `gpt-5.4 = "gpt-5.4"`) is a no-op
// for Codex, and keeping the client free of model-version knowledge means we
// don't have to ship a client update every time Anthropic/OpenAI ages out a model.
set_codex_model_migration(doc, model_id, CODEX_MODEL_MIGRATIONS_TARGET);
}
fn set_codex_model_migration(
doc: &mut toml_edit::DocumentMut,
from_model_id: &str,
to_model_id: &str,
) {
if !doc.contains_table("notice") {
let mut notice_tbl = toml_edit::Table::new();
notice_tbl.set_implicit(true);
doc.insert("notice", toml_edit::Item::Table(notice_tbl));
}
let migrations_tbl = doc["notice"]
.as_table_mut()
.expect("notice table inserted above")
.entry("model_migrations")
.or_insert_with(toml_edit::table)
.as_table_mut()
.expect("model_migrations entry is a table");
migrations_tbl.set_implicit(false);
migrations_tbl[from_model_id] = toml_edit::value(to_model_id);
}
/// Return immediate subdirectories of `dir` that contain a `.git`.
fn find_child_git_repos(dir: &Path) -> Vec<std::path::PathBuf> {
let Ok(entries) = fs::read_dir(dir) else {
return Vec::new();
};
entries
.flatten()
.filter_map(|entry| {
let path = entry.path();
(path.is_dir() && path.join(".git").exists()).then_some(path)
})
.collect()
}
/// Insert/update `[projects."<project_key>"] trust_level = <trust_level>`.
///
/// Codex itself always writes `projects` as an explicit table, so we don't
/// handle the inline-table form here.
fn set_codex_project_trust_level(
doc: &mut toml_edit::DocumentMut,
project_key: &str,
trust_level: &str,
) {
if !doc.contains_table("projects") {
let mut projects_tbl = toml_edit::Table::new();
projects_tbl.set_implicit(true);
doc.insert("projects", toml_edit::Item::Table(projects_tbl));
}
let proj_tbl = doc["projects"]
.as_table_mut()
.expect("projects table inserted above")
.entry(project_key)
.or_insert_with(toml_edit::table)
.as_table_mut()
.expect("project entry is a table");
proj_tbl.set_implicit(false);
proj_tbl["trust_level"] = toml_edit::value(trust_level);
}
/// Write resolved MCP servers into `[mcp_servers.<name>]` sections in the Codex config.
fn write_codex_mcp_servers(
doc: &mut toml_edit::DocumentMut,
servers: &HashMap<String, JSONMCPServer>,
) {
if servers.is_empty() {
return;
}
if !doc.contains_table("mcp_servers") {
let mut tbl = toml_edit::Table::new();
tbl.set_implicit(true);
doc.insert("mcp_servers", toml_edit::Item::Table(tbl));
}
let mcp_tbl = doc["mcp_servers"]
.as_table_mut()
.expect("mcp_servers table inserted above");
for (name, server) in servers {
let entry = mcp_tbl
.entry(name)
.or_insert_with(toml_edit::table)
.as_table_mut()
.expect("mcp_servers entry is a table");
entry.set_implicit(false);
match &server.transport_type {
JSONTransportType::CLIServer {
command,
args,
env,
working_directory,
} => {
entry["command"] = toml_edit::value(command.as_str());
if !args.is_empty() {
let mut arr = toml_edit::Array::new();
for arg in args {
arr.push(arg.as_str());
}
entry["args"] = toml_edit::value(arr);
}
if !env.is_empty() {
let mut env_tbl = toml_edit::InlineTable::new();
for (k, v) in env {
env_tbl.insert(k, v.as_str().into());
}
entry["env"] = toml_edit::value(env_tbl);
}
if let Some(cwd) = working_directory {
entry["cwd"] = toml_edit::value(cwd.as_str());
}
}
JSONTransportType::SSEServer { url, headers } => {
entry["url"] = toml_edit::value(url.as_str());
if !headers.is_empty() {
let mut hdrs_tbl = toml_edit::InlineTable::new();
for (k, v) in headers {
hdrs_tbl.insert(k, v.as_str().into());
}
entry["http_headers"] = toml_edit::value(hdrs_tbl);
}
}
}
}
}
#[cfg(test)]
#[path = "codex_tests.rs"]
mod tests;
@@ -0,0 +1,849 @@
use std::collections::HashMap;
use std::ffi::OsString;
use std::fs;
use std::sync::Arc;
use serde_json::Value;
use tempfile::TempDir;
use uuid::Uuid;
use super::super::codex_transcript::CodexTranscriptEnvelope;
use super::*;
use crate::ai::agent::conversation::AIConversationId;
use crate::server::server_api::harness_support::MockHarnessSupportClient;
#[test]
fn prepare_codex_auth_writes_fresh_file_with_api_key_mode() {
let tmp = TempDir::new().unwrap();
let auth_path = tmp.path().join(".codex/auth.json");
prepare_codex_auth(&auth_path, "sk-test-key").unwrap();
let auth: Value = serde_json::from_slice(&fs::read(&auth_path).unwrap()).unwrap();
assert_eq!(auth["OPENAI_API_KEY"], "sk-test-key");
assert_eq!(auth["auth_mode"], "apikey");
}
#[test]
fn prepare_codex_auth_preserves_unrelated_fields() {
let tmp = TempDir::new().unwrap();
let auth_path = tmp.path().join("auth.json");
fs::write(
&auth_path,
r#"{"tokens":{"access_token":"tok"},"last_refresh":"2026-01-01T00:00:00Z"}"#,
)
.unwrap();
prepare_codex_auth(&auth_path, "sk-new-key").unwrap();
let auth: Value = serde_json::from_slice(&fs::read(&auth_path).unwrap()).unwrap();
assert_eq!(auth["OPENAI_API_KEY"], "sk-new-key");
assert_eq!(auth["auth_mode"], "apikey");
assert_eq!(auth["tokens"]["access_token"], "tok");
assert_eq!(auth["last_refresh"], "2026-01-01T00:00:00Z");
}
#[test]
fn prepare_codex_auth_does_not_overwrite_existing_auth_mode() {
let tmp = TempDir::new().unwrap();
let auth_path = tmp.path().join("auth.json");
fs::write(&auth_path, r#"{"auth_mode":"Chatgpt"}"#).unwrap();
prepare_codex_auth(&auth_path, "sk-new-key").unwrap();
let auth: Value = serde_json::from_slice(&fs::read(&auth_path).unwrap()).unwrap();
assert_eq!(auth["auth_mode"], "Chatgpt");
assert_eq!(auth["OPENAI_API_KEY"], "sk-new-key");
}
#[test]
fn prepare_codex_auth_overwrites_stale_openai_api_key() {
let tmp = TempDir::new().unwrap();
let auth_path = tmp.path().join("auth.json");
fs::write(
&auth_path,
r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-old"}"#,
)
.unwrap();
prepare_codex_auth(&auth_path, "sk-new").unwrap();
let auth: Value = serde_json::from_slice(&fs::read(&auth_path).unwrap()).unwrap();
assert_eq!(auth["OPENAI_API_KEY"], "sk-new");
}
#[cfg(unix)]
#[test]
fn prepare_codex_auth_writes_with_0600_perms() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let auth_path = tmp.path().join(".codex/auth.json");
prepare_codex_auth(&auth_path, "sk-test-key").unwrap();
let mode = fs::metadata(&auth_path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn resolve_openai_api_key_returns_value_from_resolved_map() {
let resolved = HashMap::from([(
OsString::from("OPENAI_API_KEY"),
OsString::from("sk-from-secret"),
)]);
assert_eq!(
resolve_openai_api_key(&resolved).as_deref(),
Some("sk-from-secret")
);
}
#[test]
#[serial_test::serial]
fn resolve_openai_api_key_falls_back_to_env_var() {
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
std::env::set_var(OPENAI_API_KEY_ENV, "sk-from-env");
let result = resolve_openai_api_key(&HashMap::new());
match prev {
Some(v) => std::env::set_var(OPENAI_API_KEY_ENV, v),
None => std::env::remove_var(OPENAI_API_KEY_ENV),
}
assert_eq!(result.as_deref(), Some("sk-from-env"));
}
#[test]
#[serial_test::serial]
fn resolve_openai_api_key_returns_none_when_map_and_env_empty() {
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
std::env::remove_var(OPENAI_API_KEY_ENV);
let result = resolve_openai_api_key(&HashMap::new());
if let Some(v) = prev {
std::env::set_var(OPENAI_API_KEY_ENV, v);
}
assert_eq!(result, None);
}
#[test]
#[serial_test::serial]
fn resolve_openai_api_key_prefers_env_over_resolved_map() {
// Worker-injected env var wins over the resolved secret map because
// build_secret_env_vars skips secrets that collide with process env.
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
std::env::set_var(OPENAI_API_KEY_ENV, "sk-from-env");
let resolved = HashMap::from([(
OsString::from("OPENAI_API_KEY"),
OsString::from("sk-from-secret"),
)]);
let result = resolve_openai_api_key(&resolved);
match prev {
Some(v) => std::env::set_var(OPENAI_API_KEY_ENV, v),
None => std::env::remove_var(OPENAI_API_KEY_ENV),
}
assert_eq!(result.as_deref(), Some("sk-from-env"));
}
#[test]
#[serial_test::serial]
fn resolve_openai_api_key_uses_resolved_map_when_env_empty() {
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
std::env::set_var(OPENAI_API_KEY_ENV, " ");
let resolved = HashMap::from([(
OsString::from("OPENAI_API_KEY"),
OsString::from("sk-from-secret"),
)]);
let result = resolve_openai_api_key(&resolved);
match prev {
Some(v) => std::env::set_var(OPENAI_API_KEY_ENV, v),
None => std::env::remove_var(OPENAI_API_KEY_ENV),
}
assert_eq!(result.as_deref(), Some("sk-from-secret"));
}
#[test]
#[serial_test::serial]
fn prepare_codex_environment_config_honors_codex_home() {
let tmp = TempDir::new().unwrap();
let codex_home = tmp.path().join("codex-home");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
let prev_codex_home = std::env::var(CODEX_HOME_ENV).ok();
let prev_openai_api_key = std::env::var(OPENAI_API_KEY_ENV).ok();
std::env::set_var(CODEX_HOME_ENV, &codex_home);
std::env::remove_var(OPENAI_API_KEY_ENV);
let resolved = HashMap::from([(
OsString::from(OPENAI_API_KEY_ENV),
OsString::from("sk-from-secret"),
)]);
let model_config = harness_model_config("gpt-5.5", None);
let result = prepare_codex_environment_config(
&working_dir,
Some("system prompt"),
&resolved,
&HashMap::new(),
&HashMap::new(),
Some(&model_config),
);
match prev_codex_home {
Some(v) => std::env::set_var(CODEX_HOME_ENV, v),
None => std::env::remove_var(CODEX_HOME_ENV),
}
match prev_openai_api_key {
Some(v) => std::env::set_var(OPENAI_API_KEY_ENV, v),
None => std::env::remove_var(OPENAI_API_KEY_ENV),
}
result.unwrap();
assert_eq!(
fs::read_to_string(codex_home.join(CODEX_AGENTS_OVERRIDE_FILE_NAME)).unwrap(),
"system prompt"
);
let auth: Value =
serde_json::from_slice(&fs::read(codex_home.join(CODEX_AUTH_FILE_NAME)).unwrap()).unwrap();
assert_eq!(auth["OPENAI_API_KEY"], "sk-from-secret");
let cfg = read_codex_config(&codex_home.join(CODEX_CONFIG_TOML_FILE_NAME));
assert_eq!(cfg["model"].as_str(), Some("gpt-5.5"));
assert!(!cfg.contains_key("openai_base_url"));
assert!(!tmp.path().join(CODEX_CONFIG_DIR).exists());
}
fn read_codex_config(path: &std::path::Path) -> toml::Table {
let content = fs::read_to_string(path).unwrap();
toml::from_str(&content).unwrap()
}
fn harness_model_config(model_id: &str, reasoning_level: Option<&str>) -> HarnessModelConfig {
HarnessModelConfig {
model_id: model_id.to_string(),
reasoning_level: reasoning_level.map(str::to_string),
}
}
#[test]
fn prepare_codex_config_toml_writes_fresh_config() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join(".codex/config.toml");
let working_dir = tmp.path().join("workspace/proj");
fs::create_dir_all(&working_dir).unwrap();
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
let canonical = working_dir.canonicalize().unwrap();
let key = canonical.to_string_lossy().into_owned();
let cfg = read_codex_config(&config_path);
assert_eq!(cfg["check_for_update_on_startup"].as_bool(), Some(false));
assert_eq!(
cfg["projects"][&key]["trust_level"].as_str(),
Some("trusted")
);
}
#[test]
fn prepare_codex_config_toml_preserves_unrelated_keys() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
fs::write(
&config_path,
"model = \"gpt-5\"\n\n[projects.\"/other/path\"]\ntrust_level = \"trusted\"\n",
)
.unwrap();
// Pass `None` — the `model` key is intentionally removed (managed
// key), but unrelated keys like existing project entries are kept.
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
let canonical = working_dir.canonicalize().unwrap();
let key = canonical.to_string_lossy().into_owned();
let cfg = read_codex_config(&config_path);
// `model` is a managed key — removed when no override is provided.
assert!(!cfg.contains_key("model"));
assert_eq!(
cfg["projects"]["/other/path"]["trust_level"].as_str(),
Some("trusted")
);
assert_eq!(
cfg["projects"][&key]["trust_level"].as_str(),
Some("trusted")
);
}
#[test]
fn prepare_codex_config_toml_is_idempotent() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
let after_first = fs::read_to_string(&config_path).unwrap();
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
let after_second = fs::read_to_string(&config_path).unwrap();
assert_eq!(after_first, after_second);
let canonical = working_dir.canonicalize().unwrap();
let key = canonical.to_string_lossy().into_owned();
let cfg: toml::Table = toml::from_str(&after_second).unwrap();
let projects = cfg["projects"].as_table().unwrap();
assert_eq!(projects.len(), 1);
assert_eq!(projects[&key]["trust_level"].as_str(), Some("trusted"));
}
#[test]
fn prepare_codex_config_toml_upgrades_untrusted_entry() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
let canonical = working_dir.canonicalize().unwrap();
let key = canonical.to_string_lossy().into_owned();
// Use a TOML literal-string key ('...') so Windows backslashes in `key`
// (e.g. `\\?\C:\...`) are not interpreted as escape sequences.
fs::write(
&config_path,
format!("[projects.'{key}']\ntrust_level = \"untrusted\"\n"),
)
.unwrap();
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
let cfg = read_codex_config(&config_path);
assert_eq!(
cfg["projects"][&key]["trust_level"].as_str(),
Some("trusted")
);
}
#[test]
fn prepare_codex_config_toml_trusts_multiple_child_repos() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
let repo_a = working_dir.join("a");
let repo_b = working_dir.join("b");
fs::create_dir_all(repo_a.join(".git")).unwrap();
fs::create_dir_all(repo_b.join(".git")).unwrap();
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
let cfg = read_codex_config(&config_path);
let projects = cfg["projects"].as_table().unwrap();
let canonical_a = repo_a.canonicalize().unwrap();
let canonical_b = repo_b.canonicalize().unwrap();
assert_eq!(
projects[canonical_a.to_str().unwrap()]["trust_level"].as_str(),
Some("trusted")
);
assert_eq!(
projects[canonical_b.to_str().unwrap()]["trust_level"].as_str(),
Some("trusted")
);
}
#[test]
fn prepare_codex_config_toml_overwrites_stale_openai_base_url() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
fs::write(
&config_path,
"openai_base_url = \"https://api.openai.com/v1\"\n",
)
.unwrap();
prepare_codex_config_toml(
&config_path,
&working_dir,
&HashMap::new(),
None,
Some("https://custom.api.openai.com/v1"),
)
.unwrap();
let cfg = read_codex_config(&config_path);
assert_eq!(
cfg["openai_base_url"].as_str(),
Some("https://custom.api.openai.com/v1")
);
}
#[test]
fn write_codex_mcp_servers_cli_server() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
let servers = HashMap::from([(
"my-mcp".to_string(),
JSONMCPServer {
transport_type: JSONTransportType::CLIServer {
command: "npx".to_string(),
args: vec!["-y".to_string(), "@some/mcp".to_string()],
env: HashMap::from([("TOKEN".to_string(), "abc".to_string())]),
working_directory: None,
},
},
)]);
prepare_codex_config_toml(&config_path, &working_dir, &servers, None, None).unwrap();
let cfg = read_codex_config(&config_path);
let mcp = &cfg["mcp_servers"]["my-mcp"];
assert_eq!(mcp["command"].as_str(), Some("npx"));
let args: Vec<&str> = mcp["args"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(args, vec!["-y", "@some/mcp"]);
assert_eq!(mcp["env"]["TOKEN"].as_str(), Some("abc"));
}
#[test]
fn write_codex_mcp_servers_sse_server() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
let servers = HashMap::from([(
"remote-mcp".to_string(),
JSONMCPServer {
transport_type: JSONTransportType::SSEServer {
url: "https://mcp.example.com/sse".to_string(),
headers: HashMap::from([("X-Key".to_string(), "val".to_string())]),
},
},
)]);
prepare_codex_config_toml(&config_path, &working_dir, &servers, None, None).unwrap();
let cfg = read_codex_config(&config_path);
let mcp = &cfg["mcp_servers"]["remote-mcp"];
assert_eq!(mcp["url"].as_str(), Some("https://mcp.example.com/sse"));
assert_eq!(mcp["http_headers"]["X-Key"].as_str(), Some("val"));
}
#[test]
fn write_codex_mcp_servers_cli_server_with_cwd() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
let servers = HashMap::from([(
"my-mcp".to_string(),
JSONMCPServer {
transport_type: JSONTransportType::CLIServer {
command: "node".to_string(),
args: vec!["server.js".to_string()],
env: HashMap::new(),
working_directory: Some("/opt/mcp-server".to_string()),
},
},
)]);
prepare_codex_config_toml(&config_path, &working_dir, &servers, None, None).unwrap();
let cfg = read_codex_config(&config_path);
let mcp = &cfg["mcp_servers"]["my-mcp"];
assert_eq!(mcp["command"].as_str(), Some("node"));
assert_eq!(mcp["cwd"].as_str(), Some("/opt/mcp-server"));
}
#[test]
fn write_codex_mcp_servers_cli_server_without_cwd_omits_key() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
let servers = HashMap::from([(
"my-mcp".to_string(),
JSONMCPServer {
transport_type: JSONTransportType::CLIServer {
command: "npx".to_string(),
args: vec![],
env: HashMap::new(),
working_directory: None,
},
},
)]);
prepare_codex_config_toml(&config_path, &working_dir, &servers, None, None).unwrap();
let cfg = read_codex_config(&config_path);
let mcp = &cfg["mcp_servers"]["my-mcp"];
assert!(mcp.get("cwd").is_none());
}
#[test]
fn prepare_codex_config_toml_writes_model_when_specified() {
// A non-default model id is written to the top-level `model` key so Codex pins it
// for new sessions launched from this `~/.codex/config.toml`. Even for the
// current target model, we stamp a self-referential migration entry so the
// upgrade prompt is suppressed regardless of what the user selected.
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
prepare_codex_config_toml(
&config_path,
&working_dir,
&HashMap::new(),
Some(&harness_model_config("gpt-5.5", None)),
None,
)
.unwrap();
let cfg = read_codex_config(&config_path);
assert_eq!(cfg["model"].as_str(), Some("gpt-5.5"));
assert_eq!(
cfg["notice"]["model_migrations"]["gpt-5.5"].as_str(),
Some(CODEX_MODEL_MIGRATIONS_TARGET),
);
}
#[test]
fn prepare_codex_config_toml_writes_model_migration_for_older_model() {
// For an older model id, the migration entry maps it to the current target
// so Codex's "choose a newer model" prompt is suppressed at session launch.
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
prepare_codex_config_toml(
&config_path,
&working_dir,
&HashMap::new(),
Some(&harness_model_config("gpt-5.2", None)),
None,
)
.unwrap();
let cfg = read_codex_config(&config_path);
assert_eq!(cfg["model"].as_str(), Some("gpt-5.2"));
assert_eq!(
cfg["notice"]["model_migrations"]["gpt-5.2"].as_str(),
Some(CODEX_MODEL_MIGRATIONS_TARGET),
);
}
#[test]
fn prepare_codex_config_toml_skips_model_for_default_sentinel() {
// The literal "default" sentinel means "let Codex pick its own default model";
// we should NOT write a `model` key (or a migration entry) in that case.
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
prepare_codex_config_toml(
&config_path,
&working_dir,
&HashMap::new(),
Some(&harness_model_config("default", None)),
None,
)
.unwrap();
let cfg = read_codex_config(&config_path);
assert!(
cfg.get("model").is_none(),
"`model` should not be written for the default sentinel"
);
assert!(
cfg.get("notice").is_none(),
"`[notice]` table should not be written without a pinned model id"
);
}
#[test]
fn prepare_codex_config_toml_skips_model_when_none() {
// No model id supplied means the user didn't pick one; we should not write a
// `model` key or any `[notice.model_migrations]` entries.
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
let cfg = read_codex_config(&config_path);
assert!(
cfg.get("model").is_none(),
"`model` should not be written when no override is supplied"
);
assert!(
cfg.get("notice").is_none(),
"`[notice]` table should not be written without a pinned model id"
);
}
#[test]
fn prepare_codex_config_toml_writes_model_reasoning_effort_when_specified() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
prepare_codex_config_toml(
&config_path,
&working_dir,
&HashMap::new(),
Some(&harness_model_config("gpt-5.5", Some("medium"))),
None,
)
.unwrap();
let cfg = read_codex_config(&config_path);
assert_eq!(cfg["model"].as_str(), Some("gpt-5.5"));
assert_eq!(cfg["model_reasoning_effort"].as_str(), Some("medium"));
}
#[test]
fn prepare_codex_config_toml_removes_stale_model_reasoning_effort_when_none() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
fs::write(&config_path, "model_reasoning_effort = \"high\"\n").unwrap();
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
let cfg = read_codex_config(&config_path);
assert!(cfg.get("model_reasoning_effort").is_none());
}
#[test]
fn find_child_git_repos_returns_only_repo_children() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace");
let repo = workspace.join("repo");
let other = workspace.join("other");
fs::create_dir_all(repo.join(".git")).unwrap();
fs::create_dir_all(&other).unwrap();
let found = find_child_git_repos(&workspace);
let canonical_repo = repo.canonicalize().unwrap();
assert_eq!(found.len(), 1);
assert_eq!(found[0].canonicalize().unwrap(), canonical_repo);
}
#[test]
fn find_child_git_repos_returns_empty_when_dir_missing() {
let tmp = TempDir::new().unwrap();
let missing = tmp.path().join("does-not-exist");
assert!(find_child_git_repos(&missing).is_empty());
}
#[test]
fn codex_command_with_session_id_invokes_resume_subcommand() {
let uuid = Uuid::new_v4();
let cmd = codex_command("codex", Some(&uuid), "/tmp/prompt.txt");
assert!(
cmd.contains(&format!(
"resume --dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust {uuid}"
)),
"resume command should pass UUID to `resume`: {cmd}"
);
assert!(
cmd.contains("\"$(cat '/tmp/prompt.txt')\""),
"resume command should pipe prompt: {cmd}"
);
}
#[test]
fn codex_command_without_session_id_bypasses_hook_trust() {
let cmd = codex_command("codex", None, "/tmp/prompt.txt");
assert!(
cmd.contains("--dangerously-bypass-approvals-and-sandbox"),
"command should bypass approvals and sandbox: {cmd}"
);
assert!(
cmd.contains("--dangerously-bypass-hook-trust"),
"command should bypass hook trust for driver-installed hooks: {cmd}"
);
assert!(
cmd.contains("\"$(cat '/tmp/prompt.txt')\""),
"command should pipe prompt: {cmd}"
);
}
#[tokio::test]
async fn fetch_resume_payload_maps_404_to_resume_state_missing() {
let mut mock = MockHarnessSupportClient::new();
mock.expect_fetch_transcript()
.returning(|| Err(anyhow::anyhow!("upstream returned status 404")));
let conversation_id = AIConversationId::new();
let result = CodexHarness
.fetch_resume_payload(&conversation_id, Arc::new(mock))
.await;
match result {
Err(AgentDriverError::ConversationResumeStateMissing { harness, .. }) => {
assert_eq!(harness, "codex");
}
other => panic!("expected ConversationResumeStateMissing, got {other:?}"),
}
}
#[tokio::test]
async fn fetch_resume_payload_maps_other_errors_to_load_failed() {
let mut mock = MockHarnessSupportClient::new();
mock.expect_fetch_transcript()
.returning(|| Err(anyhow::anyhow!("connection reset")));
let conversation_id = AIConversationId::new();
let result = CodexHarness
.fetch_resume_payload(&conversation_id, Arc::new(mock))
.await;
assert!(
matches!(result, Err(AgentDriverError::ConversationLoadFailed(_))),
"expected ConversationLoadFailed, got {result:?}"
);
}
#[test]
#[serial_test::serial]
fn resolve_openai_base_url_from_secret_returns_base_url_when_typed_secret_active() {
// When the typed OpenAI secret is the active API key source, the base URL
// should be extracted from the structured secret.
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
std::env::remove_var(OPENAI_API_KEY_ENV);
let secrets = HashMap::from([(
"openai-key".to_string(),
ManagedSecretValue::openai_api_key(
"sk-test",
Some("https://us.api.openai.com/v1".to_string()),
),
)]);
let resolved_env =
HashMap::from([(OsString::from("OPENAI_API_KEY"), OsString::from("sk-test"))]);
let result = resolve_openai_base_url_from_secret(&secrets, &resolved_env);
if let Some(v) = prev {
std::env::set_var(OPENAI_API_KEY_ENV, v);
}
assert_eq!(result.as_deref(), Some("https://us.api.openai.com/v1"));
}
#[test]
#[serial_test::serial]
fn resolve_openai_base_url_from_secret_returns_none_when_worker_env_wins() {
// When a worker-injected OPENAI_API_KEY already exists in process env,
// the typed-secret base_url should NOT be applied.
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
std::env::set_var(OPENAI_API_KEY_ENV, "sk-worker-key");
let secrets = HashMap::from([(
"openai-key".to_string(),
ManagedSecretValue::openai_api_key(
"sk-secret",
Some("https://us.api.openai.com/v1".to_string()),
),
)]);
let resolved_env = HashMap::new();
let result = resolve_openai_base_url_from_secret(&secrets, &resolved_env);
match prev {
Some(v) => std::env::set_var(OPENAI_API_KEY_ENV, v),
None => std::env::remove_var(OPENAI_API_KEY_ENV),
}
assert_eq!(result, None);
}
#[test]
#[serial_test::serial]
fn resolve_openai_base_url_from_secret_returns_none_when_no_base_url() {
// When the typed OpenAI secret has no base_url, None is returned.
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
std::env::remove_var(OPENAI_API_KEY_ENV);
let secrets = HashMap::from([(
"openai-key".to_string(),
ManagedSecretValue::openai_api_key("sk-test", None),
)]);
let resolved_env =
HashMap::from([(OsString::from("OPENAI_API_KEY"), OsString::from("sk-test"))]);
let result = resolve_openai_base_url_from_secret(&secrets, &resolved_env);
if let Some(v) = prev {
std::env::set_var(OPENAI_API_KEY_ENV, v);
}
assert_eq!(result, None);
}
#[test]
#[serial_test::serial]
fn resolve_openai_base_url_from_secret_returns_none_when_api_key_not_in_resolved() {
// When OPENAI_API_KEY is not in the resolved env vars (e.g. the secret was
// skipped due to collision), the base URL should not be applied.
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
std::env::remove_var(OPENAI_API_KEY_ENV);
let secrets = HashMap::from([(
"openai-key".to_string(),
ManagedSecretValue::openai_api_key(
"sk-test",
Some("https://us.api.openai.com/v1".to_string()),
),
)]);
let resolved_env = HashMap::new();
let result = resolve_openai_base_url_from_secret(&secrets, &resolved_env);
if let Some(v) = prev {
std::env::set_var(OPENAI_API_KEY_ENV, v);
}
assert_eq!(result, None);
}
#[tokio::test]
async fn fetch_resume_payload_returns_codex_variant_on_success() {
let uuid = Uuid::new_v4();
let envelope = CodexTranscriptEnvelope {
cwd: "/cloud/work".into(),
session_id: uuid,
codex_version: Some("0.55.0".to_string()),
session_start_timestamp: None,
entries: vec![serde_json::json!({"type": "event_msg"})],
};
let bytes = serde_json::to_vec(&envelope).unwrap();
let mut mock = MockHarnessSupportClient::new();
mock.expect_fetch_transcript()
.returning(move || Ok(bytes::Bytes::from(bytes.clone())));
let conversation_id = AIConversationId::new();
let payload = CodexHarness
.fetch_resume_payload(&conversation_id, Arc::new(mock))
.await
.unwrap()
.unwrap();
match payload {
ResumePayload::Codex(info) => {
assert_eq!(info.session_id, uuid);
assert_eq!(info.conversation_id, conversation_id);
assert_eq!(info.envelope.codex_version.as_deref(), Some("0.55.0"));
}
other => panic!("expected ResumePayload::Codex, got {other:?}"),
}
}
@@ -0,0 +1,248 @@
//! Codex session transcript envelope + rehydration helpers.
//!
//! Owns:
//! - [`CodexTranscriptEnvelope`] — the on-wire/on-GCS shape of a saved Codex rollout
//! (parsed JSONL entries plus session-level metadata). Reader/writer functions
//! interoperate with Codex's own `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`
//! layout (codex `rollout/src/recorder.rs`).
//! - [`CodexResumeInfo`] — everything the harness runner needs to resume an existing
//! Codex conversation: the Warp server conversation id to reuse, the codex session
//! uuid (`ThreadId`) to pass to `codex resume`, and the decoded envelope to rehydrate
//! onto disk.
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, Datelike, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use super::json_utils::entries_to_jsonl;
use crate::ai::agent::conversation::AIConversationId;
/// Env var codex honors to override `~/.codex` (see codex `core/src/config/mod.rs`).
const CODEX_HOME_ENV: &str = "CODEX_HOME";
const CODEX_HOME_DIRNAME: &str = ".codex";
/// Subdirectory under `$CODEX_HOME` where rollouts live.
const CODEX_SESSIONS_SUBDIR: &str = "sessions";
/// JSON envelope sent to the server representing a complete Codex session.
///
/// The transcript is the parsed JSONL content of the rollout file; codex's resume
/// path re-reads this JSONL line by line.
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub(crate) struct CodexTranscriptEnvelope {
/// The directory the codex session started in (recovered from the `SessionMeta` line).
pub(crate) cwd: PathBuf,
/// Codex session/thread UUID. Matches the trailing `-<uuid>` in the rollout filename.
pub(crate) session_id: Uuid,
/// `cli_version` from `SessionMeta`, surfaced separately for the server.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) codex_version: Option<String>,
/// Timestamp from the `SessionMeta` line, used to derive the YYYY/MM/DD directory
/// path when writing the rollout file back to disk.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) session_start_timestamp: Option<DateTime<Utc>>,
/// Parsed JSONL entries.
pub(crate) entries: Vec<Value>,
}
impl CodexTranscriptEnvelope {
pub(crate) fn new(session_id: Uuid, meta: CodexSessionMetadata, entries: Vec<Value>) -> Self {
Self {
cwd: meta.cwd,
session_id,
codex_version: meta.codex_version,
session_start_timestamp: meta.session_start_timestamp,
entries,
}
}
}
/// Session-level metadata pulled from the rollout's `SessionMeta` line.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct CodexSessionMetadata {
pub(crate) cwd: PathBuf,
pub(crate) codex_version: Option<String>,
pub(crate) session_start_timestamp: Option<DateTime<Utc>>,
}
/// Everything needed to resume an existing Codex conversation.
///
/// Built from a `--conversation` id after the client fetches the stored envelope from
/// the server. Passed into `CodexHarnessRunner::new` so the runner reuses the existing
/// session and server conversation ids instead of minting fresh ones.
#[derive(Debug)]
pub(crate) struct CodexResumeInfo {
/// Warp server-side conversation id. Reused so subsequent transcript/block-snapshot
/// uploads overwrite the same GCS objects.
pub(crate) conversation_id: AIConversationId,
/// Codex session uuid passed to `codex resume <session_id>`. Matches `envelope.session_id`.
pub(crate) session_id: Uuid,
/// Envelope fetched from the server, written back to disk before launching codex.
pub(crate) envelope: CodexTranscriptEnvelope,
}
#[derive(Debug)]
pub(crate) struct CodexLocalContinuation {
pub(crate) command: String,
pub(crate) transcript_path: PathBuf,
}
/// Resolve the codex sessions root, honoring `$CODEX_HOME` then falling back to `~/.codex`.
pub(crate) fn codex_sessions_root() -> anyhow::Result<PathBuf> {
let home = if let Ok(dir) = std::env::var(CODEX_HOME_ENV) {
PathBuf::from(dir)
} else {
dirs::home_dir()
.ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?
.join(CODEX_HOME_DIRNAME)
};
Ok(home.join(CODEX_SESSIONS_SUBDIR))
}
/// Walk `<sessions_root>/YYYY/MM/DD/` looking for a `rollout-*-<session_id>.jsonl`.
///
/// Returns `None` if `sessions_root` doesn't exist yet or no matching file is found.
pub(crate) fn find_session_file(sessions_root: &Path, session_id: Uuid) -> Option<PathBuf> {
if !sessions_root.exists() {
return None;
}
let suffix = format!("-{session_id}.jsonl");
for year_dir in read_subdirs(sessions_root) {
for month_dir in read_subdirs(&year_dir) {
for day_dir in read_subdirs(&month_dir) {
let entries = match fs::read_dir(&day_dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.starts_with("rollout-") && name.ends_with(&suffix) {
return Some(path);
}
}
}
}
}
None
}
fn read_subdirs(parent: &Path) -> impl Iterator<Item = PathBuf> {
fs::read_dir(parent)
.into_iter()
.flatten()
.filter_map(|entry| {
let entry = entry.ok()?;
entry.file_type().ok()?.is_dir().then(|| entry.path())
})
}
/// Pull `cwd` and `cli_version` out of the first JSONL line if it's a `SessionMeta`.
pub(crate) fn parse_session_meta(first: Option<&Value>) -> Option<CodexSessionMetadata> {
let entry = first?;
if entry.get("type").and_then(|v| v.as_str()) != Some("session_meta") {
return None;
}
let payload = entry.get("payload")?;
let cwd = PathBuf::from(payload.get("cwd").and_then(|v| v.as_str())?);
let codex_version = payload
.get("cli_version")
.and_then(|v| v.as_str())
.map(str::to_owned);
let session_start_timestamp = payload
.get("timestamp")
.and_then(|v| v.as_str())
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc));
Some(CodexSessionMetadata {
cwd,
codex_version,
session_start_timestamp,
})
}
/// Write `envelope` back under `<sessions_root>/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`.
///
/// YYYY/MM/DD and `<ts>` come from `envelope.session_start_timestamp`. Falls back to
/// today's UTC date if absent — codex's lookup is by UUID so the precise path doesn't
/// matter for resume to work.
pub(crate) fn write_envelope(
envelope: &CodexTranscriptEnvelope,
sessions_root: &Path,
) -> Result<PathBuf> {
let timestamp = envelope.session_start_timestamp.unwrap_or_else(Utc::now);
let day_dir = sessions_root
.join(format!("{:04}", timestamp.year()))
.join(format!("{:02}", timestamp.month()))
.join(format!("{:02}", timestamp.day()));
fs::create_dir_all(&day_dir)
.with_context(|| format!("Failed to create {}", day_dir.display()))?;
// Codex's filename format: `[year]-[month]-[day]T[hour]-[minute]-[second]`
// (codex `rollout/src/recorder.rs::precompute_log_file_info`).
let date_str = timestamp.format("%Y-%m-%dT%H-%M-%S").to_string();
let file_path = day_dir.join(format!(
"rollout-{date_str}-{session_id}.jsonl",
session_id = envelope.session_id
));
fs::write(&file_path, entries_to_jsonl(&envelope.entries)?)
.with_context(|| format!("Failed to write {}", file_path.display()))?;
Ok(file_path)
}
pub(crate) fn rehydrate_codex_transcript(
envelope: &mut CodexTranscriptEnvelope,
local_cwd: &Path,
) -> Result<CodexLocalContinuation> {
envelope.cwd = local_cwd.to_path_buf();
if let Some(Value::Object(entry)) = envelope.entries.first_mut() {
if entry.get("type").and_then(|value| value.as_str()) == Some("session_meta") {
if let Some(Value::Object(payload)) = entry.get_mut("payload") {
payload.insert(
"cwd".to_string(),
Value::String(local_cwd.to_string_lossy().to_string()),
);
}
}
}
let session_id = envelope.session_id;
let sessions_root = codex_sessions_root().context("Failed to resolve codex sessions root")?;
let transcript_path =
write_envelope(envelope, &sessions_root).context("Failed to rehydrate codex transcript")?;
Ok(CodexLocalContinuation {
command: format!("codex resume {session_id}"),
transcript_path,
})
}
/// Rehydrate a Codex transcript downloaded from a remote cloud run for local continuation.
///
/// Unlike [`rehydrate_codex_transcript`] (used by the cloud resume harness runner), this
/// function does **not** mutate the envelope's `cwd` field or patch the `session_meta` payload
/// — the remote session's working directory is preserved as-is in the transcript.
pub(crate) fn rehydrate_codex_transcript_from_reader(
reader: impl Read,
) -> Result<CodexLocalContinuation> {
let envelope: CodexTranscriptEnvelope =
serde_json::from_reader(reader).context("Failed to parse codex transcript envelope")?;
let session_id = envelope.session_id;
let sessions_root = codex_sessions_root().context("Failed to resolve codex sessions root")?;
// Write as-is: no cwd mutation, no session_meta patch.
let transcript_path = write_envelope(&envelope, &sessions_root)
.context("Failed to rehydrate codex transcript")?;
Ok(CodexLocalContinuation {
command: format!("codex resume {session_id}"),
transcript_path,
})
}
#[cfg(test)]
#[path = "codex_transcript_tests.rs"]
mod tests;
@@ -0,0 +1,205 @@
use std::fs;
use std::path::Path;
use anyhow::Result;
use tempfile::TempDir;
use uuid::Uuid;
use super::super::claude_transcript::read_jsonl;
use super::*;
/// Walk `sessions_root` for `session_id`'s rollout and assemble an envelope.
fn read_envelope(
session_id: Uuid,
sessions_root: &Path,
) -> Result<Option<CodexTranscriptEnvelope>> {
let Some(path) = find_session_file(sessions_root, session_id) else {
return Ok(None);
};
let entries = read_jsonl(&path)?;
let meta = parse_session_meta(entries.first()).unwrap_or_default();
Ok(Some(CodexTranscriptEnvelope::new(
session_id, meta, entries,
)))
}
/// Minimal SessionMeta line in the same shape codex writes (codex
/// `protocol/src/protocol.rs::RolloutItem`): `{type, payload}`.
fn session_meta_line(uuid: Uuid, cwd: &str, timestamp: &str, cli_version: &str) -> String {
serde_json::json!({
"type": "session_meta",
"payload": {
"id": uuid.to_string(),
"timestamp": timestamp,
"cwd": cwd,
"originator": "test",
"cli_version": cli_version,
},
})
.to_string()
}
#[test]
#[serial_test::serial]
fn codex_sessions_root_honors_codex_home_env() {
let tmp = TempDir::new().unwrap();
let prev = std::env::var(CODEX_HOME_ENV).ok();
std::env::set_var(CODEX_HOME_ENV, tmp.path());
let root = codex_sessions_root().unwrap();
match prev {
Some(v) => std::env::set_var(CODEX_HOME_ENV, v),
None => std::env::remove_var(CODEX_HOME_ENV),
}
assert_eq!(root, tmp.path().join(CODEX_SESSIONS_SUBDIR));
}
#[test]
fn find_session_file_walks_yyyy_mm_dd_tree() {
let tmp = TempDir::new().unwrap();
let uuid = Uuid::new_v4();
let day = tmp.path().join("2026").join("04").join("30");
fs::create_dir_all(&day).unwrap();
let file = day.join(format!("rollout-ignored-ts-{uuid}.jsonl"));
fs::write(&file, "").unwrap();
let found = find_session_file(tmp.path(), uuid);
assert_eq!(found, Some(file));
}
#[test]
fn find_session_file_returns_none_when_no_match() {
let tmp = TempDir::new().unwrap();
let day = tmp.path().join("2026").join("04").join("30");
fs::create_dir_all(&day).unwrap();
fs::write(
day.join(format!("rollout-ignored-ts-{}.jsonl", Uuid::new_v4())),
"",
)
.unwrap();
assert!(find_session_file(tmp.path(), Uuid::new_v4()).is_none());
}
#[test]
fn find_session_file_returns_none_when_root_missing() {
let tmp = TempDir::new().unwrap();
assert!(find_session_file(&tmp.path().join("missing"), Uuid::new_v4()).is_none());
}
#[test]
fn read_envelope_recovers_cwd_and_version_from_session_meta() {
let tmp = TempDir::new().unwrap();
let uuid = Uuid::new_v4();
let day = tmp.path().join("2026").join("04").join("30");
fs::create_dir_all(&day).unwrap();
let meta = session_meta_line(uuid, "/work/proj", "2026-04-30T01:54:20.000Z", "0.55.0");
let body = format!("{meta}\n{{\"type\":\"event_msg\",\"payload\":{{\"x\":1}}}}\n");
fs::write(day.join(format!("rollout-ignored-ts-{uuid}.jsonl")), body).unwrap();
let envelope = read_envelope(uuid, tmp.path()).unwrap().unwrap();
assert_eq!(envelope.session_id, uuid);
assert_eq!(envelope.cwd, std::path::PathBuf::from("/work/proj"));
assert_eq!(envelope.codex_version.as_deref(), Some("0.55.0"));
assert_eq!(envelope.entries.len(), 2);
}
#[test]
fn read_envelope_returns_none_when_missing() {
let tmp = TempDir::new().unwrap();
assert!(read_envelope(Uuid::new_v4(), tmp.path()).unwrap().is_none());
}
#[test]
fn write_envelope_uses_session_meta_timestamp_for_path() {
let tmp = TempDir::new().unwrap();
let uuid = Uuid::new_v4();
let envelope = CodexTranscriptEnvelope {
cwd: "/work".into(),
session_id: uuid,
codex_version: Some("0.55.0".to_string()),
session_start_timestamp: Some(
chrono::DateTime::parse_from_rfc3339("2026-04-30T01:54:20.000Z")
.unwrap()
.with_timezone(&chrono::Utc),
),
entries: vec![
serde_json::from_str::<serde_json::Value>(&session_meta_line(
uuid,
"/work",
"2026-04-30T01:54:20.000Z",
"0.55.0",
))
.unwrap(),
],
};
let path = write_envelope(&envelope, tmp.path()).unwrap();
let expected = tmp
.path()
.join("2026")
.join("04")
.join("30")
.join(format!("rollout-2026-04-30T01-54-20-{uuid}.jsonl"));
assert_eq!(path, expected);
assert!(path.exists());
}
#[test]
fn write_envelope_round_trip_preserves_entries() {
let tmp = TempDir::new().unwrap();
let uuid = Uuid::new_v4();
let entries = vec![
serde_json::from_str::<serde_json::Value>(&session_meta_line(
uuid,
"/work",
"2026-04-30T01:54:20.000Z",
"0.55.0",
))
.unwrap(),
serde_json::json!({"type": "event_msg", "payload": {"x": 1}}),
];
let original = CodexTranscriptEnvelope {
cwd: "/work".into(),
session_id: uuid,
codex_version: Some("0.55.0".to_string()),
session_start_timestamp: Some(
chrono::DateTime::parse_from_rfc3339("2026-04-30T01:54:20.000Z")
.unwrap()
.with_timezone(&chrono::Utc),
),
entries: entries.clone(),
};
write_envelope(&original, tmp.path()).unwrap();
let decoded = read_envelope(uuid, tmp.path()).unwrap().unwrap();
assert_eq!(decoded.session_id, uuid);
assert_eq!(decoded.entries, entries);
// `cwd` and `codex_version` are recovered from the SessionMeta line.
assert_eq!(decoded.cwd, std::path::PathBuf::from("/work"));
assert_eq!(decoded.codex_version.as_deref(), Some("0.55.0"));
}
#[test]
fn write_envelope_falls_back_to_today_when_timestamp_missing() {
let tmp = TempDir::new().unwrap();
let uuid = Uuid::new_v4();
// No SessionMeta first line — just one event entry.
let envelope = CodexTranscriptEnvelope {
cwd: "/work".into(),
session_id: uuid,
codex_version: None,
session_start_timestamp: None,
entries: vec![serde_json::json!({"type": "event_msg"})],
};
let path = write_envelope(&envelope, tmp.path()).unwrap();
// Path should still be under sessions_root and findable by uuid.
assert!(path.starts_with(tmp.path()));
let found = find_session_file(tmp.path(), uuid);
assert_eq!(found, Some(path));
}
+63 -29
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::Path;
use std::sync::Arc;
@@ -12,18 +13,24 @@ use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tempfile::NamedTempFile;
use super::super::terminal::{CommandHandle, TerminalDriver};
use super::super::{AgentDriver, AgentDriverError};
use super::json_utils::{read_json_file_or_default, write_json_file};
use super::{
write_temp_file, HarnessCleanupDisposition, HarnessRunner, JSONMCPServer, ResumePayload,
SavePoint, ThirdPartyHarness,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_sdk::setup_observability::{
OzRunTimelineEvent, SetupClientEventReporter, SetupStep,
};
use crate::ai::ambient_agents::task::HarnessModelConfig;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::server::server_api::harness_support::HarnessSupportClient;
use crate::server::server_api::ServerApi;
use crate::terminal::model::block::BlockId;
use crate::terminal::CLIAgent;
use super::super::terminal::{CommandHandle, TerminalDriver};
use super::super::{AgentDriver, AgentDriverError};
use super::json_utils::{read_json_file_or_default, write_json_file};
use super::{write_temp_file, HarnessRunner, ResumePayload, SavePoint, ThirdPartyHarness};
pub(crate) struct GeminiHarness;
/// Format slug sent to the server when creating a Gemini conversation.
@@ -46,38 +53,42 @@ impl ThirdPartyHarness for GeminiHarness {
Some("https://geminicli.com/")
}
fn prepare_environment_config(
&self,
working_dir: &Path,
system_prompt: Option<&str>,
_secrets: &HashMap<String, ManagedSecretValue>,
) -> Result<(), AgentDriverError> {
prepare_gemini_environment_config(working_dir, system_prompt).map_err(|error| {
AgentDriverError::HarnessConfigSetupFailed {
harness: self.cli_agent().command_prefix().to_owned(),
error,
}
})
}
fn build_runner(
&self,
prompt: &str,
system_prompt: Option<&str>,
_resumption_prompt: Option<&str>,
context: Option<&str>,
working_dir: &Path,
_task_id: Option<AmbientAgentTaskId>,
server_api: Arc<ServerApi>,
terminal_driver: ModelHandle<TerminalDriver>,
_resume: Option<ResumePayload>,
_resolved_env_vars: &HashMap<OsString, OsString>,
_resolved_secrets: &HashMap<String, ManagedSecretValue>,
_resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
_third_party_harness_model_config: Option<&HarnessModelConfig>,
) -> Result<Box<dyn HarnessRunner>, AgentDriverError> {
// Prepare the environment config files.
prepare_gemini_environment_config(working_dir, system_prompt).map_err(|error| {
AgentDriverError::HarnessConfigSetupFailed {
harness: self.cli_agent().command_prefix().to_owned(),
error,
}
})?;
// Gemini does not support conversation resume yet. When it does, it will add its
// own `ResumePayload::Gemini(..)` variant and override `fetch_resume_payload`,
// and decide how to surface the user-turn resumption preamble.
// Prepend server context to the prompt if available.
let effective_prompt = match context {
Some(ctx) if !ctx.is_empty() => format!("{ctx}\n\n{prompt}"),
_ => prompt.to_string(),
};
let client: Arc<dyn HarnessSupportClient> = server_api;
Ok(Box::new(GeminiHarnessRunner::new(
self.cli_agent().command_prefix(),
prompt,
&effective_prompt,
system_prompt,
working_dir,
client,
@@ -104,6 +115,8 @@ enum GeminiRunnerState {
struct GeminiHarnessRunner {
command: String,
/// The CLI name used to invoke Gemini.
cli_name: String,
/// Held so the temp file is cleaned up when the runner is dropped.
_temp_prompt_file: NamedTempFile,
client: Arc<dyn HarnessSupportClient>,
@@ -120,11 +133,12 @@ impl GeminiHarnessRunner {
client: Arc<dyn HarnessSupportClient>,
terminal_driver: ModelHandle<TerminalDriver>,
) -> Result<Self, AgentDriverError> {
let temp_file = write_temp_file("oz_prompt_", prompt)?;
let temp_file = write_temp_file("oz_prompt_", prompt, ".txt")?;
let prompt_path = temp_file.path().display().to_string();
Ok(Self {
command: gemini_command(cli_command, &prompt_path),
cli_name: cli_command.to_string(),
_temp_prompt_file: temp_file,
client,
terminal_driver,
@@ -136,19 +150,27 @@ impl GeminiHarnessRunner {
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl HarnessRunner for GeminiHarnessRunner {
fn harness_name(&self) -> &str {
&self.cli_name
}
async fn start(
&self,
foreground: &ModelSpawner<AgentDriver>,
setup_events: &SetupClientEventReporter,
) -> Result<CommandHandle, AgentDriverError> {
// Create the external conversation record on the server.
let conversation_id = self
.client
.create_external_conversation(GEMINI_CLI_FORMAT)
.await
.map_err(|e| {
log::error!("Failed to create external conversation: {e}");
AgentDriverError::ConfigBuildFailed(e)
})?;
let conversation_id = setup_events
.record_result(SetupStep::ThirdPartyHarnessExternalConversation, async {
self.client
.create_external_conversation(GEMINI_CLI_FORMAT)
.await
.map_err(|e| {
log::error!("Failed to create external conversation: {e}");
AgentDriverError::ConfigBuildFailed(e)
})
})
.await?;
log::info!("Created external conversation {conversation_id}");
let command = self.command.clone();
@@ -166,6 +188,10 @@ impl HarnessRunner for GeminiHarnessRunner {
block_id: command_handle.block_id().clone(),
};
setup_events
.post_timeline_event(OzRunTimelineEvent::AgentStarted)
.await;
Ok(command_handle)
}
@@ -215,6 +241,14 @@ impl HarnessRunner for GeminiHarnessRunner {
)
.await
}
async fn cleanup(
&self,
_cleanup_disposition: HarnessCleanupDisposition,
_foreground: &ModelSpawner<AgentDriver>,
) -> Result<()> {
Ok(())
}
}
fn prepare_gemini_environment_config(
@@ -10,6 +10,7 @@ use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Read a JSON file as `T`, or return `T::default()` if the file does not exist.
///
@@ -54,3 +55,13 @@ where
)
.with_context(|| format!("Failed to write {}", path.display()))
}
/// Serialize a slice of JSON values as a JSONL byte string (one value per line).
pub(super) fn entries_to_jsonl(entries: &[Value]) -> Result<Vec<u8>> {
let mut buf = Vec::new();
for entry in entries {
serde_json::to_writer(&mut buf, entry)?;
buf.push(b'\n');
}
Ok(buf)
}
+208 -36
View File
@@ -1,6 +1,6 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::ffi::OsString;
use std::ffi::{OsStr, OsString};
use std::fmt;
use std::io::Write;
use std::path::Path;
@@ -12,15 +12,6 @@ use galaxy_cli::agent::Harness;
use galaxy_managed_secrets::ManagedSecretValue;
use galaxyui::{ModelHandle, ModelSpawner, SingletonEntity};
use tempfile::NamedTempFile;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::server::server_api::harness_support::{upload_to_target, HarnessSupportClient};
use crate::server::server_api::ServerApi;
use crate::terminal::cli_agent_sessions::{CLIAgentSessionStatus, CLIAgentSessionsModel};
use crate::terminal::model::block::{BlockId, SerializedBlock};
use crate::terminal::CLIAgent;
use crate::util::path::resolve_executable;
use galaxy_cli::{
OZ_CLI_ENV, OZ_HARNESS_ENV, OZ_PARENT_RUN_ID_ENV, OZ_RUN_ID_ENV, SERVER_ROOT_URL_OVERRIDE_ENV,
SESSION_SHARING_SERVER_URL_OVERRIDE_ENV, WS_SERVER_URL_OVERRIDE_ENV,
@@ -33,24 +24,97 @@ use super::{
LEGACY_OZ_PARENT_STATE_ROOT_ENV, OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV,
OZ_MESSAGE_LISTENER_STATE_ROOT_ENV,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_sdk::setup_observability::SetupClientEventReporter;
use crate::ai::ambient_agents::task::HarnessModelConfig;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::mcp::JSONMCPServer;
use crate::server::server_api::harness_support::{upload_to_target, HarnessSupportClient};
use crate::server::server_api::ServerApi;
use crate::terminal::cli_agent_sessions::{CLIAgentSessionStatus, CLIAgentSessionsModel};
use crate::terminal::model::block::{BlockId, SerializedBlock};
use crate::terminal::CLIAgent;
use crate::util::path::resolve_executable;
mod claude_code;
pub(crate) mod claude_code;
pub(crate) mod claude_transcript;
mod codex;
pub(crate) mod codex_transcript;
mod gemini;
mod json_utils;
mod telemetry;
pub(crate) use claude_code::ClaudeHarness;
use claude_transcript::ClaudeResumeInfo;
use codex::CodexHarness;
use codex_transcript::CodexResumeInfo;
use gemini::GeminiHarness;
pub(crate) use telemetry::ThirdPartyHarnessTelemetryEvent;
/// Harness-agnostic payload describing how to resume an existing conversation.
///
/// Each variant carries the data a specific harness needs to rehydrate state before its CLI
/// launches. Harnesses match on the variant they produce and ignore others; new CLIs that
/// want resume support add a new variant and override [`ThirdPartyHarness::fetch_resume_payload`].
#[derive(Debug)]
pub(crate) enum ResumePayload {
/// Claude Code session state fetched from the server's transcript endpoint.
Claude(ClaudeResumeInfo),
/// Codex session state fetched from the server's transcript endpoint.
Codex(CodexResumeInfo),
}
impl TryFrom<ResumePayload> for ClaudeResumeInfo {
type Error = AgentDriverError;
fn try_from(payload: ResumePayload) -> Result<Self, Self::Error> {
match payload {
ResumePayload::Claude(info) => Ok(info),
_ => {
log::error!("ClaudeHarness given non-Claude ResumePayload variant");
Err(AgentDriverError::InvalidRuntimeState)
}
}
}
}
impl TryFrom<ResumePayload> for CodexResumeInfo {
type Error = AgentDriverError;
fn try_from(payload: ResumePayload) -> Result<Self, Self::Error> {
match payload {
ResumePayload::Codex(info) => Ok(info),
_ => {
log::error!("CodexHarness given non-Codex ResumePayload variant");
Err(AgentDriverError::InvalidRuntimeState)
}
}
}
}
/// Fetch the harness transcript for `conversation_id` and deserialize it into `E`.
pub(super) async fn fetch_transcript_envelope<E: serde::de::DeserializeOwned>(
harness_label: &str,
conversation_id: &AIConversationId,
client: Arc<dyn HarnessSupportClient>,
) -> Result<E, AgentDriverError> {
let bytes = client.fetch_transcript().await.map_err(|err| {
// A 404 from the server maps to "no stored transcript" so the CLI can tell
// the user the prior run never saved state.
let message = format!("{err:#}").to_lowercase();
if message.contains("status 404") {
AgentDriverError::ConversationResumeStateMissing {
harness: harness_label.to_string(),
conversation_id: conversation_id.to_string(),
}
} else {
AgentDriverError::ConversationLoadFailed(format!("{err:#}"))
}
})?;
serde_json::from_slice(&bytes).map_err(|err| {
AgentDriverError::ConversationLoadFailed(format!(
"Failed to deserialize {harness_label} transcript for {conversation_id}: {err:#}"
))
})
}
/// Trait for third-party agent harnesses that execute prompts via their own CLIs.
@@ -77,14 +141,27 @@ pub(crate) trait ThirdPartyHarness: Send + Sync {
validate_cli_installed(self.cli_agent().command_prefix(), self.install_docs_url())
}
/// Prepare CLI-specific config files before launching the harness command.
fn prepare_environment_config(
&self,
_working_dir: &Path,
_system_prompt: Option<&str>,
_secrets: &HashMap<String, ManagedSecretValue>,
) -> Result<(), AgentDriverError> {
Ok(())
/// Shell command to verify authentication credentials are valid.
/// Exit code 0 = pass; non-zero = fail.
fn auth_check_command(&self) -> Option<String> {
None
}
/// Substrings to scan for in the running harness block's output. A hit
/// indicates the harness can't make a successful API request (e.g.
/// invalid key, no billing, quota exhausted). The driver matches
/// case-insensitively against the block's plaintext via the same DFA
/// machinery used by the find feature.
fn runtime_error_patterns(&self) -> &'static [&'static str] {
&[]
}
/// Whether this harness must verify its Oz platform plugin before launch.
/// Codex opts into this because its unattended launch command bypasses hook
/// trust globally, so we should fail setup instead of running without the
/// Warp-installed orchestration hooks at the required version.
fn requires_verified_platform_plugin(&self) -> bool {
false
}
/// Fetch the harness-specific resume payload for an existing conversation.
@@ -107,25 +184,34 @@ pub(crate) trait ThirdPartyHarness: Send + Sync {
/// Build a runner for executing this harness with the given prompt.
///
/// If `resume` is `Some`, the harness matches on its own [`ResumePayload`] variant and
/// reuses the stored session/conversation ids instead of minting fresh ones. Variants
/// belonging to other harnesses are ignored.
/// Responsible for all harness-specific setup: writing config files (auth,
/// trust, system prompt, MCP, etc.) and constructing the runner that will
/// execute the CLI command.
///
/// `resumption_prompt`, when non-empty, is a short user-turn preamble the server emits
/// during a resumed session. Each harness decides exactly how to surface it (e.g. Claude
/// prepends it to the user-turn prompt that gets piped into the CLI). Harnesses that
/// don't yet support resumption can ignore it.
/// `resolved_env_vars` contains already-resolved secret env vars (worker
/// env > typed secrets > raw values precedence already applied).
///
/// `resolved_secrets` provides the raw typed managed secrets so harnesses
/// can read structured fields (e.g. `base_url`) without relying on env vars.
///
/// If `resume` is `Some`, the harness matches on its own [`ResumePayload`]
/// variant and reuses stored session/conversation ids.
#[allow(clippy::too_many_arguments)]
fn build_runner(
&self,
prompt: &str,
system_prompt: Option<&str>,
resumption_prompt: Option<&str>,
context: Option<&str>,
working_dir: &Path,
task_id: Option<AmbientAgentTaskId>,
server_api: Arc<ServerApi>,
terminal_driver: ModelHandle<TerminalDriver>,
resume: Option<ResumePayload>,
resolved_env_vars: &HashMap<OsString, OsString>,
resolved_secrets: &HashMap<String, ManagedSecretValue>,
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
third_party_harness_model_config: Option<&HarnessModelConfig>,
) -> Result<Box<dyn HarnessRunner>, AgentDriverError>;
}
@@ -165,12 +251,30 @@ pub(crate) fn harness_kind(harness: Harness) -> Result<HarnessKind, AgentDriverE
match harness {
Harness::Oz => Ok(HarnessKind::Oz),
Harness::Claude => Ok(HarnessKind::ThirdParty(Box::new(ClaudeHarness))),
Harness::Codex => Ok(HarnessKind::ThirdParty(Box::new(CodexHarness))),
Harness::OpenCode => Ok(HarnessKind::Unsupported(Harness::OpenCode)),
Harness::Gemini => Ok(HarnessKind::ThirdParty(Box::new(GeminiHarness))),
Harness::Unknown => Err(AgentDriverError::InvalidRuntimeState),
}
}
/// Returns the harness's auth-check preflight command, if any.
///
/// The viewer uses this to recognize preflight blocks via exact string
/// equality (so they stay grouped under "Set up environment commands"
/// rather than being mistaken for the main harness invocation, which
/// shares the same CLI prefix).
///
/// Returns `None` for [`Harness::Oz`], for unsupported harnesses, and
/// for any third-party harness whose `auth_check_command` returns `None`
/// (e.g. Gemini today).
pub(crate) fn auth_check_command_for(harness: Harness) -> Option<String> {
let HarnessKind::ThirdParty(third_party) = harness_kind(harness).ok()? else {
return None;
};
third_party.auth_check_command()
}
/// Check that `cli` is installed and on PATH, returning a `HarnessSetupFailed`
/// error with an optional install-docs link when it isn't.
pub(crate) fn validate_cli_installed(
@@ -302,6 +406,17 @@ fn task_env_vars_for_harness_name(
env_vars
}
pub(crate) fn remove_claude_externally_managed_listener_env_vars(
env_vars: &mut HashMap<OsString, OsString>,
) {
for env_name in [
OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV,
LEGACY_OZ_PARENT_LISTENER_MANAGED_EXTERNALLY_ENV,
] {
env_vars.remove(OsStr::new(env_name));
}
}
pub(crate) fn task_env_vars(
task_id: Option<&AmbientAgentTaskId>,
parent_run_id: Option<&str>,
@@ -310,6 +425,34 @@ pub(crate) fn task_env_vars(
task_env_vars_for_harness_name(task_id, parent_run_id, selected_harness)
}
/// Returns environment variables that configure the model for a third-party harness.
/// Returns an empty map for Oz or when no model is specified.
///
/// We use the `ANTHROPIC_MODEL` env var rather than the `--model` CLI flag because
/// the env var is the most reliable mechanism and avoids precedence conflicts with
/// Claude Code's `settings.json`.
pub(crate) fn harness_model_env_vars(
selected_harness: Harness,
third_party_harness_model_config: Option<&HarnessModelConfig>,
) -> HashMap<OsString, OsString> {
let mut env_vars = HashMap::new();
let Some(model_id) = third_party_harness_model_config
.map(|config| config.model_id.as_str())
.filter(|id| !id.is_empty())
else {
return env_vars;
};
match selected_harness {
Harness::Claude => {
env_vars.insert(OsString::from("ANTHROPIC_MODEL"), OsString::from(model_id));
}
Harness::Oz | Harness::OpenCode | Harness::Gemini | Harness::Codex | Harness::Unknown => {}
}
env_vars
}
/// Indicates when the harness conversation is being saved.
/// Implementations may use this to customize the saved data, such as
/// recording additional metadata on completion.
@@ -322,6 +465,18 @@ pub(crate) enum SavePoint {
PostTurn,
}
/// Controls how much harness-owned state should survive cleanup after the CLI
/// exits.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum HarnessCleanupDisposition {
/// Tear down all harness-owned resume and wake state.
DropResumptionState,
/// The harness exited cleanly and its final save completed, so wake/resume
/// state may be preserved if the harness-specific runtime also considers
/// the run complete.
PreserveResumptionStateIfSupported,
}
/// Stateful per-run representation of an external harness produced
/// by [`ThirdPartyHarness::build_runner`].
///
@@ -333,6 +488,8 @@ pub(crate) enum SavePoint {
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
pub(crate) trait HarnessRunner: Send + Sync {
fn harness_name(&self) -> &str;
/// Create the external conversation on the server and start the harness
/// command in the terminal.
///
@@ -342,6 +499,7 @@ pub(crate) trait HarnessRunner: Send + Sync {
async fn start(
&self,
foreground: &ModelSpawner<AgentDriver>,
setup_events: &SetupClientEventReporter,
) -> Result<CommandHandle, AgentDriverError>;
/// Save the current conversation state (transcript upload, etc.).
@@ -359,7 +517,11 @@ pub(crate) trait HarnessRunner: Send + Sync {
}
/// Clean up any harness-owned background state after the harness exits.
async fn cleanup(&self, _foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
async fn cleanup(
&self,
_cleanup_disposition: HarnessCleanupDisposition,
_foreground: &ModelSpawner<AgentDriver>,
) -> Result<()> {
Ok(())
}
}
@@ -370,20 +532,29 @@ pub(crate) async fn has_running_cli_agent(
terminal_driver: &ModelHandle<TerminalDriver>,
foreground: &ModelSpawner<AgentDriver>,
) -> bool {
matches!(
cli_agent_session_status(terminal_driver, foreground).await,
Some(CLIAgentSessionStatus::InProgress)
)
}
/// Returns the tracked CLI agent session status for the terminal, if any.
pub(crate) async fn cli_agent_session_status(
terminal_driver: &ModelHandle<TerminalDriver>,
foreground: &ModelSpawner<AgentDriver>,
) -> Option<CLIAgentSessionStatus> {
let driver = terminal_driver.clone();
let Ok(running) = foreground
foreground
.spawn(move |_, ctx| {
let terminal_view_id = driver.as_ref(ctx).terminal_view().id();
CLIAgentSessionsModel::handle(ctx)
.as_ref(ctx)
.session(terminal_view_id)
.is_some_and(|s| s.status == CLIAgentSessionStatus::InProgress)
.map(|session| session.status.clone())
})
.await
else {
return false;
};
running
.ok()
.flatten()
}
/// Create a [`NamedTempFile`] with the given prefix and write `content` into it.
@@ -393,10 +564,11 @@ pub(crate) async fn has_running_cli_agent(
pub(super) fn write_temp_file(
prefix: &str,
content: &str,
suffix: &str,
) -> Result<NamedTempFile, AgentDriverError> {
let mut file = tempfile::Builder::new()
.prefix(prefix)
.suffix(".txt")
.suffix(suffix)
.tempfile()
.map_err(|e| {
AgentDriverError::ConfigBuildFailed(anyhow::anyhow!(
@@ -457,5 +629,5 @@ pub(super) async fn upload_current_block_snapshot(
}
#[cfg(test)]
#[path = "mod_test.rs"]
#[path = "mod_tests.rs"]
mod tests;
@@ -1,33 +0,0 @@
use super::validate_cli_installed;
use crate::ai::agent_sdk::driver::AgentDriverError;
fn assert_harness_setup_failed(err: &AgentDriverError) -> (&str, &str) {
match err {
AgentDriverError::HarnessSetupFailed { harness, reason } => (harness, reason),
other => panic!("expected HarnessSetupFailed, got: {other}"),
}
}
#[cfg(not(windows))]
#[test]
fn validate_cli_installed_succeeds_for_known_binary() {
assert!(validate_cli_installed("ls", None).is_ok());
}
#[test]
fn validate_cli_installed_fails_for_missing_binary() {
let err = validate_cli_installed("__nonexistent_cli_abc123__", None).unwrap_err();
let (harness, reason) = assert_harness_setup_failed(&err);
assert_eq!(harness, "__nonexistent_cli_abc123__");
assert!(reason.contains("not found"));
assert!(!reason.contains("Install it first"));
}
#[test]
fn validate_cli_installed_includes_docs_url_in_error() {
let url = "https://example.com/install";
let err = validate_cli_installed("__nonexistent_cli_abc123__", Some(url)).unwrap_err();
let (_, reason) = assert_harness_setup_failed(&err);
assert!(reason.contains(url));
assert!(reason.contains("Install it first"));
}
@@ -0,0 +1,82 @@
use warp_cli::agent::Harness;
use super::{auth_check_command_for, validate_cli_installed};
use crate::ai::agent_sdk::driver::AgentDriverError;
fn assert_harness_setup_failed(err: &AgentDriverError) -> (&str, &str) {
match err {
AgentDriverError::HarnessSetupFailed { harness, reason } => (harness, reason),
other => panic!("expected HarnessSetupFailed, got: {other}"),
}
}
#[cfg(not(windows))]
#[test]
fn validate_cli_installed_succeeds_for_known_binary() {
assert!(validate_cli_installed("ls", None).is_ok());
}
#[test]
fn validate_cli_installed_fails_for_missing_binary() {
let err = validate_cli_installed("__nonexistent_cli_abc123__", None).unwrap_err();
let (harness, reason) = assert_harness_setup_failed(&err);
assert_eq!(harness, "__nonexistent_cli_abc123__");
assert!(reason.contains("not found"));
assert!(!reason.contains("Install it first"));
}
#[test]
fn validate_cli_installed_includes_docs_url_in_error() {
let url = "https://example.com/install";
let err = validate_cli_installed("__nonexistent_cli_abc123__", Some(url)).unwrap_err();
let (_, reason) = assert_harness_setup_failed(&err);
assert!(reason.contains(url));
assert!(reason.contains("Install it first"));
}
// --- Runtime error pattern tests ---
#[test]
fn claude_runtime_error_patterns_returns_slice() {
use super::claude_code::ClaudeHarness;
use super::ThirdPartyHarness;
// Patterns are initially empty until validated needles are filled in.
// The trait method must still be callable.
let _: &[&str] = ClaudeHarness.runtime_error_patterns();
}
#[test]
fn codex_runtime_error_patterns_returns_slice() {
use super::codex::CodexHarness;
let _: &[&str] = CodexHarness.runtime_error_patterns();
}
#[test]
fn gemini_runtime_error_patterns_is_empty_by_default() {
use super::gemini::GeminiHarness;
assert!(GeminiHarness.runtime_error_patterns().is_empty());
}
#[test]
fn auth_check_command_for_gemini_is_none() {
assert!(auth_check_command_for(Harness::Gemini).is_none());
}
#[test]
fn auth_check_command_for_oz_is_none() {
assert!(auth_check_command_for(Harness::Oz).is_none());
}
#[test]
fn auth_check_command_for_unsupported_is_none() {
// OpenCode is mapped to HarnessKind::Unsupported and therefore has no
// auth check command of its own.
assert!(auth_check_command_for(Harness::OpenCode).is_none());
}
#[test]
fn auth_check_command_for_unknown_is_none() {
// Harness::Unknown causes harness_kind to return Err; the helper still
// returns None instead of panicking.
assert!(auth_check_command_for(Harness::Unknown).is_none());
}
@@ -0,0 +1,84 @@
use serde_json::{json, Value};
use strum_macros::{EnumDiscriminants, EnumIter};
use galaxy_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
/// Telemetry events emitted by the third-party harness runtime layer.
#[derive(Debug, EnumDiscriminants)]
#[strum_discriminants(derive(EnumIter))]
pub(crate) enum ThirdPartyHarnessTelemetryEvent {
/// The runtime output scanner observed one of the harness's known
/// failure substrings. Fires once per detection, before any suppression
/// logic, so dashboards can compare raw trigger volume vs. detections
/// that actually fail the run.
RuntimeErrorDetected {
/// CLI command prefix for the harness whose block was scanned
/// (e.g. `"claude"`, `"codex"`).
harness: String,
/// The originating needle from `runtime_error_patterns` that hit.
pattern: String,
},
}
impl TelemetryEvent for ThirdPartyHarnessTelemetryEvent {
fn name(&self) -> &'static str {
ThirdPartyHarnessTelemetryEventDiscriminants::from(self).name()
}
fn payload(&self) -> Option<Value> {
match self {
ThirdPartyHarnessTelemetryEvent::RuntimeErrorDetected { harness, pattern } => {
Some(json!({
"harness": harness,
"pattern": pattern,
}))
}
}
}
fn description(&self) -> &'static str {
ThirdPartyHarnessTelemetryEventDiscriminants::from(self).description()
}
fn enablement_state(&self) -> EnablementState {
ThirdPartyHarnessTelemetryEventDiscriminants::from(self).enablement_state()
}
fn contains_ugc(&self) -> bool {
match self {
ThirdPartyHarnessTelemetryEvent::RuntimeErrorDetected { .. } => false,
}
}
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
galaxy_core::telemetry::enum_events::<Self>()
}
}
impl TelemetryEventDesc for ThirdPartyHarnessTelemetryEventDiscriminants {
fn name(&self) -> &'static str {
match self {
ThirdPartyHarnessTelemetryEventDiscriminants::RuntimeErrorDetected => {
"AmbientAgents.ThirdPartyHarness.RuntimeError.Detected"
}
}
}
fn description(&self) -> &'static str {
match self {
ThirdPartyHarnessTelemetryEventDiscriminants::RuntimeErrorDetected => {
"Runtime output scanner detected a known failure substring in a third-party \
harness block."
}
}
}
fn enablement_state(&self) -> EnablementState {
match self {
ThirdPartyHarnessTelemetryEventDiscriminants::RuntimeErrorDetected => {
EnablementState::Always
}
}
}
}
galaxy_core::register_telemetry_event!(ThirdPartyHarnessTelemetryEvent);
@@ -0,0 +1,217 @@
//! Background monitor that scans the running harness block for known runtime
//! failure substrings (e.g. invalid API key, exhausted credits) and reports
//! the first hit so the driver can fail the task fast instead of letting the
//! harness hang.
use std::sync::Arc;
use std::time::Duration;
use regex::escape;
use warpui::ModelSpawner;
use super::terminal::BlockOutputMatch;
use super::AgentDriver;
use crate::terminal::cli_agent_sessions::CLIAgentSessionStatus;
use crate::terminal::model::block::BlockId;
use crate::terminal::model::find::RegexDFAs;
const SCAN_INTERVALS: &[Duration] = &[
Duration::from_secs(5),
Duration::from_secs(5),
Duration::from_secs(5),
Duration::from_secs(5),
Duration::from_secs(5),
Duration::from_secs(5),
Duration::from_secs(15),
Duration::from_secs(15),
Duration::from_secs(15),
Duration::from_secs(15),
];
const STALL_POLL_INTERVAL: Duration = Duration::from_secs(10);
const STALL_CONFIRMATION_BUDGET: Duration = Duration::from_secs(60);
#[derive(Debug, Clone)]
pub(crate) struct DetectedHarnessError {
pub pattern: String,
pub excerpt: String,
}
/// Build a combined case-insensitive DFA from the harness's static patterns.
pub(crate) fn build_dfas(patterns: &[&'static str]) -> Option<RegexDFAs> {
if patterns.is_empty() {
return None;
}
let escaped: Vec<String> = patterns.iter().map(|p| escape(p)).collect();
let refs: Vec<&str> = escaped.iter().map(String::as_str).collect();
match RegexDFAs::new_many(
&refs, false, // enable_unicode_word_boundary
false, // case_sensitive
) {
Ok(dfas) => Some(dfas),
Err(err) => {
log::warn!("Failed to build harness output DFAs: {err}");
None
}
}
}
/// Map a `matched_text` produced by the combined DFA back to the originating
/// needle. The DFA matched `(p1|p2|…)`, so the matched substring is exactly
/// one of `patterns` up to case — we lowercase-compare to identify it.
pub(crate) fn pattern_for_match(
matched_text: &str,
patterns: &[&'static str],
) -> Option<&'static str> {
let matched_lower = matched_text.to_lowercase();
patterns
.iter()
.copied()
.find(|p| p.to_lowercase() == matched_lower)
}
fn outputs_stalled(before: Option<&str>, after: Option<&str>) -> bool {
matches!((before, after), (Some(a), Some(b)) if a == b)
}
async fn find_match_once(
block_id: &BlockId,
dfas: &Arc<RegexDFAs>,
foreground: &ModelSpawner<AgentDriver>,
) -> Option<BlockOutputMatch> {
let block_id_for_tick = block_id.clone();
let dfas_for_tick = Arc::clone(dfas);
foreground
.spawn(move |me, ctx| {
me.terminal_driver
.as_ref(ctx)
.find_first_match_in_block_output(&block_id_for_tick, &dfas_for_tick, ctx)
})
.await
.ok()
.flatten()
}
async fn fetch_plaintext(
block_id: &BlockId,
foreground: &ModelSpawner<AgentDriver>,
) -> Option<String> {
let block_id_for_tick = block_id.clone();
foreground
.spawn(move |me, ctx| {
me.terminal_driver
.as_ref(ctx)
.block_output_plaintext(&block_id_for_tick, ctx)
})
.await
.ok()
.flatten()
}
/// Run the stall-confirmation loop after a pattern hit.
async fn confirm_stall(
block_id: &BlockId,
dfas: &Arc<RegexDFAs>,
foreground: &ModelSpawner<AgentDriver>,
) -> (Option<BlockOutputMatch>, Duration) {
let mut previous = fetch_plaintext(block_id, foreground).await;
let mut elapsed = Duration::ZERO;
while elapsed < STALL_CONFIRMATION_BUDGET {
warpui::r#async::Timer::after(STALL_POLL_INTERVAL).await;
elapsed += STALL_POLL_INTERVAL;
let current = fetch_plaintext(block_id, foreground).await;
if outputs_stalled(previous.as_deref(), current.as_deref()) {
// Output settled. Re-run the DFA so we report the post-dwell
// match in case the row positions shifted while we waited.
return (find_match_once(block_id, dfas, foreground).await, elapsed);
}
previous = current;
}
(None, elapsed)
}
/// Watch the given block for harness output errors on the
/// [`SCAN_INTERVALS`] cadence.
///
/// On every pattern hit, run a stall-confirmation loop (up to
/// [`STALL_CONFIRMATION_BUDGET`]) and only resolve with
/// `Some(DetectedHarnessError)` when the harness output stabilizes with
/// the pattern still present. If the harness keeps producing output
/// (e.g. spinner frames during an automatic retry), the detection is
/// dropped and the scanner resumes normal polling.
///
/// Returns `None` when the schedule completes without a confirmed hit
/// (or when `patterns` is empty / DFA construction fails).
/// Cancellation-safe: dropping the future stops both loops.
pub(crate) async fn watch_block_for_errors(
block_id: BlockId,
patterns: &'static [&'static str],
foreground: &ModelSpawner<AgentDriver>,
) -> Option<DetectedHarnessError> {
if patterns.is_empty() {
return None;
}
let dfas = Arc::new(build_dfas(patterns)?);
// Total observation budget = sum of all scan intervals. Used as an
// early-exit guard after stall confirmation in case a flaky harness
// has burned most of the window into confirmation loops.
let total_budget: Duration = SCAN_INTERVALS.iter().copied().sum();
let mut elapsed = Duration::ZERO;
for &interval in SCAN_INTERVALS {
warpui::r#async::Timer::after(interval).await;
elapsed += interval;
if find_match_once(&block_id, &dfas, foreground)
.await
.is_none()
{
continue;
}
// Candidate match observed. Confirm via the stall loop so we
// don't false-positive while the harness is mid-retry.
let (confirmed, confirmation_elapsed) = confirm_stall(&block_id, &dfas, foreground).await;
elapsed += confirmation_elapsed;
let Some(hit) = confirmed else {
if elapsed >= total_budget {
break;
}
continue;
};
// The DFA matched one of our needles, so `pattern_for_match`
// should always resolve. Fall back to the matched substring
// verbatim if it somehow doesn't.
let pattern = pattern_for_match(&hit.matched_text, patterns)
.map(str::to_owned)
.unwrap_or_else(|| hit.matched_text.clone());
return Some(DetectedHarnessError {
pattern,
excerpt: cap_excerpt(&hit.excerpt),
});
}
None
}
pub(crate) fn should_suppress_runtime_failure(status: Option<&CLIAgentSessionStatus>) -> bool {
matches!(status, Some(CLIAgentSessionStatus::Success))
}
/// Cap excerpt length so we don't blow up status messages or logs with
/// terminal-width rows.
const EXCERPT_MAX_LEN: usize = 240;
fn cap_excerpt(excerpt: &str) -> String {
if excerpt.chars().count() <= EXCERPT_MAX_LEN {
return excerpt.to_owned();
}
let mut out: String = excerpt.chars().take(EXCERPT_MAX_LEN).collect();
out.push('…');
out
}
#[cfg(test)]
#[path = "harness_output_monitor_tests.rs"]
mod tests;
@@ -0,0 +1,79 @@
use std::time::Duration;
use super::{outputs_stalled, pattern_for_match, STALL_CONFIRMATION_BUDGET, STALL_POLL_INTERVAL};
#[test]
fn pattern_for_match_returns_originating_needle() {
let patterns: &[&'static str] = &["credit balance is too low", "invalid_api_key"];
// The DFA gives us the matched substring (e.g. lifted from the grid as
// mixed case); we must map back to the original `'static` needle.
let resolved = pattern_for_match("Credit Balance Is Too Low", patterns);
assert_eq!(resolved, Some("credit balance is too low"));
}
#[test]
fn pattern_for_match_is_case_insensitive() {
let patterns: &[&'static str] = &["INVALID_API_KEY"];
let resolved = pattern_for_match("invalid_api_key", patterns);
assert_eq!(resolved, Some("INVALID_API_KEY"));
}
#[test]
fn pattern_for_match_returns_none_when_no_match() {
let patterns: &[&'static str] = &["needle a", "needle b"];
assert!(pattern_for_match("entirely different text", patterns).is_none());
}
#[test]
fn pattern_for_match_picks_first_matching_needle() {
// When two needles lowercase-equal the same matched text (degenerate
// case), we deterministically return the first one in the slice.
let patterns: &[&'static str] = &["Foo", "foo"];
let resolved = pattern_for_match("FOO", patterns);
assert_eq!(resolved, Some("Foo"));
}
// --- outputs_stalled / stall confirmation ---
#[test]
fn outputs_stalled_returns_true_when_inputs_equal() {
let snapshot = "Error: credit balance is too low\n";
assert!(outputs_stalled(Some(snapshot), Some(snapshot)));
}
#[test]
fn outputs_stalled_returns_false_when_inputs_differ_by_any_byte() {
// Spinner case: a single character differs between frames. The
// confirmation loop must treat this as "still moving" and not
// mistakenly declare the harness stalled.
let before = "Retrying \u{2807}";
let after = "Retrying \u{2826}";
assert!(!outputs_stalled(Some(before), Some(after)));
}
#[test]
fn outputs_stalled_returns_false_when_either_input_is_none() {
// A failed snapshot fetch must default to "not confirmed" rather than
// killing the harness on a transient lookup error.
assert!(!outputs_stalled(None, Some("data")));
assert!(!outputs_stalled(Some("data"), None));
assert!(!outputs_stalled(None, None));
}
#[test]
fn stall_confirmation_budget_matches_six_poll_intervals() {
// The loop guarantees up to BUDGET/INTERVAL iterations. Pin the ratio
// so a careless tweak to either constant can't accidentally turn the
// confirmation into a single comparison or extend it indefinitely.
assert_eq!(
STALL_CONFIRMATION_BUDGET.as_secs() / STALL_POLL_INTERVAL.as_secs(),
6
);
// And that they're cleanly divisible (no leftover sub-interval window).
assert_eq!(
STALL_CONFIRMATION_BUDGET.as_secs() % STALL_POLL_INTERVAL.as_secs(),
0
);
// Sanity: STALL_POLL_INTERVAL must be non-zero or the loop would spin.
assert!(STALL_POLL_INTERVAL > Duration::ZERO);
}
+52 -42
View File
@@ -1,26 +1,22 @@
pub mod text {
use std::{
collections::HashSet,
fmt,
io::{self, Write},
};
use std::collections::HashSet;
use std::fmt;
use std::io::{self, Write};
const CANCELLED_MESSAGE: &str = "<cancelled>";
use ai::agent::action_result::{FetchConversationResult, ReadSkillResult, UseComputerResult};
use itertools::Itertools;
use crate::{
ai::agent::{
AIAgentActionType, AIAgentInput, AIAgentOutput, AIAgentOutputMessageType, AIAgentTodo,
ArtifactCreatedData, CallMCPToolResult, FileGlobResult, FileGlobV2Result, GrepResult,
ReadFilesResult, ReadMCPResourceResult, RequestCommandOutputResult,
RequestFileEditsResult, SearchCodebaseResult, SuggestNewConversationResult,
SuggestPromptResult, TodoOperation, UploadArtifactResult, WebFetchStatus,
WebSearchStatus, WriteToLongRunningShellCommandResult,
},
AIAgentActionResultType,
use crate::ai::agent::{
AIAgentActionType, AIAgentInput, AIAgentOutput, AIAgentOutputMessageType, AIAgentTodo,
ArtifactCreatedData, CallMCPToolResult, FileGlobResult, FileGlobV2Result, GrepResult,
ReadFilesResult, ReadMCPResourceResult, RequestCommandOutputResult, RequestFileEditsResult,
SearchCodebaseResult, SuggestNewConversationResult, SuggestPromptResult, TodoOperation,
UploadArtifactResult, WebFetchStatus, WebSearchStatus,
WriteToLongRunningShellCommandResult,
};
use crate::AIAgentActionResultType;
/// Format an agent input as a human-readable string. For action results, it's assumed that
/// the action is shown immediately before this result.
@@ -42,7 +38,8 @@ pub mod text {
| AIAgentInput::StartFromAmbientRunPrompt { .. }
| AIAgentInput::MessagesReceivedFromAgents { .. }
| AIAgentInput::PassiveSuggestionResult { .. }
| AIAgentInput::EventsFromAgents { .. } => {
| AIAgentInput::EventsFromAgents { .. }
| AIAgentInput::OrchestrationConfigUpdate { .. } => {
// Do not include the user query, since it's already provided as input to the agent.
Ok(())
}
@@ -303,6 +300,10 @@ pub mod text {
// SendMessageToAgent is a client-side orchestration action, not used in SDK
AIAgentActionResultType::SendMessageToAgent(_) => Ok(()),
AIAgentActionResultType::AskUserQuestion(_) => Ok(()),
// RunAgents is a desktop-client-only action; not used in the SDK.
AIAgentActionResultType::RunAgents(_) => Ok(()),
// No user-visible payload to emit.
AIAgentActionResultType::WaitForEvents(_) => Ok(()),
},
}
}
@@ -427,6 +428,9 @@ pub mod text {
)?;
}
AIAgentActionType::AskUserQuestion { .. } => (),
// RunAgents is desktop-client-only; SDK driver renders nothing.
AIAgentActionType::RunAgents(_) => (),
AIAgentActionType::WaitForEvents { .. } => (),
},
AIAgentOutputMessageType::TodoOperation(operation) => match operation {
TodoOperation::UpdateTodos { todos } => {
@@ -559,26 +563,20 @@ pub mod text {
}
pub mod json {
use crate::{
ai::agent::{
AIAgentActionType, AIAgentInput, AIAgentOutput, AIAgentOutputMessage,
AIAgentOutputMessageType, AIAgentTodo, ArtifactCreatedData, CallMCPToolResult,
FileContext, FileGlobResult, FileGlobV2Result, GrepResult, ReadFilesResult,
ReadMCPResourceResult, RequestCommandOutputResult, RequestFileEditsResult,
SearchCodebaseResult, SubagentCall, TodoOperation, UploadArtifactResult,
WriteToLongRunningShellCommandResult,
},
AIAgentActionResultType,
};
use std::borrow::Cow;
use std::ops::Range;
use serde::Serialize;
use crate::ai::agent::comment::ReviewComment;
use serde::Serialize;
use std::path::Path;
use std::{
borrow::Cow,
io::{self, Write},
ops::Range,
use crate::ai::agent::{
AIAgentActionType, AIAgentInput, AIAgentOutput, AIAgentOutputMessage,
AIAgentOutputMessageType, AIAgentTodo, ArtifactCreatedData, CallMCPToolResult, FileContext,
FileGlobResult, FileGlobV2Result, GrepResult, ReadFilesResult, ReadMCPResourceResult,
RequestCommandOutputResult, RequestFileEditsResult, SearchCodebaseResult, SubagentCall,
TodoOperation, UploadArtifactResult, WriteToLongRunningShellCommandResult,
};
use crate::code::buffer_location::LocalOrRemotePath;
/// JSON representation of messages in an agent conversation. This is intentionally not 1:1 with our internal `AIAgent*` types - it's
/// a stable interface for callers.
@@ -742,7 +740,7 @@ pub mod json {
#[derive(Serialize)]
struct JsonComment<'a> {
comment_text: &'a str,
file_path: Option<&'a Path>,
file_path: Option<String>,
line_number: Option<usize>,
head_title: Option<&'a str>,
}
@@ -791,7 +789,8 @@ pub mod json {
| AIAgentInput::StartFromAmbientRunPrompt { .. }
| AIAgentInput::MessagesReceivedFromAgents { .. }
| AIAgentInput::EventsFromAgents { .. }
| AIAgentInput::PassiveSuggestionResult { .. } => None,
| AIAgentInput::PassiveSuggestionResult { .. }
| AIAgentInput::OrchestrationConfigUpdate { .. } => None,
// These input types should not occur in a SDK-run agent.
AIAgentInput::ResumeConversation { .. }
| AIAgentInput::TriggerPassiveSuggestion { .. } => None,
@@ -1108,6 +1107,10 @@ pub mod json {
| AIAgentActionType::SendMessageToAgent { .. }
| AIAgentActionType::TransferShellCommandControlToUser { .. } => None,
AIAgentActionType::AskUserQuestion { .. } => None,
// RunAgents is desktop-client-only; SDK has no JSON
// representation for it.
AIAgentActionType::RunAgents(_) => None,
AIAgentActionType::WaitForEvents { .. } => None,
},
AIAgentOutputMessageType::TodoOperation(operation) => match operation {
TodoOperation::UpdateTodos { todos } => Some(JsonMessage::UpdateTodos {
@@ -1169,7 +1172,11 @@ pub mod json {
fn from(review_comment: &'a ReviewComment) -> Self {
Self {
comment_text: review_comment.content.as_str(),
file_path: review_comment.diff.file_path.as_deref(),
file_path: review_comment
.diff
.file_path
.as_ref()
.map(LocalOrRemotePath::display_path),
line_number: review_comment.diff.line_number,
head_title: review_comment.head_title.as_deref(),
}
@@ -1286,10 +1293,12 @@ pub mod json {
}
}
use std::io::{self, BufWriter, Write};
use galaxy_core::channel::ChannelState;
use crate::ai::agent::{AIAgentText, AIAgentTextSection};
use crate::code::editor_management::CodeSource;
use galaxy_core::channel::ChannelState;
use std::io::{self, BufWriter, Write};
/// Constructs the Oz dashboard URL for a given run ID.
fn run_url(run_id: &str) -> String {
@@ -1328,8 +1337,8 @@ fn format_agent_text<W: Write>(text: &AIAgentText, w: &mut W) -> io::Result<()>
}
match source {
Some(CodeSource::ProjectRules { path }) => {
writeln!(w, " rules_path={}", path.display())?;
Some(CodeSource::ProjectRules { location }) => {
writeln!(w, " rules_path={}", location.display_path())?;
}
Some(CodeSource::Link {
path,
@@ -1348,12 +1357,13 @@ fn format_agent_text<W: Write>(text: &AIAgentText, w: &mut W) -> io::Result<()>
writeln!(w)?;
}
Some(CodeSource::Skill { path, .. }) => {
writeln!(w, " skill_path={}", path.display())?;
Some(CodeSource::Skill { location, .. }) => {
writeln!(w, " skill_path={}", location.display_path())?;
}
Some(CodeSource::AIAction { .. })
| Some(CodeSource::New { .. })
| Some(CodeSource::FileTree { .. })
| Some(CodeSource::CommandPalette { .. })
| Some(CodeSource::Finder { .. })
| None => {}
}
+454 -75
View File
@@ -30,15 +30,23 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use anyhow::{Context as _, Result};
use command::r#async::Command;
use command::Stdio;
use futures::future::join_all;
use tokio::fs::{self as tokio_fs, OpenOptions};
use tokio::io::AsyncWriteExt as _;
use tokio::sync::{mpsc, oneshot};
use galaxy_core::report_error;
use galaxyui::r#async::executor::Background;
use galaxyui::r#async::FutureExt as _;
use crate::ai::agent_sdk::retry::with_bounded_retry;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::server::server_api::ai::{
AIClient, InitialSnapshotToken, SnapshotUploadFileInfo as AiSnapshotUploadFileInfo,
UploadLocalHandoffSnapshotRequest,
};
use crate::server::server_api::harness_support::{
upload_to_target, HarnessSupportClient, SnapshotFileInfo, SnapshotUploadRequest, UploadTarget,
};
@@ -99,6 +107,15 @@ struct DeclarationLine {
path: String,
}
/// Serialize-only sibling of [`DeclarationLine`] used by the writer task to emit `file`
/// entries with a fixed `version` and `kind`.
#[derive(serde::Serialize)]
struct FileDeclaration<'a> {
version: u32,
kind: &'a str,
path: &'a str,
}
/// Invoke `snapshot-declarations.sh` to (re)generate the declarations file consumed by the
/// rest of the upload pipeline.
///
@@ -107,10 +124,11 @@ struct DeclarationLine {
/// from `task_id`. The script appends to the file if it already exists, so
/// repeated invocations within a single run accumulate repos instead of clobbering.
///
/// A missing env var, a missing script, a non-zero exit status, a spawn failure, or a runtime
/// exceeding `script_timeout` are each logged at `log::error!` and returned without aborting the
/// caller — if a previous invocation already produced a declarations file on disk it remains
/// usable; otherwise the upload pipeline becomes a no-op.
/// A missing env var is expected in some paths and is logged at `log::info!`. A missing script,
/// a non-zero exit status, a spawn failure, or a runtime exceeding `script_timeout` are each
/// logged at `log::error!` and returned without aborting the caller — if a previous invocation
/// already produced a declarations file on disk it remains usable; otherwise the upload pipeline
/// becomes a no-op.
///
/// Exposed as a standalone helper so future call sites can trigger declarations generation at
/// other points in the run lifecycle (e.g. periodic mid-run snapshots).
@@ -120,7 +138,7 @@ pub(super) async fn run_declarations_script(
script_timeout: Duration,
) {
let Some(script_path) = std::env::var_os(DECLARATIONS_SCRIPT_PATH_ENV_VAR) else {
log::error!(
log::info!(
"{DECLARATIONS_SCRIPT_PATH_ENV_VAR} is not set; skipping snapshot declarations script (task {task_id})"
);
return;
@@ -199,7 +217,7 @@ fn resolve_declarations_path(task_id: Option<&AmbientAgentTaskId>) -> PathBuf {
/// 3. `{DEFAULT_DECLARATIONS_DIR}/{DEFAULT_DECLARATIONS_FILENAME}` as a final fallback.
fn resolve_declarations_path_with_override(
task_id: Option<&AmbientAgentTaskId>,
override_path: Option<std::ffi::OsString>,
override_path: Option<OsString>,
) -> PathBuf {
if let Some(override_path) = override_path {
return PathBuf::from(override_path);
@@ -216,31 +234,28 @@ fn resolve_declarations_path_with_override(
///
/// Returns `None` when the file is missing, unreadable, or yields no valid entries; logs a
/// WARN describing why in each case. A returned `Some(entries)` is guaranteed non-empty.
fn read_and_parse_declarations(
path: &Path,
task_id: &AmbientAgentTaskId,
) -> Option<Vec<DeclarationEntry>> {
fn read_and_parse_declarations(path: &Path) -> Option<Vec<DeclarationEntry>> {
let contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
log::warn!(
"Snapshot declarations file not found at '{}'; skipping upload (task {task_id})",
"Snapshot declarations file not found at '{}'; skipping upload",
path.display()
);
return None;
}
Err(e) => {
log::warn!(
"Failed to read snapshot declarations file '{}': {e:#}; skipping upload (task {task_id})",
"Failed to read snapshot declarations file '{}': {e:#}; skipping upload",
path.display()
);
return None;
}
};
let entries = parse_declarations(&contents, task_id);
let entries = parse_declarations(&contents);
if entries.is_empty() {
log::warn!(
"Snapshot declarations file '{}' has no valid entries; skipping upload (task {task_id})",
"Snapshot declarations file '{}' has no valid entries; skipping upload",
path.display()
);
return None;
@@ -254,7 +269,7 @@ fn read_and_parse_declarations(
/// and `path` (absolute path). Blank lines are ignored. Malformed lines (invalid JSON, missing
/// fields, unsupported versions, unknown kind, non-absolute path) are logged at WARN and skipped;
/// they never abort parsing.
fn parse_declarations(contents: &str, task_id: &AmbientAgentTaskId) -> Vec<DeclarationEntry> {
fn parse_declarations(contents: &str) -> Vec<DeclarationEntry> {
let mut entries = Vec::new();
let mut seen = HashSet::new();
for (index, raw) in contents.lines().enumerate() {
@@ -267,26 +282,26 @@ fn parse_declarations(contents: &str, task_id: &AmbientAgentTaskId) -> Vec<Decla
Ok(declaration) => declaration,
Err(e) => {
log::warn!(
"Malformed snapshot declarations JSONL line {line_number}: {e:#}: {raw:?} (task {task_id})"
"Malformed snapshot declarations JSONL line {line_number}: {e:#}: {raw:?}"
);
continue;
}
};
if declaration.version != Some(DECLARATION_VERSION) {
log::warn!(
"Malformed snapshot declarations line {line_number} (missing or unsupported version): {raw:?} (task {task_id})"
"Malformed snapshot declarations line {line_number} (missing or unsupported version): {raw:?}"
);
continue;
}
if declaration.path.is_empty() {
log::warn!(
"Malformed snapshot declarations line {line_number} (missing path): {raw:?} (task {task_id})"
"Malformed snapshot declarations line {line_number} (missing path): {raw:?}"
);
continue;
}
if !Path::new(&declaration.path).is_absolute() {
log::warn!(
"Malformed snapshot declarations line {line_number} (non-absolute path): {raw:?} (task {task_id})"
"Malformed snapshot declarations line {line_number} (non-absolute path): {raw:?}"
);
continue;
}
@@ -295,7 +310,7 @@ fn parse_declarations(contents: &str, task_id: &AmbientAgentTaskId) -> Vec<Decla
"file" => EntryKind::File,
other => {
log::warn!(
"Malformed snapshot declarations line {line_number} (unknown kind '{other}'): {raw:?} (task {task_id})"
"Malformed snapshot declarations line {line_number} (unknown kind '{other}'): {raw:?}"
);
continue;
}
@@ -311,6 +326,243 @@ fn parse_declarations(contents: &str, task_id: &AmbientAgentTaskId) -> Vec<Decla
entries
}
/// Drop `file` declarations whose path is already covered by a declared `repo` path so the
/// gather step does not double-upload files the repo patch already carries.
fn drop_files_covered_by_repos(entries: Vec<DeclarationEntry>) -> Vec<DeclarationEntry> {
let repo_paths: Vec<PathBuf> = entries
.iter()
.filter(|entry| entry.kind == EntryKind::Repo)
.map(|entry| PathBuf::from(&entry.path))
.collect();
if repo_paths.is_empty() {
return entries;
}
entries
.into_iter()
.filter(|entry| {
if entry.kind != EntryKind::File {
return true;
}
let file_path = Path::new(&entry.path);
for repo in &repo_paths {
if file_path.starts_with(repo) {
log::info!(
"Dropping file declaration '{}' covered by repo '{}'",
entry.path,
repo.display()
);
return false;
}
}
true
})
.collect()
}
// --- Declarations writer: SDK driver → declarations file ---
/// Commands accepted by the async declarations writer task.
enum WriterCommand {
/// Append `file` entries for the given paths to the declarations file.
Append(Vec<String>),
/// Acknowledge once every previously-queued command has finished its fs writes.
Flush(oneshot::Sender<()>),
}
/// Handle used by the SDK driver to enqueue `file` declaration appends from the subscription
/// thread without ever touching the filesystem inline.
///
/// The handle owns an unbounded `mpsc` sender into a dedicated writer task spawned by
/// [`DeclarationsWriterHandle::new`]. The writer task owns the `seen: HashSet<String>` and
/// the resolved declarations path, and processes commands sequentially, which serializes
/// writes within the process. Handles are cheaply cloneable because the underlying sender is;
/// dropping every handle closes the channel and lets the writer task exit cleanly.
#[derive(Clone)]
pub(super) struct DeclarationsWriterHandle {
tx: mpsc::UnboundedSender<WriterCommand>,
}
impl DeclarationsWriterHandle {
/// Spawn the writer task on `background` and return a fire-and-forget handle.
pub(super) fn new(
task_id: AmbientAgentTaskId,
working_dir: PathBuf,
background: &Background,
) -> Self {
let (tx, rx) = mpsc::unbounded_channel();
let declarations_path = resolve_declarations_path(Some(&task_id));
background
.spawn(writer_task(rx, declarations_path, working_dir, task_id))
.detach();
Self { tx }
}
/// Test-facing constructor that bypasses env-var-dependent path resolution and uses
/// `tokio::spawn` directly so tests can run without standing up a `Background`.
#[cfg(all(test, not(windows)))]
pub(super) fn new_at_path(
declarations_path: PathBuf,
working_dir: PathBuf,
task_id: AmbientAgentTaskId,
) -> Self {
let (tx, rx) = mpsc::unbounded_channel();
tokio::spawn(writer_task(rx, declarations_path, working_dir, task_id));
Self { tx }
}
/// Enqueue `paths` for appending as `file` entries.
///
/// Non-blocking; the subscription handler can call this from a sync context. Empty
/// input is a no-op.
pub(super) fn append(&self, paths: Vec<String>) {
if paths.is_empty() {
return;
}
if let Err(e) = self.tx.send(WriterCommand::Append(paths)) {
log::warn!("Declarations writer channel closed; dropping append: {e}");
}
}
/// Awaits until every previously-queued `append` has finished its fs writes.
///
/// Called once from `AgentDriver::run_snapshot_upload` immediately before
/// `snapshot::run_declarations_script`, so no driver-side write is in flight when the
/// bash script starts its own appends.
pub(super) async fn flush(&self) {
let (ack_tx, ack_rx) = oneshot::channel();
if self.tx.send(WriterCommand::Flush(ack_tx)).is_err() {
// Writer task has already exited; nothing is queued, nothing to drain.
return;
}
if ack_rx.await.is_err() {
log::warn!("Declarations writer flush oneshot dropped without ack");
}
}
}
/// Writer task loop: owns the `seen` set, lazily opens the file per write, and services
/// `Append` and `Flush` commands in order.
async fn writer_task(
mut rx: mpsc::UnboundedReceiver<WriterCommand>,
declarations_path: PathBuf,
working_dir: PathBuf,
task_id: AmbientAgentTaskId,
) {
let mut seen: HashSet<String> = HashSet::new();
while let Some(cmd) = rx.recv().await {
match cmd {
WriterCommand::Append(paths) => {
for path in paths {
process_append_path(
path,
&declarations_path,
&working_dir,
&task_id,
&mut seen,
)
.await;
}
}
WriterCommand::Flush(ack) => {
let _ = ack.send(());
}
}
}
}
/// Normalize, preempt against existing repos, and write one JSONL line for `raw_path`.
/// All failures log at WARN and return without advancing `seen`.
async fn process_append_path(
raw_path: String,
declarations_path: &Path,
working_dir: &Path,
task_id: &AmbientAgentTaskId,
seen: &mut HashSet<String>,
) {
let candidate = Path::new(&raw_path);
let absolute = if candidate.is_absolute() {
candidate.to_path_buf()
} else {
working_dir.join(candidate)
};
if !absolute.is_absolute() {
log::warn!(
"Skipping non-absolute file-edit path {absolute:?} for declarations (task {task_id})"
);
return;
}
let Some(absolute_str) = absolute.to_str().map(str::to_owned) else {
log::warn!(
"Skipping non-UTF-8 file-edit path {absolute:?} for declarations (task {task_id})"
);
return;
};
if seen.contains(&absolute_str) {
return;
}
if path_is_under_existing_repo(&absolute).await {
log::debug!(
"Skipping file declaration for '{absolute_str}': already inside an existing git repo (task {task_id})"
);
seen.insert(absolute_str);
return;
}
match append_declaration_line(declarations_path, &absolute_str).await {
Ok(()) => {
seen.insert(absolute_str);
}
Err(e) => {
log::warn!(
"Failed to append file declaration for '{absolute_str}': {e:#} (task {task_id})"
);
}
}
}
/// Walk ancestors of `path` and return `true` if any of them already contains a `.git`
/// directory. Cheap enough to run per path: one `stat(2)` per ancestor up to `/`.
async fn path_is_under_existing_repo(path: &Path) -> bool {
let mut current = path.parent();
while let Some(dir) = current {
let git_dir = dir.join(".git");
if tokio_fs::try_exists(&git_dir).await.unwrap_or(false) {
return true;
}
current = dir.parent();
}
false
}
/// Open the declarations file in append-create mode and write one JSONL line for `path`.
/// The serialized shape matches the schema the parser expects.
async fn append_declaration_line(declarations_path: &Path, path: &str) -> Result<()> {
if let Some(parent) = declarations_path.parent() {
tokio_fs::create_dir_all(parent)
.await
.with_context(|| format!("create_dir_all {}", parent.display()))?;
}
let mut line = serde_json::to_string(&FileDeclaration {
version: DECLARATION_VERSION,
kind: "file",
path,
})
.context("serialize file declaration")?;
line.push('\n');
let mut file = OpenOptions::new()
.append(true)
.create(true)
.open(declarations_path)
.await
.with_context(|| format!("open declarations file {}", declarations_path.display()))?;
file.write_all(line.as_bytes())
.await
.with_context(|| format!("write declarations file {}", declarations_path.display()))?;
file.flush()
.await
.with_context(|| format!("flush declarations file {}", declarations_path.display()))?;
Ok(())
}
// --- Gather phase: upload blobs and per-entry results ---
struct SnapshotUploadFile {
@@ -447,7 +699,7 @@ pub(super) async fn upload_snapshot_from_declarations(
task_id: &AmbientAgentTaskId,
) {
let declarations_path = resolve_declarations_path(Some(task_id));
let _ = upload_snapshot_from_declarations_file(&declarations_path, client, task_id).await;
let _ = upload_snapshot_from_declarations_file(&declarations_path, client).await;
}
/// Internal entry that reads from an explicit path and returns the structured outcome so tests
@@ -456,13 +708,10 @@ pub(super) async fn upload_snapshot_from_declarations(
async fn upload_snapshot_from_declarations_file(
path: &Path,
client: Arc<dyn HarnessSupportClient>,
task_id: &AmbientAgentTaskId,
) -> Option<SnapshotOutcome> {
log::info!(
"Snapshot upload starting from {} (task {task_id})",
path.display()
);
let declarations = read_and_parse_declarations(path, task_id)?;
log::info!("Snapshot upload starting from {}", path.display());
let declarations = read_and_parse_declarations(path)?;
let declarations = drop_files_covered_by_repos(declarations);
let (repo_count, file_count) = declarations
.iter()
.fold((0usize, 0usize), |(r, f), e| match e.kind {
@@ -470,14 +719,149 @@ async fn upload_snapshot_from_declarations_file(
EntryKind::File => (r, f + 1),
});
log::info!(
"Snapshot declarations: {} entries ({repo_count} repo, {file_count} file) (task {task_id})",
"Snapshot declarations: {} entries ({repo_count} repo, {file_count} file)",
declarations.len(),
);
let outcome = run_pipeline(declarations, client, task_id).await?;
log_snapshot_outcome(&outcome, task_id);
let outcome = run_pipeline(declarations, client).await?;
log_snapshot_outcome(&outcome);
Some(outcome)
}
/// Build the snapshot for a local-to-cloud handoff: gather repo patches and orphan file
/// contents, allocate an initial snapshot token plus presigned upload URLs via
/// `AIClient::upload_local_handoff_snapshot`, and upload the artifacts.
///
/// Returns:
/// - `Ok(Some(initial_snapshot_token))` when a token was minted **and the manifest landed in GCS**.
/// Individual blob uploads may still have failed; the manifest catalogues their status so the
/// cloud agent rehydrates against whatever did land, matching the cloud→cloud best-effort
/// posture.
/// - `Ok(None)` when the workspace was empty (no repos, no orphan files) **or** when the
/// manifest itself failed to upload. Without the manifest the snapshot is unusable, so
/// callers should spawn the cloud agent without an initial snapshot token instead of pointing
/// it at an incomplete prefix. Manifest-upload failures are also routed through
/// `report_error!` so on-call alerting catches the silent regression.
/// - `Err(_)` only for hard failures of `upload_local_handoff_snapshot` itself (auth, etc.).
pub(crate) async fn upload_snapshot_for_handoff(
repo_paths: Vec<PathBuf>,
orphan_file_paths: Vec<PathBuf>,
client: Arc<dyn AIClient>,
http: &http_client::Client,
) -> Result<Option<InitialSnapshotToken>> {
if repo_paths.is_empty() && orphan_file_paths.is_empty() {
log::info!("Handoff snapshot has no declarations; skipping upload");
return Ok(None);
}
let declarations: Vec<DeclarationEntry> = repo_paths
.into_iter()
.map(|path| DeclarationEntry {
kind: EntryKind::Repo,
path: path.display().to_string(),
})
.chain(orphan_file_paths.into_iter().map(|path| DeclarationEntry {
kind: EntryKind::File,
path: path.display().to_string(),
}))
.collect();
let GatheredSnapshot {
manifest_filename,
mut upload_files,
mut repos,
mut files,
mut pre_upload_entries,
} = gather_snapshot_entries(declarations).await;
apply_per_run_cap(
&mut upload_files,
&mut repos,
&mut files,
&mut pre_upload_entries,
);
let mut file_infos: Vec<SnapshotFileInfo> = upload_files
.iter()
.map(|file| SnapshotFileInfo {
filename: file.filename.clone(),
mime_type: file.mime_type.clone(),
})
.collect();
file_infos.push(SnapshotFileInfo {
filename: manifest_filename.clone(),
mime_type: "application/json".to_string(),
});
let upload_request = UploadLocalHandoffSnapshotRequest {
files: file_infos
.iter()
.map(|file| AiSnapshotUploadFileInfo {
filename: file.filename.clone(),
mime_type: file.mime_type.clone(),
})
.collect(),
};
let response = client
.upload_local_handoff_snapshot(upload_request)
.await
.context("failed to allocate initial snapshot token")?;
log::info!(
"Initial snapshot token allocated; expires_at={}, uploads={}",
response.expires_at,
response.uploads.len(),
);
let initial_snapshot_token = response.initial_snapshot_token;
// Server returns `uploads` aligned by index with the request `files` array (and does
// not echo per-entry filenames), so we zip them positionally into a filename-keyed map.
// Any request file the server omits lands in `upload_entry` with no target and is
// marked `skipped` downstream.
if response.uploads.len() != file_infos.len() {
log::warn!(
"Handoff snapshot upload-target response length {} does not match request length {}; \
extras will be marked skipped",
response.uploads.len(),
file_infos.len(),
);
}
let mut target_map: HashMap<String, UploadTarget> = HashMap::new();
for (file, target) in file_infos.iter().zip(response.uploads.into_iter()) {
target_map.insert(file.filename.clone(), target);
}
let Some(outcome) = upload_prepared_snapshot_files(
http,
manifest_filename,
upload_files,
repos,
files,
pre_upload_entries,
target_map,
)
.await
else {
// Manifest serialization failed (already reported via `report_error!` inside
// the helper). Without a manifest the snapshot is unusable, so refuse the token.
return Ok(None);
};
let summary = SnapshotSummary::from_entries(&outcome.entries, outcome.manifest_uploaded);
log_snapshot_outcome(&outcome);
if !summary.manifest_uploaded {
// Without the manifest the cloud agent has no catalogue to rehydrate from, even
// when individual blobs landed. Alert on-call and refuse the token so we don't
// silently spawn a cloud agent with no recoverable state.
report_error!(anyhow::anyhow!(
"Handoff snapshot manifest failed to upload (blobs: {}/{}); cloud agent will start with no rehydration content",
summary.uploaded,
summary.total,
));
return Ok(None);
}
Ok(Some(initial_snapshot_token))
}
/// Core upload pipeline.
///
/// Gather/read/upload failures are captured in [`SnapshotOutcome::entries`] and never abort
@@ -486,7 +870,6 @@ async fn upload_snapshot_from_declarations_file(
async fn run_pipeline(
declarations: Vec<DeclarationEntry>,
client: Arc<dyn HarnessSupportClient>,
task_id: &AmbientAgentTaskId,
) -> Option<SnapshotOutcome> {
let GatheredSnapshot {
manifest_filename,
@@ -494,11 +877,10 @@ async fn run_pipeline(
repos,
files,
pre_upload_entries,
} = gather_snapshot_entries(declarations, task_id).await;
} = gather_snapshot_entries(declarations).await;
upload_gathered_snapshot(
client,
task_id,
manifest_filename,
upload_files,
repos,
@@ -516,10 +898,7 @@ struct GatheredSnapshot {
pre_upload_entries: Vec<EntryResult>,
}
async fn gather_snapshot_entries(
declarations: Vec<DeclarationEntry>,
task_id: &AmbientAgentTaskId,
) -> GatheredSnapshot {
async fn gather_snapshot_entries(declarations: Vec<DeclarationEntry>) -> GatheredSnapshot {
let mut used_filenames = HashSet::new();
let manifest_filename = unique_filename("snapshot_state.json", &mut used_filenames);
@@ -542,7 +921,6 @@ async fn gather_snapshot_entries(
&mut upload_files,
&mut repos,
&mut pre_upload_entries,
task_id,
)
.await;
}
@@ -553,7 +931,6 @@ async fn gather_snapshot_entries(
&mut upload_files,
&mut files,
&mut pre_upload_entries,
task_id,
)
.await;
}
@@ -571,7 +948,6 @@ async fn gather_snapshot_entries(
async fn upload_gathered_snapshot(
client: Arc<dyn HarnessSupportClient>,
task_id: &AmbientAgentTaskId,
manifest_filename: String,
mut upload_files: Vec<SnapshotUploadFile>,
mut repos: Vec<RepoManifestEntry>,
@@ -587,7 +963,6 @@ async fn upload_gathered_snapshot(
&mut repos,
&mut files,
&mut pre_upload_entries,
task_id,
);
// Ask the server for presigned URLs for every filename we intend to upload —
@@ -619,16 +994,14 @@ async fn upload_gathered_snapshot(
Err(e) => {
// Pipeline-abort: route through report_error! so Sentry captures the structured
// error chain and on-call alerting can fire.
report_error!(e.context(format!(
"Failed to get snapshot upload targets; skipping upload (task {task_id})"
)));
report_error!(e.context("Failed to get snapshot upload targets; skipping upload"));
return None;
}
};
if targets.len() != chunk.len() {
log::warn!(
"Snapshot upload-target response length {} does not match request length {}; \
extras will be marked skipped (task {task_id})",
extras will be marked skipped",
targets.len(),
chunk.len(),
);
@@ -637,14 +1010,33 @@ async fn upload_gathered_snapshot(
target_map.insert(file.filename.clone(), target);
}
}
upload_prepared_snapshot_files(
client.http_client(),
manifest_filename,
upload_files,
repos,
files,
pre_upload_entries,
target_map,
)
.await
}
async fn upload_prepared_snapshot_files(
http: &http_client::Client,
manifest_filename: String,
upload_files: Vec<SnapshotUploadFile>,
mut repos: Vec<RepoManifestEntry>,
mut files: Vec<FileManifestEntry>,
pre_upload_entries: Vec<EntryResult>,
target_map: HashMap<String, UploadTarget>,
) -> Option<SnapshotOutcome> {
// Upload non-manifest blobs concurrently, each with bounded retries on transient errors.
let http = client.http_client();
let upload_futures = upload_files
.iter()
.map(|file| upload_entry(http, file, &target_map, task_id));
.map(|file| upload_entry(http, file, &target_map));
let upload_entries: Vec<EntryResult> = join_all(upload_futures).await;
fold_upload_results(&mut repos, &mut files, &upload_entries, task_id);
fold_upload_results(&mut repos, &mut files, &upload_entries);
// Build and upload the manifest last, with the real outcomes baked in.
let manifest = SnapshotManifest {
@@ -656,9 +1048,8 @@ async fn upload_gathered_snapshot(
Ok(b) => b,
Err(e) => {
// Pipeline-abort: route through report_error! so Sentry captures it.
report_error!(anyhow::Error::from(e).context(format!(
"Failed to serialize snapshot manifest; skipping upload (task {task_id})"
)));
report_error!(anyhow::Error::from(e)
.context("Failed to serialize snapshot manifest; skipping upload"));
return None;
}
};
@@ -671,9 +1062,7 @@ async fn upload_gathered_snapshot(
Err(e) => {
// Capture the full chain for the manifest's `error` field, then surface it
// to Sentry via report_error!.
let e = e.context(format!(
"Failed to upload manifest '{manifest_filename}' (task {task_id})"
));
let e = e.context(format!("Failed to upload manifest '{manifest_filename}'"));
let msg = format!("{e:#}");
report_error!(e);
(false, Some(msg))
@@ -714,7 +1103,6 @@ async fn gather_repo(
upload_files: &mut Vec<SnapshotUploadFile>,
repos: &mut Vec<RepoManifestEntry>,
pre_upload_entries: &mut Vec<EntryResult>,
task_id: &AmbientAgentTaskId,
) {
let repo = Path::new(repo_path);
let metadata = repo_metadata(repo).await;
@@ -756,7 +1144,7 @@ async fn gather_repo(
}
Err(e) => {
let err_str = format!("{e:#}");
log::warn!("Failed to snapshot repo '{repo_path}': {err_str} (task {task_id})");
log::warn!("Failed to snapshot repo '{repo_path}': {err_str}");
repos.push(RepoManifestEntry {
path: repo_path.to_string(),
repo_name: metadata.repo_name,
@@ -783,7 +1171,6 @@ async fn gather_file(
upload_files: &mut Vec<SnapshotUploadFile>,
files: &mut Vec<FileManifestEntry>,
pre_upload_entries: &mut Vec<EntryResult>,
task_id: &AmbientAgentTaskId,
) {
let path = Path::new(file_path);
match tokio::fs::read(path).await {
@@ -812,7 +1199,7 @@ async fn gather_file(
}
Err(e) => {
let err_str = format!("Failed to read file '{file_path}': {e:#}");
log::warn!("{err_str} (task {task_id})");
log::warn!("{err_str}");
files.push(FileManifestEntry {
path: file_path.to_string(),
snapshot_file: None,
@@ -836,13 +1223,9 @@ async fn upload_entry(
http: &http_client::Client,
file: &SnapshotUploadFile,
target_map: &HashMap<String, UploadTarget>,
task_id: &AmbientAgentTaskId,
) -> EntryResult {
let Some(target) = target_map.get(&file.filename) else {
log::warn!(
"No upload target for file '{}', skipping (task {task_id})",
file.filename
);
log::warn!("No upload target for file '{}', skipping", file.filename);
return EntryResult {
label: file.filename.clone(),
status: EntryStatus::Skipped,
@@ -860,10 +1243,7 @@ async fn upload_entry(
},
Err(e) => {
let msg = format!("{e:#}");
log::warn!(
"Failed to upload '{}': {msg} (task {task_id})",
file.filename
);
log::warn!("Failed to upload '{}': {msg}", file.filename);
EntryResult {
label: file.filename.clone(),
status: EntryStatus::Failed,
@@ -879,7 +1259,6 @@ fn fold_upload_results(
repos: &mut [RepoManifestEntry],
files: &mut [FileManifestEntry],
upload_entries: &[EntryResult],
task_id: &AmbientAgentTaskId,
) {
for entry in upload_entries {
if let Some(repo_entry) = repos
@@ -903,7 +1282,7 @@ fn fold_upload_results(
}
EntryStatus::GatherFailed | EntryStatus::ReadFailed => {
log::error!(
"fold_upload_results: unexpected pre-upload status {:?} for repo patch '{}' (task {task_id})",
"fold_upload_results: unexpected pre-upload status {:?} for repo patch '{}'",
entry.status,
entry.label
);
@@ -930,7 +1309,7 @@ fn fold_upload_results(
}
EntryStatus::GatherFailed | EntryStatus::ReadFailed => {
log::error!(
"fold_upload_results: unexpected pre-upload status {:?} for file '{}' (task {task_id})",
"fold_upload_results: unexpected pre-upload status {:?} for file '{}'",
entry.status,
entry.label
);
@@ -949,7 +1328,6 @@ fn apply_per_run_cap(
repos: &mut [RepoManifestEntry],
files: &mut [FileManifestEntry],
pre_upload_entries: &mut Vec<EntryResult>,
task_id: &AmbientAgentTaskId,
) {
let blob_limit = MAX_SNAPSHOT_FILES_PER_RUN.saturating_sub(1);
if upload_files.len() <= blob_limit {
@@ -958,7 +1336,7 @@ fn apply_per_run_cap(
let total_including_manifest = upload_files.len() + 1;
let dropped = upload_files.split_off(blob_limit);
log::warn!(
"Snapshot exceeds per-run cap of {MAX_SNAPSHOT_FILES_PER_RUN} files ({total_including_manifest} declared); dropping {} blob(s) from upload (task {task_id})",
"Snapshot exceeds per-run cap of {MAX_SNAPSHOT_FILES_PER_RUN} files ({total_including_manifest} declared); dropping {} blob(s) from upload",
dropped.len(),
);
let err_msg = format!("exceeded per-run snapshot cap of {MAX_SNAPSHOT_FILES_PER_RUN} files");
@@ -1011,13 +1389,14 @@ fn merge_content_type(target: &UploadTarget, mime_type: &str) -> UploadTarget {
url: target.url.clone(),
method: target.method.clone(),
headers,
fields: target.fields.clone(),
}
}
/// Log the final outcome at INFO when everything uploaded, WARN otherwise. The log line
/// includes per-entry statuses so operators can diagnose partial state without parsing any
/// downstream logs.
fn log_snapshot_outcome(outcome: &SnapshotOutcome, task_id: &AmbientAgentTaskId) {
fn log_snapshot_outcome(outcome: &SnapshotOutcome) {
let summary = SnapshotSummary::from_entries(&outcome.entries, outcome.manifest_uploaded);
let manifest_bit = if summary.manifest_uploaded {
"manifest: uploaded"
@@ -1025,7 +1404,7 @@ fn log_snapshot_outcome(outcome: &SnapshotOutcome, task_id: &AmbientAgentTaskId)
"manifest: failed"
};
let header = format!(
"Snapshot upload: {}/{} uploaded (failed: {}, skipped: {}, gather_failed: {}, read_failed: {}; {manifest_bit}) (task {task_id})",
"Snapshot upload: {}/{} uploaded (failed: {}, skipped: {}, gather_failed: {}, read_failed: {}; {manifest_bit})",
summary.uploaded,
summary.total,
summary.failed,
+311 -30
View File
@@ -86,6 +86,10 @@ impl HarnessSupportClient for TestClient {
unimplemented!("not used by upload_snapshot_from_declarations_file")
}
async fn fetch_transcript(&self) -> Result<bytes::Bytes> {
unimplemented!("not used by upload_snapshot_from_declarations_file")
}
async fn get_block_snapshot_upload_target(
&self,
_conversation_id: &AIConversationId,
@@ -112,6 +116,18 @@ impl HarnessSupportClient for TestClient {
unimplemented!("not used by upload_snapshot_from_declarations_file")
}
async fn report_clean_shutdown(&self) -> Result<()> {
unimplemented!("not used by upload_snapshot_from_declarations_file")
}
async fn report_error_shutdown(
&self,
_error_category: String,
_error_message: String,
) -> Result<()> {
unimplemented!("not used by upload_snapshot_from_declarations_file")
}
async fn get_snapshot_upload_targets(
&self,
request: &SnapshotUploadRequest,
@@ -131,6 +147,7 @@ impl HarnessSupportClient for TestClient {
url: format!("{}/upload/{}", self.server_base_url, f.filename),
method: "PUT".to_string(),
headers: HashMap::new(),
fields: Vec::new(),
})
.collect();
let keep = targets.len().saturating_sub(self.drop_trailing_targets);
@@ -138,10 +155,6 @@ impl HarnessSupportClient for TestClient {
Ok(targets)
}
async fn fetch_transcript(&self) -> Result<bytes::Bytes> {
unimplemented!("not used by upload_snapshot_from_declarations_file")
}
fn http_client(&self) -> &http_client::Client {
&self.http
}
@@ -248,7 +261,6 @@ fn run(
.block_on(upload_snapshot_from_declarations_file(
&declarations_path,
client,
&fake_task_id(),
))
.expect("pipeline returned None");
let summary = SnapshotSummary::from_entries(&outcome.entries, outcome.manifest_uploaded);
@@ -276,7 +288,7 @@ fn parse_declarations_ignores_blank_lines() {
"{\"version\":1,\"kind\":\"file\",\"path\":\"/abs/file.txt\"}\n",
"\n",
);
let entries = parse_declarations(contents, &fake_task_id());
let entries = parse_declarations(contents);
assert_eq!(
entries,
vec![
@@ -302,7 +314,7 @@ fn parse_declarations_skips_malformed_lines_without_aborting() {
"{\"version\":1,\"kind\":\"file\"}\n",
"{\"version\":1,\"kind\":\"file\",\"path\":\"/abs/also-good\",\"extra\":true}\n",
);
let entries = parse_declarations(contents, &fake_task_id());
let entries = parse_declarations(contents);
assert_eq!(
entries,
vec![
@@ -325,7 +337,7 @@ fn parse_declarations_skips_missing_or_unsupported_versions() {
"{\"version\":2,\"kind\":\"repo\",\"path\":\"/abs/unsupported-version\"}\n",
"{\"version\":1,\"kind\":\"file\",\"path\":\"/abs/good\"}\n",
);
let entries = parse_declarations(contents, &fake_task_id());
let entries = parse_declarations(contents);
assert_eq!(
entries,
vec![DeclarationEntry {
@@ -342,7 +354,7 @@ fn parse_declarations_tolerates_crlf_line_endings() {
"{\"version\":1,\"kind\":\"repo\",\"path\":\"/abs/good\"}\r\n",
"{\"version\":1,\"kind\":\"file\",\"path\":\"/abs/also-good\"}\r\n",
);
let entries = parse_declarations(contents, &fake_task_id());
let entries = parse_declarations(contents);
assert_eq!(
entries,
vec![
@@ -365,7 +377,7 @@ fn parse_declarations_skips_lines_with_empty_path() {
"{\"version\":1,\"kind\":\"file\",\"path\":\" \"}\n",
"{\"version\":1,\"kind\":\"repo\",\"path\":\"/abs/still-good\"}\n",
);
let entries = parse_declarations(contents, &fake_task_id());
let entries = parse_declarations(contents);
assert_eq!(
entries,
vec![DeclarationEntry {
@@ -382,7 +394,7 @@ fn parse_declarations_deduplicates_kind_path_pairs() {
"{\"version\":1,\"kind\":\"repo\",\"path\":\"/abs/repo\"}\n",
"{\"version\":1,\"kind\":\"file\",\"path\":\"/abs/repo\"}\n",
);
let entries = parse_declarations(contents, &fake_task_id());
let entries = parse_declarations(contents);
assert_eq!(
entries,
vec![
@@ -406,11 +418,7 @@ fn upload_skipped_when_declarations_file_missing() {
let client = TestClient::new(server.url());
let outcome = Runtime::new()
.unwrap()
.block_on(upload_snapshot_from_declarations_file(
&missing,
client,
&fake_task_id(),
));
.block_on(upload_snapshot_from_declarations_file(&missing, client));
assert!(
outcome.is_none(),
"missing declarations file should skip the upload"
@@ -426,11 +434,7 @@ fn upload_skipped_when_declarations_file_empty() {
let client = TestClient::new(server.url());
let outcome = Runtime::new()
.unwrap()
.block_on(upload_snapshot_from_declarations_file(
&decl,
client,
&fake_task_id(),
));
.block_on(upload_snapshot_from_declarations_file(&decl, client));
assert!(outcome.is_none(), "empty declarations file should skip");
}
@@ -447,11 +451,7 @@ fn upload_skipped_when_declarations_file_has_no_valid_jsonl_entries() {
let client = TestClient::new(server.url());
let outcome = Runtime::new()
.unwrap()
.block_on(upload_snapshot_from_declarations_file(
&decl,
client,
&fake_task_id(),
));
.block_on(upload_snapshot_from_declarations_file(&decl, client));
assert!(
outcome.is_none(),
"declarations file with no valid entries should skip"
@@ -631,10 +631,12 @@ fn e2e_gather_failed_entry_captured_in_manifest() {
#[test]
fn e2e_read_failed_for_missing_file_continues_pipeline() {
// Point a `file` entry at a path that doesn't exist → read_failed, with a clean repo also
// included so we verify the pipeline didn't abort after the read failure.
// included so we verify the pipeline didn't abort after the read failure. Keep the missing
// file outside the repo so the repo-overlap filter does not strip it before gather.
let tempdir = snaptest_tempdir();
init_git_repo(tempdir.path(), false);
let missing_file = tempdir.path().join("does-not-exist.txt");
let missing_dir = snaptest_tempdir();
let missing_file = missing_dir.path().join("does-not-exist.txt");
let decl_dir = snaptest_tempdir();
let mut server = Server::new();
@@ -978,7 +980,6 @@ fn e2e_get_snapshot_upload_targets_failure_returns_none() {
.block_on(upload_snapshot_from_declarations_file(
&declarations_path,
client,
&fake_task_id(),
));
assert!(
outcome.is_none(),
@@ -1022,7 +1023,6 @@ fn e2e_short_response_leaves_trailing_file_without_target() {
.block_on(upload_snapshot_from_declarations_file(
&declarations_path,
client,
&fake_task_id(),
))
.expect("pipeline returned None");
let summary = SnapshotSummary::from_entries(&outcome.entries, outcome.manifest_uploaded);
@@ -1182,3 +1182,284 @@ fn e2e_per_run_cap_drops_excess_blobs_as_skipped() {
}
upload_mock.assert();
}
// ------------------------------------------------------------------------------------------------
// REMOTE-1465: repo-overlap dedup + DeclarationsWriterHandle.
// ------------------------------------------------------------------------------------------------
/// Build a `DeclarationEntry` without exposing the private type to call sites.
fn repo_entry(path: &str) -> DeclarationEntry {
DeclarationEntry {
kind: EntryKind::Repo,
path: path.to_string(),
}
}
fn file_entry(path: &str) -> DeclarationEntry {
DeclarationEntry {
kind: EntryKind::File,
path: path.to_string(),
}
}
#[test]
fn drop_files_covered_by_repos_keeps_everything_when_no_repos_declared() {
let entries = vec![
file_entry("/abs/outside.txt"),
file_entry("/other/also-outside.txt"),
];
let after = drop_files_covered_by_repos(entries.clone());
assert_eq!(after, entries);
}
#[test]
fn drop_files_covered_by_repos_drops_file_inside_repo_keeps_file_outside() {
let entries = vec![
repo_entry("/workspace/my-repo"),
file_entry("/workspace/my-repo/src/foo.rs"),
file_entry("/tmp/outside.txt"),
];
let after = drop_files_covered_by_repos(entries);
assert_eq!(
after,
vec![
repo_entry("/workspace/my-repo"),
file_entry("/tmp/outside.txt"),
]
);
}
#[test]
fn drop_files_covered_by_repos_handles_nested_repo_paths() {
// A file under /a/b/sub should be filtered by either /a or /a/b/sub.
let entries = vec![
repo_entry("/a"),
repo_entry("/a/b/sub"),
file_entry("/a/b/sub/file.txt"),
file_entry("/a/top.txt"),
file_entry("/unrelated.txt"),
];
let after = drop_files_covered_by_repos(entries);
assert_eq!(
after,
vec![
repo_entry("/a"),
repo_entry("/a/b/sub"),
file_entry("/unrelated.txt"),
]
);
}
/// Parse the declarations file written by `DeclarationsWriterHandle` into the paths we care
/// about for assertions, ignoring any lines the helper tests weren't asked to produce.
fn parsed_file_paths(path: &Path) -> Vec<String> {
let contents = fs::read_to_string(path).unwrap_or_default();
let entries = parse_declarations(&contents);
entries
.into_iter()
.filter(|e| e.kind == EntryKind::File)
.map(|e| e.path)
.collect()
}
#[test]
fn declarations_writer_appends_unique_absolute_paths_once() {
let tempdir = snaptest_tempdir();
let workspace = tempdir.path().join("workspace");
fs::create_dir_all(&workspace).unwrap();
let decl_path = tempdir.path().join("declarations.jsonl");
let task_id = fake_task_id();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let handle =
DeclarationsWriterHandle::new_at_path(decl_path.clone(), workspace.clone(), task_id);
let path_one = workspace.join("one.txt");
let path_two = workspace.join("two.txt");
handle.append(vec![
path_one.to_string_lossy().into_owned(),
path_two.to_string_lossy().into_owned(),
// Duplicate: should still only produce one entry per unique path.
path_one.to_string_lossy().into_owned(),
]);
handle.flush().await;
// A second batch that re-declares path_one should also be a no-op.
handle.append(vec![path_one.to_string_lossy().into_owned()]);
handle.flush().await;
});
let paths = parsed_file_paths(&decl_path);
assert_eq!(
paths,
vec![
workspace.join("one.txt").to_string_lossy().into_owned(),
workspace.join("two.txt").to_string_lossy().into_owned(),
]
);
}
#[test]
fn declarations_writer_resolves_relative_paths_against_working_dir() {
let tempdir = snaptest_tempdir();
let workspace = tempdir.path().join("workspace");
fs::create_dir_all(&workspace).unwrap();
let decl_path = tempdir.path().join("declarations.jsonl");
let task_id = fake_task_id();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let handle =
DeclarationsWriterHandle::new_at_path(decl_path.clone(), workspace.clone(), task_id);
handle.append(vec!["notes/relative.txt".to_string()]);
handle.flush().await;
});
let paths = parsed_file_paths(&decl_path);
assert_eq!(
paths,
vec![workspace
.join("notes/relative.txt")
.to_string_lossy()
.into_owned()]
);
}
#[test]
fn declarations_writer_continues_after_per_path_write_failures() {
// Pre-create a directory at the declarations file path so the first append's open call
// fails. Once we remove the directory, a subsequent append must succeed, proving the
// writer task absorbed the failure and kept servicing commands.
let tempdir = snaptest_tempdir();
let workspace = tempdir.path().join("workspace");
fs::create_dir_all(&workspace).unwrap();
let decl_path = tempdir.path().join("declarations.jsonl");
fs::create_dir(&decl_path).unwrap();
let task_id = fake_task_id();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let handle =
DeclarationsWriterHandle::new_at_path(decl_path.clone(), workspace.clone(), task_id);
handle.append(vec![workspace
.join("first.txt")
.to_string_lossy()
.into_owned()]);
handle.flush().await;
// Replace the staged directory so the next append's open call can succeed.
fs::remove_dir(&decl_path).unwrap();
handle.append(vec![workspace
.join("second.txt")
.to_string_lossy()
.into_owned()]);
handle.flush().await;
});
let paths = parsed_file_paths(&decl_path);
assert_eq!(
paths,
vec![workspace.join("second.txt").to_string_lossy().into_owned()],
"writer task should absorb the first failure and process the second append"
);
}
#[test]
fn declarations_writer_preempts_paths_inside_existing_repo() {
let tempdir = snaptest_tempdir();
// Simulate an existing repo by creating the `.git` directory the ancestor walker checks.
let repo = tempdir.path().join("existing-repo");
fs::create_dir_all(repo.join(".git")).unwrap();
let inside = repo.join("inside.txt");
let outside = tempdir.path().join("outside.txt");
let decl_path = tempdir.path().join("declarations.jsonl");
let task_id = fake_task_id();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let handle = DeclarationsWriterHandle::new_at_path(
decl_path.clone(),
tempdir.path().to_path_buf(),
task_id,
);
handle.append(vec![
inside.to_string_lossy().into_owned(),
outside.to_string_lossy().into_owned(),
]);
handle.flush().await;
});
let paths = parsed_file_paths(&decl_path);
assert_eq!(paths, vec![outside.to_string_lossy().into_owned()]);
}
#[test]
fn e2e_repo_plus_inside_and_outside_files_filters_overlap() {
// The writer-written declarations file feeds straight into the upload pipeline. Pair one
// `repo` with two `file` entries (one inside the repo, one outside). The gather-time
// overlap filter should drop the inside-repo file entry before upload so only the repo's
// patch + the outside-repo file + the manifest land on the server.
let repo_dir = snaptest_tempdir();
init_git_repo(repo_dir.path(), true);
let inside_file = repo_dir.path().join("new-untracked.txt");
fs::write(&inside_file, b"tracked-or-not, handled by the patch\n").unwrap();
let outside_dir = snaptest_tempdir();
let outside_file = outside_dir.path().join("standalone_log.txt");
fs::write(&outside_file, b"agent-produced log\n").unwrap();
let decl_dir = snaptest_tempdir();
let decl_path = decl_dir.path().join("snapshot-declarations.jsonl");
let contents = format!(
concat!(
"{{\"version\":1,\"kind\":\"repo\",\"path\":{repo:?}}}\n",
"{{\"version\":1,\"kind\":\"file\",\"path\":{inside:?}}}\n",
"{{\"version\":1,\"kind\":\"file\",\"path\":{outside:?}}}\n",
),
repo = repo_dir.path().to_string_lossy(),
inside = inside_file.to_string_lossy(),
outside = outside_file.to_string_lossy(),
);
fs::write(&decl_path, contents).unwrap();
let mut server = Server::new();
let patch_mock = server
.mock("PUT", upload_path(r".+\.patch"))
.with_status(200)
.expect(1)
.create();
let file_mock = server
.mock("PUT", upload_path("standalone_log\\.txt"))
.with_status(200)
.expect(1)
.create();
let manifest_mock = server
.mock("PUT", upload_path("snapshot_state\\.json"))
.match_body(Matcher::PartialJson(serde_json::json!({
"files": [
{
"path": outside_file.to_string_lossy(),
"status": "uploaded",
"uploaded": true,
}
],
})))
.with_status(200)
.expect(1)
.create();
let client = TestClient::new(server.url());
let outcome = Runtime::new()
.unwrap()
.block_on(upload_snapshot_from_declarations_file(&decl_path, client))
.expect("pipeline returned None");
let summary = SnapshotSummary::from_entries(&outcome.entries, outcome.manifest_uploaded);
assert!(summary.all_uploaded(), "expected all uploads to succeed");
// repo patch + outside file + manifest = 3 uploaded entries total; the inside-repo file
// entry was filtered before gather so it never hits the entries list.
assert_eq!(summary.uploaded, 3);
assert_eq!(summary.total, 3);
assert!(outcome.manifest_uploaded);
patch_mock.assert();
file_mock.assert();
manifest_mock.assert();
}

Some files were not shown because too many files have changed in this diff Show More