diff --git a/products/tasks/backend/presentation/serializers.py b/products/tasks/backend/presentation/serializers.py index 398351a454b3..6783ae197d05 100644 --- a/products/tasks/backend/presentation/serializers.py +++ b/products/tasks/backend/presentation/serializers.py @@ -2041,7 +2041,12 @@ class TaskRunBootstrapCreateRequestSerializer( PR_AUTHORSHIP_MODE_CHOICES = [mode.value for mode in PrAuthorshipMode] RUN_SOURCE_CHOICES = [source.value for source in RunSource] RUNTIME_ADAPTER_CHOICES = [adapter.value for adapter in RuntimeAdapter] - REASONING_EFFORT_CHOICES = [effort.value for effort in PUBLIC_REASONING_EFFORTS] + PI_THINKING_LEVEL_CHOICES = ("off", "minimal", "low", "medium", "high", "xhigh", "max") + REASONING_EFFORT_CHOICES = [ + "off", + "minimal", + *(effort.value for effort in PUBLIC_REASONING_EFFORTS), + ] environment = serializers.ChoiceField( choices=[environment.value for environment in tasks_facade.TaskRunEnvironment], @@ -2163,6 +2168,37 @@ def validate(self, attrs): errors["relayed_mcp_servers"] = collision_error initial_permission_mode = attrs.get("initial_permission_mode") runtime_adapter = attrs.get("runtime_adapter") + view = self.context.get("view") + request = self.context.get("request") + task_id = view._task_id() if view else None + is_pi_task = ( + task_id is not None + and request is not None + and tasks_facade.task_runtime( + task_id, + self.context["team"].id, + request.user.id, + for_control=True, + ) + == tasks_facade.TaskRuntime.PI + ) + if is_pi_task: + if runtime_adapter is not None: + errors["runtime_adapter"] = "This field cannot be used with a Pi task." + if attrs.get("context_window") is not None: + errors["context_window"] = "This field cannot be used with a Pi task." + if attrs.get("fast_mode") is not None: + errors["fast_mode"] = "This field cannot be used with a Pi task." + if initial_permission_mode is not None: + errors["initial_permission_mode"] = "This field cannot be used with a Pi task." + if ( + attrs.get("reasoning_effort") is not None + and attrs["reasoning_effort"] not in self.PI_THINKING_LEVEL_CHOICES + ): + errors["reasoning_effort"] = "This thinking level is not supported by Pi." + if errors: + raise serializers.ValidationError(errors) + return attrs if initial_permission_mode is not None: if runtime_adapter is None: errors["initial_permission_mode"] = "This field requires runtime_adapter to be set." diff --git a/products/tasks/backend/tests/test_api.py b/products/tasks/backend/tests/test_api.py index ece88a369fcd..f2c100e56b50 100644 --- a/products/tasks/backend/tests/test_api.py +++ b/products/tasks/backend/tests/test_api.py @@ -2206,6 +2206,43 @@ def test_create_run_endpoint_creates_cloud_run_without_triggering_workflow(self, self.assertEqual(task_run.state["auto_publish"], True) mock_workflow.assert_not_called() + def test_create_run_endpoint_uses_pi_model_without_an_acp_adapter(self): + task = self.create_task(runtime=Task.Runtime.PI) + + response = self.client.post( + f"/api/projects/@current/tasks/{task.id}/runs/", + { + "environment": "cloud", + "mode": "interactive", + "model": "gpt-5.6-terra", + "reasoning_effort": "high", + }, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + task_run = TaskRun.objects.get(id=response.json()["id"]) + self.assertEqual(task_run.state["model"], "gpt-5.6-terra") + self.assertEqual(task_run.state["reasoning_effort"], "high") + self.assertNotIn("runtime_adapter", task_run.state) + + def test_create_run_endpoint_rejects_non_pi_thinking_level_for_pi_task(self): + task = self.create_task(runtime=Task.Runtime.PI) + + response = self.client.post( + f"/api/projects/@current/tasks/{task.id}/runs/", + { + "environment": "cloud", + "mode": "interactive", + "model": "gpt-5.6-terra", + "reasoning_effort": "ultracode", + }, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("reasoning_effort", response.json()) + # is_url_allowed resolves DNS for real in CI, and example.com subdomains don't resolve. @patch("products.tasks.backend.presentation.serializers.is_url_allowed", return_value=(True, None)) @patch("products.tasks.backend.temporal.client.execute_task_processing_workflow")