From e8d636ad5c9e82fe2d9f06dad67e42dd35ed8c6f Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:59:54 +0000 Subject: [PATCH 1/2] [REV-2383] Scope oz run list by team Resolve TeamSelection once for run listing and carry its request scope through table, NDJSON, and raw JSON requests while leaving existing UI caches unscoped. Co-Authored-By: Warp Agent --- app/src/ai/agent_conversations_model.rs | 22 ++++- app/src/ai/agent_sdk/ambient.rs | 68 ++++++++++++--- app/src/ai/agent_sdk/ambient_tests.rs | 87 +++++++++++++++++++ app/src/ai/agent_sdk/mod.rs | 2 +- .../blocklist/orchestration_event_streamer.rs | 2 +- app/src/pane_group/child_agent/restoration.rs | 2 +- app/src/server/server_api/ai.rs | 53 ++++++++++- crates/warp_cli/src/task.rs | 5 +- crates/warp_cli/src/task_tests.rs | 8 ++ 9 files changed, 229 insertions(+), 20 deletions(-) diff --git a/app/src/ai/agent_conversations_model.rs b/app/src/ai/agent_conversations_model.rs index aac762b52bc..91e4ac96a72 100644 --- a/app/src/ai/agent_conversations_model.rs +++ b/app/src/ai/agent_conversations_model.rs @@ -920,6 +920,9 @@ impl AgentConversationsModel { updated_after: Some(updated_after), ..Default::default() }, + // TODO: Multi-team UI needs a team component in the cache or equivalent + // scope-aware filtering before this request can supply team scope. + None, ) .await } @@ -992,6 +995,9 @@ impl AgentConversationsModel { creator_uid: Some(creator_uid), ..Default::default() }, + // TODO: Multi-team UI needs a team component in the cache or equivalent + // scope-aware filtering before this request can supply team scope. + None, ); let conversation_metadata_future = ai_client.list_ai_conversation_metadata(None); @@ -1221,7 +1227,13 @@ impl AgentConversationsModel { let ai_client = ai_client.clone(); async move { ai_client - .list_ambient_agent_tasks(100, TaskListFilter::default()) + .list_ambient_agent_tasks( + 100, + TaskListFilter::default(), + // TODO: Multi-team UI needs a team component in the cache or equivalent + // scope-aware filtering before this request can supply team scope. + None, + ) .await } }, @@ -2035,7 +2047,13 @@ impl AgentConversationsModel { let task_filter = task_filter.clone(); async move { ai_client - .list_ambient_agent_tasks(INITIAL_TASK_AMOUNT, task_filter) + .list_ambient_agent_tasks( + INITIAL_TASK_AMOUNT, + task_filter, + // TODO: Multi-team UI needs a team component in the cache or equivalent + // scope-aware filtering before this request can supply team scope. + None, + ) .await } }, diff --git a/app/src/ai/agent_sdk/ambient.rs b/app/src/ai/agent_sdk/ambient.rs index 44b48a8ce26..0fc302b395d 100644 --- a/app/src/ai/agent_sdk/ambient.rs +++ b/app/src/ai/agent_sdk/ambient.rs @@ -46,6 +46,7 @@ use crate::server::server_api::ai::{ ListAgentMessagesRequest, ReadAgentMessageResponse, RunSortBy, RunSortOrder, SendAgentMessageRequest, SendAgentMessageResponse, SpawnAgentRequest, TaskListFilter, }; +use crate::server::team_scope::RequestTeamScope; use crate::terminal::shared_session; use crate::util::time_format::format_approx_duration_from_now_utc; use crate::workspaces::user_workspaces::UserWorkspaces; @@ -74,11 +75,20 @@ pub fn list_ambient_agent_tasks( args: ListTasksArgs, ) -> anyhow::Result<()> { let runner = ctx.add_singleton_model(|_ctx| AmbientAgentRunner); + let cli_scope = UserWorkspaces::as_ref(ctx).team_scope_for_cli(&args.team_selection)?; + let request_team_scope = RequestTeamScope::from_scope(&cli_scope); let filter = filter_from_args(&args); let json_output = args.json_output.clone(); let output_format = global_options.output_format; runner.update(ctx, |runner, ctx| { - runner.list_tasks(args.limit, filter, output_format, json_output, ctx) + runner.list_tasks( + args.limit, + filter, + request_team_scope, + output_format, + json_output, + ctx, + ) }) } @@ -195,6 +205,31 @@ fn sort_order_from_arg(arg: SortOrderArg) -> RunSortOrder { } } +enum ListTasksOutput { + Raw(serde_json::Value), + Tasks(Vec), +} + +async fn load_tasks_for_output( + ai_client: &dyn AIClient, + limit: i32, + filter: TaskListFilter, + request_team_scope: RequestTeamScope, + output_format: OutputFormat, + json_output: &JsonOutput, +) -> anyhow::Result { + if matches!(output_format, OutputFormat::Json) || json_output.force_json_output() { + let response = ai_client + .list_agent_runs_raw(limit, filter, Some(request_team_scope)) + .await?; + Ok(ListTasksOutput::Raw(response)) + } else { + let tasks = ai_client + .list_ambient_agent_tasks(limit, filter, Some(request_team_scope)) + .await?; + Ok(ListTasksOutput::Tasks(tasks)) + } +} /// Run a message-related CLI command. pub fn run_message( ctx: &mut AppContext, @@ -642,6 +677,7 @@ impl AmbientAgentRunner { &self, limit: i32, filter: TaskListFilter, + request_team_scope: RequestTeamScope, output_format: OutputFormat, json_output: JsonOutput, ctx: &mut ModelContext, @@ -649,17 +685,27 @@ impl AmbientAgentRunner { let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client(); let list_future = async move { - if matches!(output_format, OutputFormat::Json) || json_output.force_json_output() { - let response = ai_client.list_agent_runs_raw(limit, filter).await?; - super::output::print_raw_json(response, &json_output)?; - } else if matches!(output_format, OutputFormat::Ndjson) { - let tasks = ai_client.list_ambient_agent_tasks(limit, filter).await?; - for task in tasks { - super::output::write_json_line(&task, std::io::stdout())?; + match load_tasks_for_output( + ai_client.as_ref(), + limit, + filter, + request_team_scope, + output_format, + &json_output, + ) + .await? + { + ListTasksOutput::Raw(response) => { + super::output::print_raw_json(response, &json_output)?; + } + ListTasksOutput::Tasks(tasks) if matches!(output_format, OutputFormat::Ndjson) => { + for task in tasks { + super::output::write_json_line(&task, std::io::stdout())?; + } + } + ListTasksOutput::Tasks(tasks) => { + Self::print_tasks_table(&tasks); } - } else { - let tasks = ai_client.list_ambient_agent_tasks(limit, filter).await?; - Self::print_tasks_table(&tasks); } Ok(()) }; diff --git a/app/src/ai/agent_sdk/ambient_tests.rs b/app/src/ai/agent_sdk/ambient_tests.rs index 5f864b9fab1..0c6a5e46732 100644 --- a/app/src/ai/agent_sdk/ambient_tests.rs +++ b/app/src/ai/agent_sdk/ambient_tests.rs @@ -3,6 +3,7 @@ use bytes::Bytes; use chrono::{TimeZone, Utc}; use warp_cli::SortOrderArg; use warp_cli::json_filter::JsonOutput; +use warp_cli::scope::TeamSelection; use warp_cli::task::{ ArtifactTypeArg, ExecutionLocationArg, ListTasksArgs, RunSortByArg, RunSourceArg, RunStateArg, }; @@ -12,6 +13,8 @@ use super::*; use crate::server::server_api::ai::{ ArtifactType, ExecutionLocation, MockAIClient, RunSortBy, RunSortOrder, }; +use crate::server::team_scope::RequestTeamScope; +use crate::workspaces::user_workspaces::TeamlessScopeForTest; const TASK_ID: &str = "00000000-0000-0000-0000-000000000001"; const OTHER_TASK_ID: &str = "00000000-0000-0000-0000-000000000002"; @@ -19,6 +22,7 @@ const OTHER_TASK_ID: &str = "00000000-0000-0000-0000-000000000002"; /// A `ListTasksArgs` whose fields are all at their defaults. fn empty_args() -> ListTasksArgs { ListTasksArgs { + team_selection: TeamSelection { team: None }, limit: 10, state: vec![], source: None, @@ -42,6 +46,10 @@ fn empty_args() -> ListTasksArgs { } } +fn request_team_scope() -> RequestTeamScope { + RequestTeamScope::from_scope(&TeamlessScopeForTest) +} + #[test] fn empty_args_yields_default_filter() { let filter = filter_from_args(&empty_args()); @@ -125,6 +133,7 @@ fn every_field_maps_through() { let updated_after = Utc.with_ymd_and_hms(2026, 4, 3, 12, 30, 0).unwrap(); let args = ListTasksArgs { + team_selection: TeamSelection { team: None }, limit: 20, state: vec![RunStateArg::InProgress], source: Some(RunSourceArg::Api), @@ -172,6 +181,84 @@ fn every_field_maps_through() { assert_eq!(filter.cursor.as_deref(), Some("abcd==")); } +#[tokio::test] +async fn table_run_listing_supplies_cli_scope() { + let mut mock = MockAIClient::new(); + mock.expect_list_ambient_agent_tasks() + .withf(|limit, filter, request_team_scope| { + *limit == 10 + && filter.creator_uid.is_none() + && filter.states.is_none() + && request_team_scope.is_some() + }) + .times(1) + .returning(|_, _, _| Ok(Vec::new())); + mock.expect_list_agent_runs_raw().times(0); + + load_tasks_for_output( + &mock, + 10, + TaskListFilter::default(), + request_team_scope(), + OutputFormat::Text, + &JsonOutput::default(), + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn ndjson_run_listing_supplies_cli_scope() { + let mut mock = MockAIClient::new(); + mock.expect_list_ambient_agent_tasks() + .withf(|limit, filter, request_team_scope| { + *limit == 10 + && filter.creator_uid.is_none() + && filter.states.is_none() + && request_team_scope.is_some() + }) + .times(1) + .returning(|_, _, _| Ok(Vec::new())); + mock.expect_list_agent_runs_raw().times(0); + + load_tasks_for_output( + &mock, + 10, + TaskListFilter::default(), + request_team_scope(), + OutputFormat::Ndjson, + &JsonOutput::default(), + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn raw_json_run_listing_supplies_cli_scope() { + let mut mock = MockAIClient::new(); + mock.expect_list_agent_runs_raw() + .withf(|limit, filter, request_team_scope| { + *limit == 10 + && filter.creator_uid.is_none() + && filter.states.is_none() + && request_team_scope.is_some() + }) + .times(1) + .returning(|_, _, _| Ok(serde_json::json!({ "runs": [] }))); + mock.expect_list_ambient_agent_tasks().times(0); + + load_tasks_for_output( + &mock, + 10, + TaskListFilter::default(), + request_team_scope(), + OutputFormat::Json, + &JsonOutput::default(), + ) + .await + .unwrap(); +} + #[test] fn task_id_from_run_id_accepts_task_uuid() { let task_id = task_id_from_run_id(TASK_ID).expect("valid task id"); diff --git a/app/src/ai/agent_sdk/mod.rs b/app/src/ai/agent_sdk/mod.rs index eeadd8dff33..ded67d28f91 100644 --- a/app/src/ai/agent_sdk/mod.rs +++ b/app/src/ai/agent_sdk/mod.rs @@ -585,7 +585,7 @@ fn run_task( command: TaskCommand, ) -> anyhow::Result<()> { match command { - TaskCommand::List(args) => ambient::list_ambient_agent_tasks(ctx, global_options, args), + TaskCommand::List(args) => ambient::list_ambient_agent_tasks(ctx, global_options, *args), TaskCommand::Get(args) => { if args.conversation { if !FeatureFlag::ConversationApi.is_enabled() { diff --git a/app/src/ai/blocklist/orchestration_event_streamer.rs b/app/src/ai/blocklist/orchestration_event_streamer.rs index efc55e85e1d..a53b6d0024d 100644 --- a/app/src/ai/blocklist/orchestration_event_streamer.rs +++ b/app/src/ai/blocklist/orchestration_event_streamer.rs @@ -1358,7 +1358,7 @@ impl OrchestrationEventStreamer { ctx.spawn( async move { ai_client - .list_ambient_agent_tasks(VIEWER_MODE_SEED_FETCH_LIMIT, filter) + .list_ambient_agent_tasks(VIEWER_MODE_SEED_FETCH_LIMIT, filter, None) .await }, move |me, result, ctx| { diff --git a/app/src/pane_group/child_agent/restoration.rs b/app/src/pane_group/child_agent/restoration.rs index fc08a930dca..00f58ac53ea 100644 --- a/app/src/pane_group/child_agent/restoration.rs +++ b/app/src/pane_group/child_agent/restoration.rs @@ -204,7 +204,7 @@ impl PaneGroup { ctx.spawn( async move { ai_client - .list_ambient_agent_tasks(RESTORE_CHILD_SEED_FETCH_LIMIT, filter) + .list_ambient_agent_tasks(RESTORE_CHILD_SEED_FETCH_LIMIT, filter, None) .await }, move |me, result, ctx| { diff --git a/app/src/server/server_api/ai.rs b/app/src/server/server_api/ai.rs index 31eace9b15d..5e984179154 100644 --- a/app/src/server/server_api/ai.rs +++ b/app/src/server/server_api/ai.rs @@ -7,7 +7,7 @@ use ai::index::full_source_code_embedding::store_client::{IntermediateNode, Stor use ai::index::full_source_code_embedding::{ self, CodebaseContextConfig, ContentHash, EmbeddingConfig, NodeHash, RepoMetadata, }; -use anyhow::anyhow; +use anyhow::{Context as _, anyhow}; use async_trait::async_trait; use base64::Engine; use bytes::Bytes; @@ -121,6 +121,7 @@ use warp_graphql::queries::task_git_credentials::{ TaskGitCredentialsLegacyVariables, TaskGitCredentialsResult, TaskGitCredentialsVariables, }; use warp_multi_agent_api::ConversationData; +use warp_server_client::base_client::TEAM_UID_HEADER; use super::ServerApi; #[cfg(not(target_family = "wasm"))] @@ -160,6 +161,7 @@ use crate::ai_assistant::{AIGeneratedCommand, GenerateCommandsFromNaturalLanguag use crate::drive::workflows::ai_assist::{GeneratedCommandMetadata, GeneratedCommandMetadataError}; use crate::persistence::model::ConversationUsageMetadata; use crate::server::graphql::{get_request_context, get_user_facing_error_message}; +use crate::server::team_scope::RequestTeamScope; use crate::terminal::model::block::SerializedBlock; #[cfg(not(feature = "agent_mode_evals"))] use crate::{ @@ -1310,6 +1312,7 @@ pub trait AIClient: 'static + Send + Sync { &self, limit: i32, filter: TaskListFilter, + request_team_scope: Option, ) -> anyhow::Result, anyhow::Error>; /// List agent runs and return the raw server JSON response. @@ -1317,6 +1320,7 @@ pub trait AIClient: 'static + Send + Sync { &self, limit: i32, filter: TaskListFilter, + request_team_scope: Option, ) -> anyhow::Result; async fn get_ambient_agent_task( @@ -1625,6 +1629,43 @@ fn into_file_artifact_record( } impl ServerApi { + async fn get_public_api_with_team_scope( + &self, + path: &str, + request_team_scope: Option, + ) -> anyhow::Result + where + R: serde::de::DeserializeOwned, + { + let auth_token = self + .get_or_refresh_access_token() + .await + .context("Failed to get access token for API request")?; + let url = format!("{}/api/v1/{path}", ChannelState::server_root_url()); + let mut request = self.base_client.http_client().get(&url); + if let Some(token) = auth_token.as_bearer_token() { + request = request.bearer_auth(token); + } + for (name, value) in self.ambient_agent_headers().await? { + request = request.header(name, value); + } + if let Some(team_uid) = request_team_scope.and_then(RequestTeamScope::team_uid) { + request = request.header(TEAM_UID_HEADER, team_uid.uid()); + } + let response = request + .send() + .await + .with_context(|| format!("Failed to send API request to {url}"))?; + if !response.status().is_success() { + self.observe_iap_challenge(&response); + return Err(Self::error_from_response(response).await); + } + response + .json::() + .await + .with_context(|| format!("Failed to deserialize response from {url}")) + } + pub(crate) async fn send_agent_message_for_task( &self, task_id: &AmbientAgentTaskId, @@ -2410,9 +2451,12 @@ impl AIClient for ServerApi { &self, limit: i32, filter: TaskListFilter, + request_team_scope: Option, ) -> anyhow::Result, anyhow::Error> { let url = build_list_agent_runs_url(limit, &filter); - let response: ListRunsResponse = self.get_public_api(&url).await?; + let response: ListRunsResponse = self + .get_public_api_with_team_scope(&url, request_team_scope) + .await?; Ok(response.runs) } @@ -2420,9 +2464,12 @@ impl AIClient for ServerApi { &self, limit: i32, filter: TaskListFilter, + request_team_scope: Option, ) -> anyhow::Result { let url = build_list_agent_runs_url(limit, &filter); - let response: serde_json::Value = self.get_public_api(&url).await?; + let response: serde_json::Value = self + .get_public_api_with_team_scope(&url, request_team_scope) + .await?; Ok(response) } diff --git a/crates/warp_cli/src/task.rs b/crates/warp_cli/src/task.rs index 0d6af3f87f7..8b0c275bd2a 100644 --- a/crates/warp_cli/src/task.rs +++ b/crates/warp_cli/src/task.rs @@ -4,12 +4,13 @@ use clap::{Args, Subcommand, ValueEnum}; use crate::SortOrderArg; use crate::date_time::parse_rfc3339; use crate::json_filter::JsonOutput; +use crate::scope::TeamSelection; /// Task-related subcommands. #[derive(Debug, Clone, Subcommand)] pub enum TaskCommand { /// List ambient agent tasks. - List(ListTasksArgs), + List(Box), /// Get status of a specific ambient agent task. Get(TaskGetArgs), /// Retrieve the conversation for a specific run or conversation. @@ -130,6 +131,8 @@ pub struct MessageDeliveredArgs { #[derive(Debug, Clone, Args)] pub struct ListTasksArgs { + #[command(flatten)] + pub team_selection: TeamSelection, /// Maximum number of tasks to return (default: 10). #[arg(short = 'L', long = "limit", default_value = "10")] pub limit: i32, diff --git a/crates/warp_cli/src/task_tests.rs b/crates/warp_cli/src/task_tests.rs index a8ec05e7cb9..49b2b9c2065 100644 --- a/crates/warp_cli/src/task_tests.rs +++ b/crates/warp_cli/src/task_tests.rs @@ -27,6 +27,7 @@ fn parse_err(argv: &[&str]) -> clap::Error { #[test] fn defaults_match_pre_change_behavior() { let TestApp { args } = parse(&[]); + assert!(args.team_selection.team.is_none()); assert_eq!(args.limit, 10); assert!(args.state.is_empty()); assert!(args.source.is_none()); @@ -48,6 +49,13 @@ fn defaults_match_pre_change_behavior() { assert!(args.cursor.is_none()); } +#[test] +fn team_selection_parses_for_run_listing() { + let TestApp { args } = parse(&["--team=team-uid"]); + + assert_eq!(args.team_selection.requested_team_uid(), Some("team-uid")); +} + #[test] fn state_flag_is_repeatable() { let TestApp { args } = parse(&["--state", "failed", "--state", "error"]); From 969b6af0f3828387646423a488467920eba73242 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:40:41 +0000 Subject: [PATCH 2/2] [REV-2383] Refresh team scope before listing runs --- app/src/ai/agent_sdk/ambient.rs | 79 +++++++++------ app/src/ai/agent_sdk/ambient_tests.rs | 132 +++++++++++++++++++++++++- app/src/server/server_api/ai.rs | 14 ++- app/src/server/server_api/ai_tests.rs | 41 +++++++- 4 files changed, 231 insertions(+), 35 deletions(-) diff --git a/app/src/ai/agent_sdk/ambient.rs b/app/src/ai/agent_sdk/ambient.rs index 0fc302b395d..5de0bcb608c 100644 --- a/app/src/ai/agent_sdk/ambient.rs +++ b/app/src/ai/agent_sdk/ambient.rs @@ -75,16 +75,14 @@ pub fn list_ambient_agent_tasks( args: ListTasksArgs, ) -> anyhow::Result<()> { let runner = ctx.add_singleton_model(|_ctx| AmbientAgentRunner); - let cli_scope = UserWorkspaces::as_ref(ctx).team_scope_for_cli(&args.team_selection)?; - let request_team_scope = RequestTeamScope::from_scope(&cli_scope); let filter = filter_from_args(&args); let json_output = args.json_output.clone(); let output_format = global_options.output_format; runner.update(ctx, |runner, ctx| { runner.list_tasks( + args.team_selection, args.limit, filter, - request_team_scope, output_format, json_output, ctx, @@ -205,6 +203,14 @@ fn sort_order_from_arg(arg: SortOrderArg) -> RunSortOrder { } } +fn request_team_scope_for_cli( + team_selection: &warp_cli::scope::TeamSelection, + ctx: &AppContext, +) -> anyhow::Result { + let cli_scope = UserWorkspaces::as_ref(ctx).team_scope_for_cli(team_selection)?; + Ok(RequestTeamScope::from_scope(&cli_scope)) +} + enum ListTasksOutput { Raw(serde_json::Value), Tasks(Vec), @@ -675,41 +681,56 @@ impl AmbientAgentRunner { fn list_tasks( &self, + team_selection: warp_cli::scope::TeamSelection, limit: i32, filter: TaskListFilter, - request_team_scope: RequestTeamScope, output_format: OutputFormat, json_output: JsonOutput, ctx: &mut ModelContext, ) -> anyhow::Result<()> { - let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client(); - - let list_future = async move { - match load_tasks_for_output( - ai_client.as_ref(), - limit, - filter, - request_team_scope, - output_format, - &json_output, - ) - .await? - { - ListTasksOutput::Raw(response) => { - super::output::print_raw_json(response, &json_output)?; + let refresh_future = super::common::refresh_workspace_metadata(ctx); + ctx.spawn(refresh_future, move |runner, refresh_result, ctx| { + if let Err(err) = refresh_result { + super::report_fatal_error(err, ctx); + return; + } + let request_team_scope = match request_team_scope_for_cli(&team_selection, ctx) { + Ok(scope) => scope, + Err(err) => { + super::report_fatal_error(err, ctx); + return; } - ListTasksOutput::Tasks(tasks) if matches!(output_format, OutputFormat::Ndjson) => { - for task in tasks { - super::output::write_json_line(&task, std::io::stdout())?; + }; + let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client(); + let list_future = async move { + match load_tasks_for_output( + ai_client.as_ref(), + limit, + filter, + request_team_scope, + output_format, + &json_output, + ) + .await? + { + ListTasksOutput::Raw(response) => { + super::output::print_raw_json(response, &json_output)?; + } + ListTasksOutput::Tasks(tasks) + if matches!(output_format, OutputFormat::Ndjson) => + { + for task in tasks { + super::output::write_json_line(&task, std::io::stdout())?; + } + } + ListTasksOutput::Tasks(tasks) => { + Self::print_tasks_table(&tasks); } } - ListTasksOutput::Tasks(tasks) => { - Self::print_tasks_table(&tasks); - } - } - Ok(()) - }; - self.spawn_command(list_future, ctx); + Ok(()) + }; + runner.spawn_command(list_future, ctx); + }); Ok(()) } diff --git a/app/src/ai/agent_sdk/ambient_tests.rs b/app/src/ai/agent_sdk/ambient_tests.rs index 0c6a5e46732..24d43032f7b 100644 --- a/app/src/ai/agent_sdk/ambient_tests.rs +++ b/app/src/ai/agent_sdk/ambient_tests.rs @@ -1,20 +1,36 @@ //! Unit tests for ambient agent CLI argument mapping and message helpers. +use std::sync::Arc; + use bytes::Bytes; use chrono::{TimeZone, Utc}; use warp_cli::SortOrderArg; -use warp_cli::json_filter::JsonOutput; +use warp_cli::json_filter::{JsonOutput, parse_jq_filter}; use warp_cli::scope::TeamSelection; use warp_cli::task::{ ArtifactTypeArg, ExecutionLocationArg, ListTasksArgs, RunSortByArg, RunSourceArg, RunStateArg, }; use warp_server_client::HttpStatusError; +use warpui::App; use super::*; +use crate::auth::AuthStateProvider; +use crate::network::NetworkStatus; +use crate::server::ids::ServerId; use crate::server::server_api::ai::{ ArtifactType, ExecutionLocation, MockAIClient, RunSortBy, RunSortOrder, }; +use crate::server::server_api::team::{MockTeamClient, TeamClient}; +use crate::server::server_api::workspace::MockWorkspaceClient; use crate::server::team_scope::RequestTeamScope; -use crate::workspaces::user_workspaces::TeamlessScopeForTest; +use crate::settings::PrivacySettings; +use crate::workspaces::team::Team; +use crate::workspaces::team_tester::TeamTesterStatus; +use crate::workspaces::update_manager::TeamUpdateManager; +use crate::workspaces::user_workspaces::{ + TeamContextForOperation, TeamlessScopeForTest, UserWorkspaces, WorkspacesMetadataResponse, + WorkspacesMetadataWithPricing, +}; +use crate::workspaces::workspace::{Workspace, WorkspaceUid}; const TASK_ID: &str = "00000000-0000-0000-0000-000000000001"; const OTHER_TASK_ID: &str = "00000000-0000-0000-0000-000000000002"; @@ -50,6 +66,10 @@ fn request_team_scope() -> RequestTeamScope { RequestTeamScope::from_scope(&TeamlessScopeForTest) } +fn request_scope_for_team(team_uid: ServerId) -> RequestTeamScope { + RequestTeamScope::from_scope(&TeamContextForOperation::new_for_test(team_uid)) +} + #[test] fn empty_args_yields_default_filter() { let filter = filter_from_args(&empty_args()); @@ -259,6 +279,114 @@ async fn raw_json_run_listing_supplies_cli_scope() { .unwrap(); } +#[tokio::test] +async fn jq_run_listing_supplies_selected_cli_scope_to_raw_request() { + let team_uid = ServerId::from(123); + let mut mock = MockAIClient::new(); + mock.expect_list_agent_runs_raw() + .withf(move |limit, filter, request_team_scope| { + *limit == 10 + && filter.creator_uid.is_none() + && filter.states.is_none() + && request_team_scope.and_then(RequestTeamScope::team_uid) == Some(team_uid) + }) + .times(1) + .returning(|_, _, _| Ok(serde_json::json!({ "runs": [] }))); + mock.expect_list_ambient_agent_tasks().times(0); + let json_output = JsonOutput { + filter: Some(parse_jq_filter(".runs").expect("jq filter compiles")), + }; + + load_tasks_for_output( + &mock, + 10, + TaskListFilter::default(), + request_scope_for_team(team_uid), + OutputFormat::Text, + &json_output, + ) + .await + .unwrap(); +} + +#[test] +fn run_list_scope_uses_team_loaded_by_workspace_refresh() { + App::test((), |mut app| async move { + let team_uid = ServerId::from(123); + let workspace_uid = WorkspaceUid::from(ServerId::from(456)); + let team = Team::from_local_cache( + team_uid, + "Selected Team".to_string(), + None, + None, + None, + None, + ); + let workspace = Workspace::from_local_cache( + workspace_uid, + "Selected Workspace".to_string(), + Some(vec![team]), + None, + ); + let mut team_client = MockTeamClient::new(); + team_client + .expect_workspaces_metadata() + .times(1) + .return_once(move || { + Ok(WorkspacesMetadataWithPricing { + metadata: WorkspacesMetadataResponse { + workspaces: vec![workspace], + joinable_teams: vec![], + experiments: None, + ai_credit_availability: None, + user_purchase_policy: None, + }, + pricing_info: None, + }) + }); + let team_client: Arc = Arc::new(team_client); + + app.add_singleton_model(|_| NetworkStatus::new()); + app.add_singleton_model(TeamTesterStatus::new); + app.add_singleton_model(PrivacySettings::mock); + app.add_singleton_model(|_| AuthStateProvider::new_for_test()); + app.add_singleton_model({ + let team_client = team_client.clone(); + move |ctx| { + UserWorkspaces::mock( + team_client, + Arc::new(MockWorkspaceClient::new()), + vec![], + ctx, + ) + } + }); + app.add_singleton_model(move |ctx| TeamUpdateManager::new(team_client, None, ctx)); + + assert!( + app.read(|ctx| UserWorkspaces::as_ref(ctx) + .team_from_uid(team_uid) + .is_none()), + "the selected team should not exist in the initial cache" + ); + + app.update(crate::ai::agent_sdk::common::refresh_workspace_metadata) + .await + .expect("workspace refresh succeeds"); + + app.read(|ctx| { + let scope = request_team_scope_for_cli( + &TeamSelection { + team: Some(Some(team_uid.to_string())), + }, + ctx, + ) + .expect("the selected team resolves from refreshed metadata"); + assert_eq!(scope.team_uid(), Some(team_uid)); + }); + }); +} + #[test] fn task_id_from_run_id_accepts_task_uuid() { let task_id = task_id_from_run_id(TASK_ID).expect("valid task id"); diff --git a/app/src/server/server_api/ai.rs b/app/src/server/server_api/ai.rs index 5e984179154..64dc46c55ae 100644 --- a/app/src/server/server_api/ai.rs +++ b/app/src/server/server_api/ai.rs @@ -1628,6 +1628,16 @@ fn into_file_artifact_record( } } +fn with_request_team_scope( + mut request: http_client::RequestBuilder<'_>, + request_team_scope: Option, +) -> http_client::RequestBuilder<'_> { + if let Some(team_uid) = request_team_scope.and_then(RequestTeamScope::team_uid) { + request = request.header(TEAM_UID_HEADER, team_uid.uid()); + } + request +} + impl ServerApi { async fn get_public_api_with_team_scope( &self, @@ -1649,9 +1659,7 @@ impl ServerApi { for (name, value) in self.ambient_agent_headers().await? { request = request.header(name, value); } - if let Some(team_uid) = request_team_scope.and_then(RequestTeamScope::team_uid) { - request = request.header(TEAM_UID_HEADER, team_uid.uid()); - } + let request = with_request_team_scope(request, request_team_scope); let response = request .send() .await diff --git a/app/src/server/server_api/ai_tests.rs b/app/src/server/server_api/ai_tests.rs index 554d995f73a..f857ea98203 100644 --- a/app/src/server/server_api/ai_tests.rs +++ b/app/src/server/server_api/ai_tests.rs @@ -2,7 +2,7 @@ use chrono::{TimeZone, Utc}; use futures::executor::block_on; use itertools::Itertools; use mockito::{Matcher, Server}; -use warp_server_client::base_client::CLOUD_AGENT_ID_HEADER; +use warp_server_client::base_client::{CLOUD_AGENT_ID_HEADER, TEAM_UID_HEADER}; use super::super::ServerApi; use super::{ @@ -13,9 +13,13 @@ use super::{ ReadAgentMessageResponse, RunFollowupRequest, RunSortBy, RunSortOrder, SpawnAgentRequest, TaskListFilter, UploadFieldValue, UserQueryMode, build_fork_conversation_url, build_list_agent_runs_url, build_run_followup_url, is_unknown_git_credential_schema_error, + with_request_team_scope, }; use crate::notebooks::NotebookId; +use crate::server::ids::ServerId; use crate::server::server_api::presigned_upload::upload_to_target; +use crate::server::team_scope::RequestTeamScope; +use crate::workspaces::user_workspaces::{TeamContextForOperation, TeamlessScopeForTest}; #[test] fn ambient_agent_headers_for_task_overrides_existing_cloud_agent_header() { @@ -41,6 +45,41 @@ fn ambient_agent_headers_for_task_overrides_existing_cloud_agent_header() { ); } +#[test] +fn list_agent_runs_sends_selected_team_header() { + let team_uid = ServerId::from(123); + let scope = RequestTeamScope::from_scope(&TeamContextForOperation::new_for_test(team_uid)); + let mut server = Server::new(); + let request = server + .mock("GET", "/agent/runs") + .match_header(TEAM_UID_HEADER, team_uid.to_string().as_str()) + .with_status(200) + .create(); + let client = http_client::Client::new_for_test(); + let request_builder = client.get(format!("{}/agent/runs", server.url())); + + block_on(with_request_team_scope(request_builder, Some(scope)).send()).unwrap(); + + request.assert(); +} + +#[test] +fn list_agent_runs_omits_team_header_for_teamless_scope() { + let scope = RequestTeamScope::from_scope(&TeamlessScopeForTest); + let mut server = Server::new(); + let request = server + .mock("GET", "/agent/runs") + .match_header(TEAM_UID_HEADER, Matcher::Missing) + .with_status(200) + .create(); + let client = http_client::Client::new_for_test(); + let request_builder = client.get(format!("{}/agent/runs", server.url())); + + block_on(with_request_team_scope(request_builder, Some(scope)).send()).unwrap(); + + request.assert(); +} + #[test] fn spawn_agent_request_serializes_agent_uid_as_agent_identity_uid() { let request = SpawnAgentRequest {