From 70a551a8b927deebfa3fda8db15ce4d7b7897484 Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Wed, 27 May 2026 09:03:40 +0300 Subject: [PATCH 1/5] commit initial QF model --- .../src/uipath/_cli/_utils/_project_files.py | 33 ++++- packages/uipath/src/uipath/_cli/cli_pull.py | 15 ++- .../uipath/src/uipath/agent/models/agent.py | 125 ++++++++++++++++++ packages/uipath/tests/cli/test_pull.py | 27 ++++ 4 files changed, 193 insertions(+), 7 deletions(-) diff --git a/packages/uipath/src/uipath/_cli/_utils/_project_files.py b/packages/uipath/src/uipath/_cli/_utils/_project_files.py index c7c025197..64395b802 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_project_files.py +++ b/packages/uipath/src/uipath/_cli/_utils/_project_files.py @@ -5,7 +5,7 @@ import re import tomllib from enum import IntEnum -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Any, AsyncIterator, Dict, Literal, Optional, Tuple from pydantic import BaseModel, Field, TypeAdapter @@ -19,7 +19,6 @@ ProjectFile, ProjectFolder, StudioClient, - get_folder_by_name, ) logger = logging.getLogger(__name__) @@ -548,6 +547,34 @@ def collect_files_from_folder( collect_files_from_folder(subfolder, subfolder_path, files_dict) +def _get_folder_by_path( + structure: ProjectFolder, folder_path: str | None +) -> Optional[ProjectFolder]: + if not folder_path: + return structure + + normalized_path = folder_path.replace("\\", "/").strip("/") + if not normalized_path or normalized_path == ".": + return structure + + folder = structure + for part in PurePosixPath(normalized_path).parts: + if part in ("", "."): + continue + if part == "..": + return None + + child = next( + (subfolder for subfolder in folder.folders if subfolder.name == part), + None, + ) + if child is None: + return None + folder = child + + return folder + + async def pull_project( project_id: str, download_configuration: dict[str | None, Path], @@ -561,7 +588,7 @@ async def pull_project( try: structure = await studio_client.get_project_structure_async() for source_key, destination in download_configuration.items(): - source_folder = get_folder_by_name(structure, source_key) + source_folder = _get_folder_by_path(structure, source_key) if source_folder: async for update in download_folder_files( studio_client, source_folder, destination diff --git a/packages/uipath/src/uipath/_cli/cli_pull.py b/packages/uipath/src/uipath/_cli/cli_pull.py index 1dcdd1b8b..7ac5c5247 100644 --- a/packages/uipath/src/uipath/_cli/cli_pull.py +++ b/packages/uipath/src/uipath/_cli/cli_pull.py @@ -24,8 +24,14 @@ @click.argument( "root", type=click.Path(exists=False, file_okay=False, dir_okay=True, path_type=Path), + required=False, default=Path("."), - metavar="", + metavar="[ROOT]", +) +@click.argument( + "source_path", + required=False, + metavar="[SOURCE_PATH]", ) @click.option( "--overwrite", @@ -33,7 +39,7 @@ help="Automatically overwrite local files without prompts", ) @track_command("pull") -def pull(root: Path, overwrite: bool) -> None: +def pull(root: Path, source_path: str | None, overwrite: bool) -> None: """Pull remote project files from Studio Web. This command pulls the remote project files from a UiPath Studio Web project. @@ -45,6 +51,7 @@ def pull(root: Path, overwrite: bool) -> None: $ uipath pull $ uipath pull /path/to/project + $ uipath pull /path/to/project studio-folder/subfolder $ uipath pull --overwrite """ project_id = UiPathConfig.project_id @@ -54,7 +61,7 @@ def pull(root: Path, overwrite: bool) -> None: studio_client = StudioClient(project_id=project_id) - result = Middlewares.next("pull", studio_client, root, overwrite) + result = Middlewares.next("pull", studio_client, root, overwrite, source_path) if result.error_message: console.error(result.error_message) return @@ -70,7 +77,7 @@ def pull(root: Path, overwrite: bool) -> None: return download_configuration: dict[str | None, Path] = { - None: root, + source_path: root, } console.log("Pulling UiPath project from Studio Web...") diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 72e7c438f..0b318c436 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -116,6 +116,7 @@ class AgentToolType(str, CaseInsensitiveEnum): INTEGRATION = "Integration" INTERNAL = "Internal" IXP = "Ixp" + CLIENT_SIDE = "clientSide" UNKNOWN = "Unknown" # fallback branch discriminator @@ -724,6 +725,13 @@ class AgentEscalationChannel(BaseCfg): ) priority: Optional[str] = None labels: List[str] = Field(default_factory=list) + # QuickForm fields — set only on channels backed by a .hitl.json schema. + # schema_id is the UUID key under which the schema is registered in + # Orchestrator's TaskSchemas table. schema is the inline schema body, + # sent on every task creation so Orchestrator can upsert it (the Agents + # runtime has no separate registration step). + schema_id: Optional[str] = Field(None, alias="schemaId") + schema: Optional[Dict[str, Any]] = Field(None, alias="schema") @model_validator(mode="before") @classmethod @@ -767,6 +775,62 @@ class AgentIxpVsEscalationResourceConfig(BaseAgentResourceConfig): ) +class AgentQuickFormEscalationResourceConfig(BaseAgentResourceConfig): + """Quick Form Agent escalation resource configuration model (escalationType=2). + + Quick Form escalations render a schema-first HITL task in Action Center + via FormLib. The schema (and its key) live on the channel (see + ``AgentEscalationChannel.schema_id`` / ``schema``) and are sent inline to + Orchestrator's ``GenericTasks/CreateTask`` endpoint. + """ + + id: Optional[str] = Field(None, alias="id") + resource_type: Literal[AgentResourceType.ESCALATION] = Field( + alias="$resourceType", default=AgentResourceType.ESCALATION, frozen=True + ) + channels: List[AgentEscalationChannel] = Field(alias="channels") + is_agent_memory_enabled: bool = Field(default=False, alias="isAgentMemoryEnabled") + escalation_type: Literal[2] = Field(default=2, alias="escalationType") + + @model_validator(mode="before") + @classmethod + def _hoist_quick_form_schema_to_channels(cls, v: Any) -> Any: + """Propagate ``quickFormProperties.schema`` onto each channel. + + Studio Web emits the FormLib schema once at the resource level under + ``quickFormProperties.schema`` (with its ``schemaId`` nested inside), + while the runtime tool factory reads ``schemaId`` / ``schema`` from + the channel. Hoist the values so per-channel access keeps working + without each channel having to repeat the payload. + """ + if not isinstance(v, dict): + return v + quick_form_properties = v.get("quickFormProperties") or v.get( + "quick_form_properties" + ) + if not isinstance(quick_form_properties, dict): + return v + schema_body = quick_form_properties.get("schema") + if not isinstance(schema_body, dict): + return v + schema_id = schema_body.get("schemaId") or schema_body.get("schema_id") + channels = v.get("channels") + if not isinstance(channels, list): + return v + for channel in channels: + if not isinstance(channel, dict): + continue + if ( + channel.get("schema") is None + and channel.get("schemaId") is None + and channel.get("schema_id") is None + ): + channel["schema"] = schema_body + if schema_id is not None: + channel["schemaId"] = schema_id + return v + + class BaseAgentToolResourceConfig(BaseAgentResourceConfig): """Base agent tool resource configuration model.""" @@ -943,6 +1007,41 @@ class AgentInternalToolResourceConfig(BaseAgentToolResourceConfig): ) +class AgentClientSideToolSettings(BaseCfg): + """Settings for a client-side tool.""" + + timeout: Optional[int] = Field(default=None) + + +class AgentClientSideToolProperties(BaseResourceProperties): + """Client-side tool properties model.""" + + +class AgentClientSideToolResourceConfig(BaseAgentToolResourceConfig): + """Client-side tool resource configuration model. + + Client-side tools are defined inline by the developer rather than backed + by an Orchestrator/Integration/Internal resource. The agent surfaces them + to the model but invocation is delegated to the calling client; the + runtime here only carries the schema/metadata round-trip. + """ + + id: Optional[str] = Field(None, alias="id") + type: Literal[AgentToolType.CLIENT_SIDE] = AgentToolType.CLIENT_SIDE + location: Literal["solution"] = "solution" + reference_key: Optional[str] = Field(default=None, alias="referenceKey") + output_schema: Dict[str, Any] = Field(EMPTY_SCHEMA, alias="outputSchema") + settings: AgentClientSideToolSettings = Field( + default_factory=AgentClientSideToolSettings + ) + properties: AgentClientSideToolProperties = Field( + default_factory=AgentClientSideToolProperties + ) + argument_properties: Dict[str, AgentToolArgumentProperties] = Field( + {}, alias="argumentProperties" + ) + + class AgentUnknownToolResourceConfig(BaseAgentToolResourceConfig): """Fallback for unknown tool types (parent normalizer sets type='Unknown').""" @@ -956,6 +1055,7 @@ class AgentUnknownToolResourceConfig(BaseAgentToolResourceConfig): AgentIntegrationToolResourceConfig, AgentInternalToolResourceConfig, AgentIxpExtractionResourceConfig, + AgentClientSideToolResourceConfig, AgentUnknownToolResourceConfig, # when parent sets type="Unknown" ], Field(discriminator="type"), @@ -965,6 +1065,7 @@ class AgentUnknownToolResourceConfig(BaseAgentToolResourceConfig): Union[ Annotated[AgentEscalationResourceConfig, Tag(0)], Annotated[AgentIxpVsEscalationResourceConfig, Tag(1)], + Annotated[AgentQuickFormEscalationResourceConfig, Tag(2)], ], Discriminator(lambda v: v.get("escalation_type") or v.get("escalationType") or 0), ] @@ -1229,6 +1330,27 @@ class AgentByomProperties(BaseCfg): connector_key: str = Field(alias="connectorKey") +class AgentVoiceSettings(BaseCfg): + """Agent voice settings for conversational low-code agents. + + Applies only when the agent supports voice in addition to text. When + absent, conversational agents fall back to defaults defined by the host + application. + """ + + model: str + max_tokens: int = Field(alias="maxTokens") + temperature: float + persona: str + + +class AgentMode(str, CaseInsensitiveEnum): + """Low-code agent authoring mode.""" + + STANDARD = "standard" + ADVANCED = "advanced" + + class AgentSettings(BaseCfg): """Agent settings model.""" @@ -1239,6 +1361,8 @@ class AgentSettings(BaseCfg): byom_properties: Optional[AgentByomProperties] = Field(None, alias="byomProperties") max_iterations: Optional[int] = Field(None, alias="maxIterations") persona: Optional[str] = Field(None, alias="persona") + mode: AgentMode = Field(default=AgentMode.STANDARD) + voice: Optional[AgentVoiceSettings] = Field(default=None) class AgentDefinition(BaseModel): @@ -1339,6 +1463,7 @@ def _normalize_resources(v: Dict[str, Any]) -> None: "integration": "Integration", "internal": "Internal", "ixp": "Ixp", + "clientside": "clientSide", "unknown": "Unknown", } CONTEXT_MODE_MAP = { diff --git a/packages/uipath/tests/cli/test_pull.py b/packages/uipath/tests/cli/test_pull.py index 7b3aebd1a..865878ed2 100644 --- a/packages/uipath/tests/cli/test_pull.py +++ b/packages/uipath/tests/cli/test_pull.py @@ -13,6 +13,7 @@ from uipath._cli import cli from uipath._cli._utils._common import may_override_files from uipath._cli._utils._studio_project import StudioProjectMetadata +from uipath._cli.middlewares import MiddlewareResult from uipath.platform.errors import EnrichedException @@ -224,6 +225,32 @@ def test_successful_pull( with open("evaluations/evaluators/test-evaluator.json", "r") as f: assert json.load(f) == test_evaluator_content + def test_pull_passes_source_path_to_middlewares( + self, + runner: CliRunner, + temp_dir: str, + mock_env_vars: dict[str, str], + ) -> None: + """Test pull accepts a remote source path and passes it to middleware.""" + project_id = "test-project-id" + source_path = "e243fcde-99b3-4495-aad2-b2c58aad8373" + + with runner.isolated_filesystem(temp_dir=temp_dir): + configure_env_vars(mock_env_vars) + os.environ["UIPATH_PROJECT_ID"] = project_id + + with patch( + "uipath._cli.cli_pull.Middlewares.next", + return_value=MiddlewareResult(should_continue=False), + ) as next_middleware: + result = runner.invoke(cli, ["pull", "./", source_path]) + + assert result.exit_code == 0 + next_middleware.assert_called_once() + _, args, _ = next_middleware.mock_calls[0] + assert args[0] == "pull" + assert args[4] == source_path + def test_pull_with_existing_files( self, runner: CliRunner, From 44070555ca453abcfdfc45363b04b21b18ab4509 Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Wed, 27 May 2026 16:47:11 +0300 Subject: [PATCH 2/5] feat(tasks): add create_quickform_async for QuickForm HITL tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds TasksService.create_quickform_async / create_quickform that POST to Orchestrator's GenericTasks/CreateTask endpoint with task type QuickFormTask, sending the FormLib schema (and its taskSchemaKey) inline so Orchestrator upserts it on every call — the Agents runtime owns no separate schema registration step. Mirrors create_async for the assignment hop: create returns the task, a follow-up assign request binds it to the recipient. Used by uipath-langchain's quick-form escalation tool to materialise escalationType=2 resources at runtime. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../platform/action_center/_tasks_service.py | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py b/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py index 662109ce4..5c60511d5 100644 --- a/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py +++ b/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py @@ -164,6 +164,68 @@ def _create_spec( ) +def _create_quickform_spec( + title: str, + task_schema_key: str, + schema: Optional[Dict[str, Any]], + data: Optional[Dict[str, Any]], + *, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + priority: Optional[str] = None, + labels: Optional[List[str]] = None, + is_actionable_message_enabled: Optional[bool] = None, + actionable_message_metadata: Optional[Dict[str, Any]] = None, + source_name: str = "Agent", +) -> RequestSpec: + json_payload: Dict[str, Any] = { + "type": "QuickFormTask", + "title": title, + "taskSchemaKey": task_schema_key, + "data": data if data is not None else {}, + } + if schema is not None: + json_payload["schema"] = schema + if priority and (normalized_priority := _normalize_priority(priority)): + json_payload["priority"] = normalized_priority + if labels is not None: + json_payload["tags"] = [ + { + "Name": label, + "DisplayName": label, + "Value": label, + "DisplayValue": label, + } + for label in labels + ] + if is_actionable_message_enabled is not None: + json_payload["isActionableMessageEnabled"] = is_actionable_message_enabled + if actionable_message_metadata is not None: + json_payload["actionableMessageMetaData"] = actionable_message_metadata + + project_id = UiPathConfig.project_id + trace_id = UiPathConfig.trace_id + if project_id and trace_id: + json_payload["taskSource"] = { + "sourceName": source_name, + "sourceId": project_id, + "jobId": UiPathConfig.job_key, + "taskSourceMetadata": { + "InstanceId": trace_id, + "FolderKey": UiPathConfig.folder_key, + "JobKey": UiPathConfig.job_key, + "ProcessKey": UiPathConfig.process_uuid, + }, + } + + return RequestSpec( + method="POST", + endpoint=Endpoint("/orchestrator_/tasks/GenericTasks/CreateTask"), + json=json_payload, + headers=header_folder(folder_key, folder_path), + ) + + def _normalize_priority(priority: str | None) -> str | None: """Normalize priority string to match API expectations. @@ -422,6 +484,81 @@ async def create_async( ) return Task.model_validate(json_response) + @traced(name="tasks_create_quickform", run_type="uipath") + async def create_quickform_async( + self, + title: str, + task_schema_key: str, + schema: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, Any]] = None, + *, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + recipient: Optional[TaskRecipient] = None, + priority: Optional[str] = None, + labels: Optional[List[str]] = None, + is_actionable_message_enabled: Optional[bool] = None, + actionable_message_metadata: Optional[Dict[str, Any]] = None, + source_name: str = "Agent", + ) -> Task: + """Create a QuickForm HITL task via Orchestrator's GenericTasks endpoint. + + The schema (and its ``task_schema_key``) is sent inline so Orchestrator + upserts it on every call — the Agents runtime owns no separate schema + registration step. Mirrors ``create_async`` for the assignment hop: + the create call returns the task, then a follow-up assign request + binds it to the resolved recipient. + + Args: + title: Title of the task as shown in Action Center. + task_schema_key: UUID under which the FormLib schema is stored. + schema: Optional inline FormLib schema (fields/outcomes) to upsert + against ``task_schema_key``. + data: Optional pre-filled form data. + folder_path: Optional folder path the task is created in. + folder_key: Optional folder key the task is created in. + recipient: Optional ``TaskRecipient`` to assign the task to. + priority: Optional priority (``Low``/``Medium``/``High``/``Critical``). + labels: Optional list of labels. + is_actionable_message_enabled: Toggle actionable notifications. + actionable_message_metadata: Custom actionable notification payload. + source_name: ``TaskSource`` ``sourceName`` value, default ``Agent``. + + Returns: + The created ``Task``. + """ + spec = _create_quickform_spec( + title=title, + task_schema_key=task_schema_key, + schema=schema, + data=data, + folder_path=folder_path, + folder_key=folder_key, + priority=priority, + labels=labels, + is_actionable_message_enabled=is_actionable_message_enabled, + actionable_message_metadata=actionable_message_metadata, + source_name=source_name, + ) + response = await self.request_async( + spec.method, + spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + json_response = response.json() + if recipient: + assign_spec = await _assign_task_spec( + self, json_response["id"], None, recipient + ) + await self.request_async( + assign_spec.method, + assign_spec.endpoint, + json=assign_spec.json, + content=assign_spec.content, + ) + return Task.model_validate(json_response) + @resource_override( resource_type="app", resource_identifier="app_name", From bf31c73cd3615852e4946e22da0a51e42de20b8e Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Thu, 28 May 2026 10:17:06 +0300 Subject: [PATCH 3/5] chore(agent): drop voice/mode/clientSide additions from QuickForm PR These rode along with the QuickForm escalation change but are unrelated to it. Strip them so this PR stays scoped to QuickForm. Removed: - AgentVoiceSettings + AgentSettings.voice - AgentMode enum + AgentSettings.mode - AgentToolType.CLIENT_SIDE + AgentClientSideTool{ResourceConfig,Settings,Properties} + its slot in ToolResourceConfig + the "clientside" normalizer mapping Co-Authored-By: Claude Opus 4.7 (1M context) --- .../uipath/src/uipath/agent/models/agent.py | 61 ------------------- 1 file changed, 61 deletions(-) diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 0b318c436..bcbdd210b 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -116,7 +116,6 @@ class AgentToolType(str, CaseInsensitiveEnum): INTEGRATION = "Integration" INTERNAL = "Internal" IXP = "Ixp" - CLIENT_SIDE = "clientSide" UNKNOWN = "Unknown" # fallback branch discriminator @@ -1007,41 +1006,6 @@ class AgentInternalToolResourceConfig(BaseAgentToolResourceConfig): ) -class AgentClientSideToolSettings(BaseCfg): - """Settings for a client-side tool.""" - - timeout: Optional[int] = Field(default=None) - - -class AgentClientSideToolProperties(BaseResourceProperties): - """Client-side tool properties model.""" - - -class AgentClientSideToolResourceConfig(BaseAgentToolResourceConfig): - """Client-side tool resource configuration model. - - Client-side tools are defined inline by the developer rather than backed - by an Orchestrator/Integration/Internal resource. The agent surfaces them - to the model but invocation is delegated to the calling client; the - runtime here only carries the schema/metadata round-trip. - """ - - id: Optional[str] = Field(None, alias="id") - type: Literal[AgentToolType.CLIENT_SIDE] = AgentToolType.CLIENT_SIDE - location: Literal["solution"] = "solution" - reference_key: Optional[str] = Field(default=None, alias="referenceKey") - output_schema: Dict[str, Any] = Field(EMPTY_SCHEMA, alias="outputSchema") - settings: AgentClientSideToolSettings = Field( - default_factory=AgentClientSideToolSettings - ) - properties: AgentClientSideToolProperties = Field( - default_factory=AgentClientSideToolProperties - ) - argument_properties: Dict[str, AgentToolArgumentProperties] = Field( - {}, alias="argumentProperties" - ) - - class AgentUnknownToolResourceConfig(BaseAgentToolResourceConfig): """Fallback for unknown tool types (parent normalizer sets type='Unknown').""" @@ -1055,7 +1019,6 @@ class AgentUnknownToolResourceConfig(BaseAgentToolResourceConfig): AgentIntegrationToolResourceConfig, AgentInternalToolResourceConfig, AgentIxpExtractionResourceConfig, - AgentClientSideToolResourceConfig, AgentUnknownToolResourceConfig, # when parent sets type="Unknown" ], Field(discriminator="type"), @@ -1330,27 +1293,6 @@ class AgentByomProperties(BaseCfg): connector_key: str = Field(alias="connectorKey") -class AgentVoiceSettings(BaseCfg): - """Agent voice settings for conversational low-code agents. - - Applies only when the agent supports voice in addition to text. When - absent, conversational agents fall back to defaults defined by the host - application. - """ - - model: str - max_tokens: int = Field(alias="maxTokens") - temperature: float - persona: str - - -class AgentMode(str, CaseInsensitiveEnum): - """Low-code agent authoring mode.""" - - STANDARD = "standard" - ADVANCED = "advanced" - - class AgentSettings(BaseCfg): """Agent settings model.""" @@ -1361,8 +1303,6 @@ class AgentSettings(BaseCfg): byom_properties: Optional[AgentByomProperties] = Field(None, alias="byomProperties") max_iterations: Optional[int] = Field(None, alias="maxIterations") persona: Optional[str] = Field(None, alias="persona") - mode: AgentMode = Field(default=AgentMode.STANDARD) - voice: Optional[AgentVoiceSettings] = Field(default=None) class AgentDefinition(BaseModel): @@ -1463,7 +1403,6 @@ def _normalize_resources(v: Dict[str, Any]) -> None: "integration": "Integration", "internal": "Internal", "ixp": "Ixp", - "clientside": "clientSide", "unknown": "Unknown", } CONTEXT_MODE_MAP = { From c278e658c3cd4776a1a97d4da1a31a3ea7087889 Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Thu, 28 May 2026 11:25:43 +0300 Subject: [PATCH 4/5] chore(cli): drop pull source-path plumbing from QuickForm PR The `_get_folder_by_path` lookup + `source_path` CLI argument were added to support pulling `.hitl.json` form schemas from nested folder paths, but that's an orthogonal pull-side feature, not part of the QuickForm runtime contract. Revert those files to main so this PR stays scoped to QuickForm. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/uipath/_cli/_utils/_project_files.py | 33 ++----------------- packages/uipath/src/uipath/_cli/cli_pull.py | 15 +++------ packages/uipath/tests/cli/test_pull.py | 27 --------------- 3 files changed, 7 insertions(+), 68 deletions(-) diff --git a/packages/uipath/src/uipath/_cli/_utils/_project_files.py b/packages/uipath/src/uipath/_cli/_utils/_project_files.py index 64395b802..c7c025197 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_project_files.py +++ b/packages/uipath/src/uipath/_cli/_utils/_project_files.py @@ -5,7 +5,7 @@ import re import tomllib from enum import IntEnum -from pathlib import Path, PurePosixPath +from pathlib import Path from typing import Any, AsyncIterator, Dict, Literal, Optional, Tuple from pydantic import BaseModel, Field, TypeAdapter @@ -19,6 +19,7 @@ ProjectFile, ProjectFolder, StudioClient, + get_folder_by_name, ) logger = logging.getLogger(__name__) @@ -547,34 +548,6 @@ def collect_files_from_folder( collect_files_from_folder(subfolder, subfolder_path, files_dict) -def _get_folder_by_path( - structure: ProjectFolder, folder_path: str | None -) -> Optional[ProjectFolder]: - if not folder_path: - return structure - - normalized_path = folder_path.replace("\\", "/").strip("/") - if not normalized_path or normalized_path == ".": - return structure - - folder = structure - for part in PurePosixPath(normalized_path).parts: - if part in ("", "."): - continue - if part == "..": - return None - - child = next( - (subfolder for subfolder in folder.folders if subfolder.name == part), - None, - ) - if child is None: - return None - folder = child - - return folder - - async def pull_project( project_id: str, download_configuration: dict[str | None, Path], @@ -588,7 +561,7 @@ async def pull_project( try: structure = await studio_client.get_project_structure_async() for source_key, destination in download_configuration.items(): - source_folder = _get_folder_by_path(structure, source_key) + source_folder = get_folder_by_name(structure, source_key) if source_folder: async for update in download_folder_files( studio_client, source_folder, destination diff --git a/packages/uipath/src/uipath/_cli/cli_pull.py b/packages/uipath/src/uipath/_cli/cli_pull.py index 7ac5c5247..1dcdd1b8b 100644 --- a/packages/uipath/src/uipath/_cli/cli_pull.py +++ b/packages/uipath/src/uipath/_cli/cli_pull.py @@ -24,14 +24,8 @@ @click.argument( "root", type=click.Path(exists=False, file_okay=False, dir_okay=True, path_type=Path), - required=False, default=Path("."), - metavar="[ROOT]", -) -@click.argument( - "source_path", - required=False, - metavar="[SOURCE_PATH]", + metavar="", ) @click.option( "--overwrite", @@ -39,7 +33,7 @@ help="Automatically overwrite local files without prompts", ) @track_command("pull") -def pull(root: Path, source_path: str | None, overwrite: bool) -> None: +def pull(root: Path, overwrite: bool) -> None: """Pull remote project files from Studio Web. This command pulls the remote project files from a UiPath Studio Web project. @@ -51,7 +45,6 @@ def pull(root: Path, source_path: str | None, overwrite: bool) -> None: $ uipath pull $ uipath pull /path/to/project - $ uipath pull /path/to/project studio-folder/subfolder $ uipath pull --overwrite """ project_id = UiPathConfig.project_id @@ -61,7 +54,7 @@ def pull(root: Path, source_path: str | None, overwrite: bool) -> None: studio_client = StudioClient(project_id=project_id) - result = Middlewares.next("pull", studio_client, root, overwrite, source_path) + result = Middlewares.next("pull", studio_client, root, overwrite) if result.error_message: console.error(result.error_message) return @@ -77,7 +70,7 @@ def pull(root: Path, source_path: str | None, overwrite: bool) -> None: return download_configuration: dict[str | None, Path] = { - source_path: root, + None: root, } console.log("Pulling UiPath project from Studio Web...") diff --git a/packages/uipath/tests/cli/test_pull.py b/packages/uipath/tests/cli/test_pull.py index 865878ed2..7b3aebd1a 100644 --- a/packages/uipath/tests/cli/test_pull.py +++ b/packages/uipath/tests/cli/test_pull.py @@ -13,7 +13,6 @@ from uipath._cli import cli from uipath._cli._utils._common import may_override_files from uipath._cli._utils._studio_project import StudioProjectMetadata -from uipath._cli.middlewares import MiddlewareResult from uipath.platform.errors import EnrichedException @@ -225,32 +224,6 @@ def test_successful_pull( with open("evaluations/evaluators/test-evaluator.json", "r") as f: assert json.load(f) == test_evaluator_content - def test_pull_passes_source_path_to_middlewares( - self, - runner: CliRunner, - temp_dir: str, - mock_env_vars: dict[str, str], - ) -> None: - """Test pull accepts a remote source path and passes it to middleware.""" - project_id = "test-project-id" - source_path = "e243fcde-99b3-4495-aad2-b2c58aad8373" - - with runner.isolated_filesystem(temp_dir=temp_dir): - configure_env_vars(mock_env_vars) - os.environ["UIPATH_PROJECT_ID"] = project_id - - with patch( - "uipath._cli.cli_pull.Middlewares.next", - return_value=MiddlewareResult(should_continue=False), - ) as next_middleware: - result = runner.invoke(cli, ["pull", "./", source_path]) - - assert result.exit_code == 0 - next_middleware.assert_called_once() - _, args, _ = next_middleware.mock_calls[0] - assert args[0] == "pull" - assert args[4] == source_path - def test_pull_with_existing_files( self, runner: CliRunner, From e00007fa3ccbe276d1699d9204cd3094953d0e5b Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Thu, 28 May 2026 11:38:47 +0300 Subject: [PATCH 5/5] test(tasks): cover create_quickform_async + _create_quickform_spec Adds: - TestCreateQuickFormSpec: 6 unit tests over the pure payload builder (minimal shape, inline schema, priority normalization + label shape, optional-field omission, actionable message fields, folder headers). - TestCreateQuickFormAsync: 3 integration tests via pytest-httpx (POST to GenericTasks/CreateTask, create+assign hop when a TaskRecipient is provided, no assign hop when omitted). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/services/test_actions_service.py | 217 +++++++++++++++++- 1 file changed, 216 insertions(+), 1 deletion(-) diff --git a/packages/uipath-platform/tests/services/test_actions_service.py b/packages/uipath-platform/tests/services/test_actions_service.py index b97d326e8..fc74a8d4a 100644 --- a/packages/uipath-platform/tests/services/test_actions_service.py +++ b/packages/uipath-platform/tests/services/test_actions_service.py @@ -1,3 +1,4 @@ +import json from typing import Any import pytest @@ -5,7 +6,11 @@ from uipath.platform import UiPathApiConfig, UiPathExecutionContext from uipath.platform.action_center import Task -from uipath.platform.action_center._tasks_service import TasksService +from uipath.platform.action_center._tasks_service import ( + TasksService, + _create_quickform_spec, +) +from uipath.platform.action_center.tasks import TaskRecipient, TaskRecipientType from uipath.platform.common.constants import HEADER_USER_AGENT @@ -555,3 +560,213 @@ def test_create_raises_when_no_folder_key_or_path_provided( app_name="my-app", app_folder_path=None, ) + + +class TestCreateQuickFormSpec: + """Unit tests for the pure `_create_quickform_spec` payload builder.""" + + def test_minimal_payload_shape(self) -> None: + spec = _create_quickform_spec( + title="Approve invoice", + task_schema_key="schema-uuid", + schema=None, + data=None, + ) + + assert spec.method == "POST" + assert str(spec.endpoint) == "/orchestrator_/tasks/GenericTasks/CreateTask" + assert spec.json == { + "type": "QuickFormTask", + "title": "Approve invoice", + "taskSchemaKey": "schema-uuid", + "data": {}, + } + + def test_inlines_schema_when_provided(self) -> None: + form_schema = {"fields": [{"name": "amount", "type": "number"}]} + + spec = _create_quickform_spec( + title="t", + task_schema_key="k", + schema=form_schema, + data={"amount": 42}, + ) + + assert spec.json is not None + assert spec.json["schema"] == form_schema + assert spec.json["data"] == {"amount": 42} + + def test_normalizes_priority_and_shapes_labels(self) -> None: + spec = _create_quickform_spec( + title="t", + task_schema_key="k", + schema=None, + data=None, + priority="high", + labels=["urgent", "finance"], + ) + + assert spec.json is not None + assert spec.json["priority"] == "High" + assert spec.json["tags"] == [ + { + "Name": "urgent", + "DisplayName": "urgent", + "Value": "urgent", + "DisplayValue": "urgent", + }, + { + "Name": "finance", + "DisplayName": "finance", + "Value": "finance", + "DisplayValue": "finance", + }, + ] + + def test_omits_optional_fields_when_unset(self) -> None: + spec = _create_quickform_spec( + title="t", + task_schema_key="k", + schema=None, + data=None, + ) + + assert spec.json is not None + assert "schema" not in spec.json + assert "priority" not in spec.json + assert "tags" not in spec.json + assert "isActionableMessageEnabled" not in spec.json + assert "actionableMessageMetaData" not in spec.json + + def test_includes_actionable_message_fields_when_set(self) -> None: + spec = _create_quickform_spec( + title="t", + task_schema_key="k", + schema=None, + data=None, + is_actionable_message_enabled=True, + actionable_message_metadata={"channel": "teams"}, + ) + + assert spec.json is not None + assert spec.json["isActionableMessageEnabled"] is True + assert spec.json["actionableMessageMetaData"] == {"channel": "teams"} + + def test_propagates_folder_headers(self) -> None: + spec = _create_quickform_spec( + title="t", + task_schema_key="k", + schema=None, + data=None, + folder_key="folder-key-value", + ) + + assert spec.headers.get("x-uipath-folderkey") == "folder-key-value" + + +class TestCreateQuickFormAsync: + """Integration tests for `TasksService.create_quickform_async`.""" + + @pytest.mark.anyio + async def test_posts_to_generic_tasks_endpoint( + self, + httpx_mock: HTTPXMock, + service: TasksService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/tasks/GenericTasks/CreateTask", + status_code=200, + json={"id": 42, "title": "Approve invoice"}, + ) + + task = await service.create_quickform_async( + title="Approve invoice", + task_schema_key="schema-uuid", + schema={"fields": []}, + ) + + assert isinstance(task, Task) + assert task.id == 42 + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "POST" + assert ( + str(sent.url) + == f"{base_url}{org}{tenant}/orchestrator_/tasks/GenericTasks/CreateTask" + ) + body = json.loads(sent.content) + assert body["type"] == "QuickFormTask" + assert body["taskSchemaKey"] == "schema-uuid" + assert body["schema"] == {"fields": []} + + @pytest.mark.anyio + async def test_assigns_after_create_when_recipient_provided( + self, + httpx_mock: HTTPXMock, + service: TasksService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/tasks/GenericTasks/CreateTask", + status_code=200, + json={"id": 99, "title": "t"}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Tasks/UiPath.Server.Configuration.OData.AssignTasks", + status_code=200, + json={}, + ) + + task = await service.create_quickform_async( + title="t", + task_schema_key="k", + recipient=TaskRecipient( + type=TaskRecipientType.EMAIL, + value="approver@example.com", + ), + ) + + assert isinstance(task, Task) + assert task.id == 99 + + requests = httpx_mock.get_requests() + assert len(requests) == 2 + + assign_request = requests[1] + assign_body = json.loads(assign_request.content) + assert assign_body == { + "taskAssignments": [ + { + "taskId": 99, + "assignmentCriteria": "SingleUser", + "userNameOrEmail": "approver@example.com", + } + ] + } + + @pytest.mark.anyio + async def test_skips_assign_when_no_recipient( + self, + httpx_mock: HTTPXMock, + service: TasksService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/tasks/GenericTasks/CreateTask", + status_code=200, + json={"id": 1, "title": "t"}, + ) + + await service.create_quickform_async(title="t", task_schema_key="k") + + requests = httpx_mock.get_requests() + assert len(requests) == 1 + assert "CreateTask" in str(requests[0].url)