From 45ca55e26634ac19c272e1d4c84ab709fa31e09c Mon Sep 17 00:00:00 2001 From: twwu Date: Tue, 3 Nov 2026 14:53:59 +0800 Subject: [PATCH] fix(builder): align backend contracts and fixtures with main CI --- .../scripts/builder-confirmed-tool-units.py | 25 +- api/controllers/console/dify_builder.py | 8 +- api/controllers/inner_api/agent/runtime.py | 7 +- .../inner_api/agent/runtime_selection.py | 3 +- .../inner_api/plugin/agent_config.py | 10 +- api/controllers/inner_api/wraps.py | 10 +- api/runtime_contracts/builder/execution.py | 10 +- .../builder/native/contracts.py | 2 +- .../builder/native/observation.py | 18 +- api/schedule/dify_builder_reconcile_task.py | 6 +- api/services/agent/workflow_run_recovery.py | 3 +- api/services/app_generate_service.py | 6 +- .../code_execution/native_scope_transport.py | 7 +- api/services/dify_builder/agent/llm.py | 10 +- .../dify_builder/cognition_model_gateway.py | 5 +- api/services/dify_builder/effect_mock.py | 5 +- .../native_api_output_contract.py | 11 +- .../native_api_string_input_binding.py | 12 +- .../dify_builder/native_docx_evidence.py | 1 - .../native_testing/agent_bridge.py | 17 +- .../native_testing/agent_lineage.py | 2 - .../native_testing/agent_preparation.py | 2 +- .../native_testing/api_service.py | 10 +- .../confirmed_agent_commands.py | 8 +- .../native_testing/context_runtime.py | 8 +- .../dify_builder/native_testing/control.py | 41 +-- .../native_testing/host_execution.py | 5 +- .../dify_builder/native_testing/lifecycle.py | 18 +- .../native_testing/observation_owner.py | 4 +- .../native_testing/observation_privacy.py | 16 +- .../native_testing/observation_stream.py | 6 +- .../native_testing/sandbox/qualification.py | 25 +- .../native_testing/sandbox/sync_bridge.py | 10 +- .../native_testing/state_store.py | 2 +- .../dify_builder/real_call_consent_service.py | 52 ++-- api/services/dify_builder/repair_gate.py | 11 +- api/services/dify_builder/repository.py | 15 +- .../dify_builder/restricted_execution.py | 2 +- api/services/dify_builder/session_lock.py | 3 +- api/services/dify_builder/task_lifecycle.py | 8 +- .../dify_builder/template_execution.py | 8 +- api/services/dify_builder/wiring.py | 6 +- .../tools/ordinary_tool_credentials.py | 5 +- api/services/workflow_generation/runner.py | 6 +- api/services/workflow_runtime/agent_node.py | 16 +- .../workflow_runtime/workflow_runner.py | 4 +- .../builder_native_runtime_diagnostics.py | 15 +- .../test_builder_agent_continuation.py | 8 +- .../workflow/test_builder_agent_workspace.py | 7 +- ...st_builder_execution_policy_persistence.py | 26 +- .../workflow/test_builder_json_tool_native.py | 6 +- ...test_builder_native_code_physical_state.py | 7 +- .../test_builder_native_continuation.py | 8 +- .../test_builder_native_http_binding.py | 8 +- .../workflow/test_builder_task_controls.py | 13 +- .../test_knowledge_fs_migration_chain.py | 1 + .../test_builder_native_migration_dialects.py | 9 +- api/tests/unit_tests/celery_fixtures.py | 6 +- .../console/test_dify_builder_routes.py | 58 ++-- .../controllers/inner_api/agent/test_llm.py | 11 +- .../test_agent_config_runtime_access.py | 18 +- .../apps/test_builder_execution_admission.py | 18 +- .../dify_builder/test_execution_policy.py | 8 +- .../core/dify_builder/test_form_fields.py | 54 +++- .../core/dify_builder/test_handlers_build.py | 151 +++++---- .../core/dify_builder/test_handlers_edit.py | 163 ++++++---- .../core/dify_builder/test_handlers_fix.py | 106 ++++--- .../code_executor/test_code_executor.py | 7 +- .../test_native_loop_generation_contract.py | 5 +- .../agent_v2/test_confirmed_agent_node.py | 16 +- .../core/workflow/test_graph_normalizers.py | 7 +- .../core/workflow/test_node_runtime.py | 11 +- .../workflow/test_restricted_execution.py | 17 +- ...rameters_cache_when_sync_draft_workflow.py | 28 +- .../libs/test_sensitive_http_telemetry.py | 12 +- ...57_replace_agent_runtime_sessions_with_.py | 28 +- .../models/test_dify_builder_models.py | 9 +- .../models/test_dify_builder_typed_models.py | 4 +- .../test_workflow_draft_sync_repository.py | 4 +- .../agent/test_workflow_run_recovery.py | 11 +- .../test_native_scope_transport.py | 85 ++--- .../dify_builder/draft_tool_inventory.py | 12 +- .../dify_builder/installed_tool_inventory.py | 5 +- .../dify_builder/native_owner_fixtures.py | 5 +- .../native_testing/test_agent_associations.py | 56 ++-- .../native_testing/test_agent_context.py | 6 +- .../native_testing/test_authorization.py | 8 +- .../native_testing/test_call_adapter.py | 44 ++- .../test_claimed_resume_checkpoint.py | 9 +- .../native_testing/test_code_executor.py | 59 ++-- .../test_code_physical_repository.py | 30 +- .../native_testing/test_code_qualification.py | 33 +- .../test_confirmed_agent_commands.py | 43 +-- .../test_confirmed_agent_completion.py | 16 +- .../native_testing/test_context_binding.py | 12 +- .../native_testing/test_control.py | 91 +++--- .../native_testing/test_dispatch_claims.py | 15 +- .../native_testing/test_dispatch_fence.py | 163 ++++++---- .../native_testing/test_host_capacity.py | 123 +++++--- .../native_testing/test_host_execution.py | 70 +++-- .../test_host_generation_wiring.py | 31 +- .../native_testing/test_host_lifetime.py | 5 +- .../native_testing/test_http_adapter.py | 30 +- .../native_testing/test_http_binding.py | 85 +++-- .../native_testing/test_lifecycle.py | 31 +- .../native_testing/test_native_execution.py | 10 +- .../test_native_observation_wiring.py | 43 ++- .../test_native_resume_scheduling.py | 28 +- .../native_testing/test_native_runtime.py | 233 +++++++------- .../native_testing/test_node_inputs.py | 77 +++-- .../native_testing/test_observation_owner.py | 30 +- .../test_observation_persistence.py | 96 ++++-- .../test_observation_privacy.py | 68 +++- .../test_observation_repository.py | 73 +++-- .../native_testing/test_observation_stream.py | 67 ++-- .../native_testing/test_operation_journal.py | 55 ++-- .../test_pending_decision_guard.py | 13 +- .../native_testing/test_runtime_bridge.py | 41 +-- .../native_testing/test_stop_sealing.py | 42 +-- .../native_testing/test_stream_runtime.py | 21 +- .../native_testing/test_unconsumed_failure.py | 40 ++- .../test_zero_http_admission.py | 25 +- .../native_testing/typed_boundaries.py | 97 ++++++ .../dify_builder/test_advance_task.py | 6 +- .../test_agent_endpoint_lineage.py | 19 +- .../test_agent_operation_owner.py | 17 +- .../test_agent_preparation_owner.py | 34 +- .../test_agent_tool_declarations.py | 11 +- .../test_api_tool_output_context.py | 18 +- .../test_business_acceptance_integration.py | 14 +- .../dify_builder/test_card_options.py | 6 +- .../test_code_risk_preparation.py | 8 +- .../dify_builder/test_cognition_parameters.py | 5 +- .../dify_builder/test_cognition_policy.py | 28 +- .../test_cognition_policy_repository.py | 4 +- .../dify_builder/test_condition_evidence.py | 22 +- .../test_confirmed_tool_launch.py | 32 +- .../dify_builder/test_conflict_chain.py | 7 +- .../test_conversation_lifecycle.py | 12 +- .../dify_builder/test_dify_port_preflight.py | 3 +- .../dify_builder/test_edit_plan_generation.py | 5 +- .../test_execution_policy_service.py | 8 +- .../dify_builder/test_initial_chatflow.py | 6 +- .../dify_builder/test_json_tool_runtime.py | 8 +- .../test_native_api_output_contract.py | 97 +++--- .../test_native_api_string_input_binding.py | 112 ++++--- .../test_native_http_output_contract.py | 90 +++--- .../dify_builder/test_native_metadata.py | 7 +- .../dify_builder/test_new_agent_recovery.py | 10 +- .../dify_builder/test_notice_retirement.py | 10 +- .../test_planning_tool_contract.py | 23 +- .../test_plugin_tool_output_context.py | 28 +- .../test_real_call_consent_service.py | 56 +++- .../services/dify_builder/test_reconcile.py | 6 +- .../dify_builder/test_resource_grounding.py | 8 +- .../services/dify_builder/test_service.py | 32 +- .../test_template_presentation.py | 8 +- .../test_tool_parameter_semantics.py | 70 ++--- .../services/dify_builder/typed_fixtures.py | 76 +++++ .../test_app_generate_service_in_process.py | 19 +- .../services/tools/test_api_tool_catalogue.py | 37 ++- .../test_builder_credential_reference.py | 30 +- .../tools/test_ordinary_plugin_credentials.py | 7 +- .../workflow_generation/test_runner.py | 291 ++++++++++-------- .../workflow_runtime/test_node_factory.py | 38 ++- .../test_remove_app_and_related_data_task.py | 18 +- .../src/dify_agent/adapters/shell/shellctl.py | 10 +- 167 files changed, 2836 insertions(+), 1975 deletions(-) create mode 100644 api/tests/unit_tests/services/dify_builder/native_testing/typed_boundaries.py create mode 100644 api/tests/unit_tests/services/dify_builder/typed_fixtures.py diff --git a/.github/scripts/builder-confirmed-tool-units.py b/.github/scripts/builder-confirmed-tool-units.py index efe03922292..368ee603b60 100644 --- a/.github/scripts/builder-confirmed-tool-units.py +++ b/.github/scripts/builder-confirmed-tool-units.py @@ -276,7 +276,7 @@ a3_expected = { }, "tests.unit_tests.controllers.inner_api.agent.test_llm": { "test_post_streams_plugin_compatible_envelope": 1, - "test_post_preserves_prompt_messages_without_transport_validation": 1, + "test_post_preserves_raw_prompts_without_serializing_them": 2, "test_post_rejects_invalid_body_before_model_resolution": 1, "test_post_preserves_preflight_quota_failure": 1, "test_post_preserves_stream_quota_failure": 1, @@ -285,6 +285,7 @@ a3_expected = { "test_post_returns_service_response": 1, "test_post_returns_400_for_invalid_body": 1, "test_post_preserves_service_error_status_code_and_description": 1, + "test_post_preserves_refresh_error_classification_through_real_credential_resolution": 8, }, "tests.unit_tests.controllers.inner_api.agent.test_files": { "test_upload_request_returns_origin_free_uri": 1, @@ -361,7 +362,7 @@ a4_expected.update( "test_hardcoded_refresh_retains_original_state_across_session_expiration": 1, "test_empty_reference_preserves_default_selection": 1, "test_refresh_update_compiles_for_supported_database_configuration": 4, - "test_refresh_requires_exactly_one_affected_row_before_cache_and_dispatch": 5, + "test_refresh_requires_proven_write_before_cache_and_dispatch": 5, "test_refresh_commit_uncertainty_refuses_cache_invalidation_and_dispatch": 1, }, "tests.unit_tests.core.tools.test_tool_manager": { @@ -396,7 +397,7 @@ a4_expected.update( "test_get_tool_runtime_builtin_missing_tool_raises": 1, "test_get_tool_runtime_builtin_plugin_credential_deleted_raises": 1, "test_get_tool_runtime_builtin_plugin_without_workspace_credential_raises": 1, - "test_get_tool_runtime_builtin_refreshes_expired_oauth_credentials": 6, + "test_get_tool_runtime_builtin_refreshes_expired_oauth_credentials": 9, "test_get_tool_runtime_builtin_with_credentials_decrypts_and_forks": 1, "test_get_tool_runtime_builtin_without_credentials": 1, "test_get_tool_runtime_from_plugin_only_uses_form_parameters": 1, @@ -510,6 +511,7 @@ for module, functions in { "test_ensure_snippet_start_node_in_worker_returns_standard_workflow_without_lookup": 1, "test_ensure_snippet_start_node_returns_original_for_non_snippet_workflow": 1, "test_ensure_snippet_start_node_returns_original_when_snippet_is_from_another_tenant": 1, + "test_execution_grants_default_files_to_worker_and_response": 4, "test_generate_forwards_pause_configuration_and_state": 1, "test_generate_includes_parent_trace_context_in_extras": 1, "test_generate_prepares_start_inputs": 2, @@ -518,9 +520,9 @@ for module, functions in { "test_generate_worker_uses_end_user_session_for_external_invocation": 1, "test_handle_response_closed_file_raises_stopped": 1, "test_resume_delegates_to_generate": 1, - "test_resume_path_runs_worker_with_runtime_state": 1, - "test_resume_preserves_existing_trace_manager": 1, - "test_resume_restores_trace_manager_when_missing": 1, + "test_resume_preserves_existing_trace_scope": 1, + "test_resume_recreates_trace_scope_when_missing": 1, + "test_service_api_preserves_caller_identity_and_file_access": 6, "test_single_iteration_generate_includes_trace_session_id_in_extras": 1, "test_single_iteration_generate_validates_args": 1, "test_single_loop_generate_includes_trace_session_id_in_extras": 1, @@ -546,7 +548,7 @@ for module, functions in { "test_resolve_user_from": 1, }, "tests.unit_tests.core.app.entities.test_app_invoke_entities": { - "test_advanced_chat_generate_entity_roundtrip_excludes_trace_manager": 1, + "test_advanced_chat_generate_entity_roundtrip_excludes_trace_scope": 1, "test_dify_run_context_normalizes_unknown_created_by_values": 1, "test_get_credit_usage_app_type_maps_app_mode": 7, "test_get_credit_usage_created_by_defaults_to_unknown": 1, @@ -554,7 +556,7 @@ for module, functions in { "test_openapi_distinct_from_service_api": 1, "test_openapi_variant_present": 1, "test_runs_as_account_only_for_console_contexts": 1, - "test_workflow_app_generate_entity_roundtrip_excludes_trace_manager": 1, + "test_workflow_app_generate_entity_roundtrip_excludes_trace_scope": 1, "test_workflow_resumption_context_roundtrip": 2, }, "tests.unit_tests.core.repositories.test_sqlalchemy_workflow_node_execution_repository": { @@ -737,7 +739,7 @@ for module, functions in { "tests.unit_tests.controllers.inner_api.agent.test_llm": { "test_native_stream_error_is_bounded_even_if_provider_raises_private_text": 2, "test_post_preserves_preflight_quota_failure": 1, - "test_post_preserves_prompt_messages_without_transport_validation": 1, + "test_post_preserves_raw_prompts_without_serializing_them": 2, "test_post_preserves_stream_quota_failure": 1, "test_post_rejects_invalid_body_before_model_resolution": 1, "test_post_streams_plugin_compatible_envelope": 1, @@ -851,11 +853,10 @@ a4_expected.setdefault( a4_expected["tests.unit_tests.services.test_workflow_run_service"] = { "test_get_paginate_advanced_chat_workflow_runs_should_attach_message_fields_when_message_exists": 1, "test_get_paginate_workflow_runs_should_forward_filters_and_parse_limit": 1, - "test_get_pause_details_keeps_human_form_when_scheduling_also_waits": 2, + "test_get_pause_details_hides_private_scheduling_messages": 4, "test_get_pause_details_maps_human_input_and_token": 1, - "test_get_pause_details_preserves_scheduling_pause_without_exposing_private_message": 1, "test_get_pause_details_rejects_unsupported_pause_reason": 1, - "test_get_pause_details_returns_empty_details_for_non_paused_run": 1, + "test_get_pause_details_returns_empty_details_without_an_active_pause": 2, "test_get_pause_details_returns_none_when_run_is_not_found": 1, "test_get_workflow_run_node_executions_should_return_empty_list_when_run_not_found": 1, "test_get_workflow_run_node_executions_should_use_request_workspace": 1, diff --git a/api/controllers/console/dify_builder.py b/api/controllers/console/dify_builder.py index 9022fb999a5..70312ec593f 100644 --- a/api/controllers/console/dify_builder.py +++ b/api/controllers/console/dify_builder.py @@ -32,14 +32,14 @@ import dataclasses import functools import logging from collections.abc import Callable, Iterator -from typing import Literal, TypedDict, cast +from typing import Literal, TypedDict -from flask import Flask, Response, current_app, request +from flask import Response, request +from flask.globals import app_ctx from flask_restx import Resource from pydantic import ValidationError from sqlalchemy.orm import scoped_session from werkzeug.exceptions import BadRequest, Conflict, Forbidden, NotFound, ServiceUnavailable -from werkzeug.local import LocalProxy from controllers.common.schema import ( query_params_from_model, @@ -500,7 +500,7 @@ def _stream( raise return mapped - app = cast(LocalProxy[Flask], current_app)._get_current_object() + app = app_ctx.app if native_version not in (None, "builder-native-test-v1"): subscription.close() raise BadRequest("Invalid native test stream version.") diff --git a/api/controllers/inner_api/agent/runtime.py b/api/controllers/inner_api/agent/runtime.py index d5104beb4af..3357d985283 100644 --- a/api/controllers/inner_api/agent/runtime.py +++ b/api/controllers/inner_api/agent/runtime.py @@ -6,6 +6,8 @@ Legacy Resource authentication cannot grant this admission. # Flask-RESTX declares a mutable Resource decorator list; each subclass owns # its own list. Tuple/ClassVar overrides conflict with that upstream contract. +from flask_restx import Resource + from controllers.inner_api import inner_api_ns from controllers.inner_api.agent import files, llm, native_testing, tools from controllers.inner_api.knowledge import retrieval @@ -313,14 +315,15 @@ document_errors(AgentRuntimeNativeMaterializeApi, AgentRuntimeRefused) } }, ) -class AgentRuntimeNativeClaimApi(native_testing.AgentNativeDispatchClaimApi): +class AgentRuntimeNativeClaimApi(Resource): method_decorators = [agent_runtime_api_only("native-control")] @inner_api_ns.expect(inner_api_ns.models[native_testing.AgentPhysicalDispatchPayload.__name__]) @inner_api_ns.response( 200, "Physical shell dispatch claimed", inner_api_ns.models[native_testing.AgentDispatchClaimResponse.__name__] ) - def post(self): + def post(self) -> dict[str, bool]: + """Claim a runtime-authorized shell dispatch; refuse other operation kinds.""" from dify_agent.adapters.native_testing.client import AgentPhysicalDispatchPayload from runtime_contracts.builder.execution import BuilderExecutionPolicyError diff --git a/api/controllers/inner_api/agent/runtime_selection.py b/api/controllers/inner_api/agent/runtime_selection.py index 25033767572..537a438e73f 100644 --- a/api/controllers/inner_api/agent/runtime_selection.py +++ b/api/controllers/inner_api/agent/runtime_selection.py @@ -1,7 +1,6 @@ """Canonical bounded Run header parsing; the parsed value grants no authority.""" import base64 -import binascii import json from dify_agent.adapters.native_testing.client import AgentPhysicalDispatchPayload @@ -45,5 +44,5 @@ def _parse_header[Model: BaseModel](headers, name: str, model: type[Model]) -> M if raw != canonical or encoded != base64.urlsafe_b64encode(canonical).rstrip(b"=").decode("ascii"): raise ValueError("noncanonical selection") return value - except (ValueError, UnicodeError, binascii.Error): + except ValueError: raise BuilderExecutionPolicyError("native_agent_selection_invalid") from None diff --git a/api/controllers/inner_api/plugin/agent_config.py b/api/controllers/inner_api/plugin/agent_config.py index ddaaa8f3cc2..e02ba26c120 100644 --- a/api/controllers/inner_api/plugin/agent_config.py +++ b/api/controllers/inner_api/plugin/agent_config.py @@ -10,12 +10,12 @@ directly from the Dify API data plane. from __future__ import annotations -from typing import Literal, cast +from typing import Literal from flask import g, request +from flask.globals import request_ctx from flask_restx import Resource from pydantic import BaseModel, ConfigDict, ValidationError, model_validator -from werkzeug.local import LocalProxy from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console.wraps import setup_required @@ -125,11 +125,7 @@ def _authorize_runtime_config_read( agent_id: str, query: _ConfigTargetQuery, source: dict[str, str] | None = None ) -> None: """Spend no second permit; revalidate the claimed parent operation before reads.""" - context = ( - g.get("agent_runtime_context") - if g.get("agent_runtime_request") is cast(LocalProxy, request)._get_current_object() - else None - ) + context = g.get("agent_runtime_context") if g.get("agent_runtime_request") is request_ctx.request else None if context is None: return from runtime_contracts.builder.execution import BuilderExecutionPolicyError diff --git a/api/controllers/inner_api/wraps.py b/api/controllers/inner_api/wraps.py index a7eeb5930e3..3e02bea7ac0 100644 --- a/api/controllers/inner_api/wraps.py +++ b/api/controllers/inner_api/wraps.py @@ -4,10 +4,9 @@ from functools import wraps from hashlib import sha1 from hmac import compare_digest from hmac import new as hmac_new -from typing import cast from flask import abort, current_app, g, request -from werkzeug.local import LocalProxy +from flask.globals import request_ctx from configs import dify_config from core.db.session_factory import session_factory @@ -158,10 +157,7 @@ def agent_endpoint_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]: @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs) -> R: - if ( - g.get("agent_runtime_admitted", False) - and g.get("agent_runtime_request") is cast(LocalProxy, request)._get_current_object() - ): + if g.get("agent_runtime_admitted", False) and g.get("agent_runtime_request") is request_ctx.request: return view(*args, **kwargs) return authenticated_legacy(*args, **kwargs) @@ -299,7 +295,7 @@ def agent_runtime_api_only(boundary: str): raise AgentRuntimeRefused() from None # g can outlive one request under an explicitly pushed app context. # Bind this admission to the actual request object, not only g. - g.agent_runtime_request = cast(LocalProxy, request)._get_current_object() + g.agent_runtime_request = request_ctx.request g.agent_runtime_principal = principal g.agent_runtime_selection = selection g.agent_runtime_context = context diff --git a/api/runtime_contracts/builder/execution.py b/api/runtime_contracts/builder/execution.py index 8e09808f8b6..294e49a0a90 100644 --- a/api/runtime_contracts/builder/execution.py +++ b/api/runtime_contracts/builder/execution.py @@ -57,7 +57,7 @@ def canonical_json(value: Any) -> bytes: _finite_json(value) encoded = json.dumps(value, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(",", ":")) return encoded.encode("utf-8") - except (ValueError, TypeError, UnicodeError, RecursionError): + except (ValueError, TypeError, RecursionError): raise BuilderExecutionPolicyError("invalid_canonical_json") from None @@ -112,7 +112,7 @@ class HttpResponseFixtureV1(_TrustedModel): if self.content_type == "application/json": try: _finite_json(json.loads(self.body)) - except (ValueError, TypeError, UnicodeError, RecursionError): + except (ValueError, TypeError, RecursionError): raise ValueError("invalid finite JSON fixture") from None return self @@ -148,7 +148,7 @@ def decode_http_fixture_submission(raw: Any) -> tuple[HttpResponseFixtureV1, ... if len({f.node_id for f in fixtures}) != len(fixtures): raise ValueError("duplicate fixture") return tuple(sorted(fixtures, key=lambda f: f.node_id)) - except (ValidationError, ValueError, TypeError, UnicodeError): + except (ValidationError, ValueError, TypeError): raise BuilderExecutionPolicyError("invalid_http_fixtures") from None @@ -158,7 +158,7 @@ def decode_http_fixture_set(raw: Any) -> HttpFixtureSetV1 | None: return None try: return HttpFixtureSetV1.model_validate(raw) - except (ValidationError, ValueError, TypeError, UnicodeError): + except (ValidationError, ValueError, TypeError): raise BuilderExecutionPolicyError("invalid_http_fixture_envelope") from None @@ -294,7 +294,7 @@ def validate_execution_context( raw = {**raw, "policy_version": "builder-restricted-v1"} try: context = _CONTEXT_VALUE_ADAPTER.validate_python(raw) - except (ValidationError, ValueError, TypeError, UnicodeError): + except (ValidationError, ValueError, TypeError): raise BuilderExecutionPolicyError("invalid_execution_context") from None if context.context_digest != context_digest(context): raise BuilderExecutionPolicyError("context_digest_mismatch") diff --git a/api/runtime_contracts/builder/native/contracts.py b/api/runtime_contracts/builder/native/contracts.py index ea27381e292..0eb28d4fa6f 100644 --- a/api/runtime_contracts/builder/native/contracts.py +++ b/api/runtime_contracts/builder/native/contracts.py @@ -173,5 +173,5 @@ def decode_native_ref(raw: JsonValue) -> NativeTestContextRef: """Validate structure only; accepted JSON cannot authorize execution.""" try: return NativeTestContextRef.model_validate(raw) - except (ValidationError, ValueError, TypeError, UnicodeError): + except (ValidationError, ValueError, TypeError): raise BuilderExecutionPolicyError("invalid_native_test_ref") from None diff --git a/api/runtime_contracts/builder/native/observation.py b/api/runtime_contracts/builder/native/observation.py index b39100bb225..aa4fcc6288b 100644 --- a/api/runtime_contracts/builder/native/observation.py +++ b/api/runtime_contracts/builder/native/observation.py @@ -2,7 +2,7 @@ import re from dataclasses import dataclass -from typing import Literal, cast +from typing import Literal, TypeIs NativeModelFailureStage = Literal["invoke", "receipt_append", "result_store", "outcome_complete", "unknown_settlement"] NativeModelFailureCategory = Literal[ @@ -34,6 +34,16 @@ _MODEL_CATEGORIES = frozenset( ) +def _is_model_failure_stage(value: str) -> TypeIs[NativeModelFailureStage]: + """Narrow a diagnostic stage only when it belongs to the fixed allowlist.""" + return value in _MODEL_STAGES + + +def _is_model_failure_category(value: str) -> TypeIs[NativeModelFailureCategory]: + """Narrow a diagnostic category only when it belongs to the fixed allowlist.""" + return value in _MODEL_CATEGORIES + + @dataclass(frozen=True) class NativeModelFailureDiagnostic: """Fixed post-claim facts only; never permission, model output or retry advice.""" @@ -62,11 +72,9 @@ def parse_native_model_failure(error: str) -> NativeModelFailureDiagnostic | Non if match is None: return None stage, category = match.groups() - if stage not in _MODEL_STAGES or category not in _MODEL_CATEGORIES: + if not _is_model_failure_stage(stage) or not _is_model_failure_category(category): return None - return NativeModelFailureDiagnostic( - stage=cast(NativeModelFailureStage, stage), category=cast(NativeModelFailureCategory, category) - ) + return NativeModelFailureDiagnostic(stage=stage, category=category) class NativeObservationError(RuntimeError): diff --git a/api/schedule/dify_builder_reconcile_task.py b/api/schedule/dify_builder_reconcile_task.py index 8ad5a2d8090..7d102b42e69 100644 --- a/api/schedule/dify_builder_reconcile_task.py +++ b/api/schedule/dify_builder_reconcile_task.py @@ -33,9 +33,7 @@ module must not push its own. """ import logging -from collections.abc import Callable from datetime import timedelta -from typing import cast from uuid import UUID from celery import shared_task @@ -149,7 +147,7 @@ def retain_waiting_agent_bindings(*, limit: int = 100) -> int: value = raw_cursor.decode("ascii") if isinstance(raw_cursor, bytes) and len(raw_cursor) == 36 else raw_cursor if isinstance(value, str) and len(value) == 36 and str(UUID(value)) == value: cursor = value - except (ValueError, UnicodeError): + except ValueError: pass # Scheduling metadata is never authority or an SQL expression. query = ( select( @@ -261,7 +259,7 @@ def reconcile_native_tests(*, limit: int = 100) -> int: actor=actor, session_id=session_id, request_id=request_id, - publish=cast(Callable[..., None], advance_native_test.delay), + publish=advance_native_test.delay, limit=limit, ) except Exception: diff --git a/api/services/agent/workflow_run_recovery.py b/api/services/agent/workflow_run_recovery.py index aefdbf1dbdd..04fb7a1bfae 100644 --- a/api/services/agent/workflow_run_recovery.py +++ b/api/services/agent/workflow_run_recovery.py @@ -9,7 +9,7 @@ record lives in the existing caller row and contains no executable config. import json from collections.abc import Callable from dataclasses import dataclass -from typing import Protocol +from typing import Protocol, runtime_checkable from dify_agent.protocol import CreateRunRequest, CreateRunResponse from dify_agent.protocol.run_recovery import LookupRunRequest, create_run_fingerprint @@ -40,6 +40,7 @@ class AgentCreateAcknowledgementUnknown(AgentBackendTransportError): # noqa: N8 error_code = "agent_create_ack_unknown" +@runtime_checkable class AgentCreateRecoveryClient(Protocol): def create_run(self, request: CreateRunRequest) -> CreateRunResponse: ... diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py index 2c1d9c46869..f26cc1b69ad 100644 --- a/api/services/app_generate_service.py +++ b/api/services/app_generate_service.py @@ -4,7 +4,7 @@ import logging import threading import uuid from collections.abc import Callable, Mapping -from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast +from typing import TYPE_CHECKING, Any, Literal, TypedDict from sqlalchemy.orm import Session @@ -461,7 +461,7 @@ class AppGenerateService: payload_json = payload.model_dump_json() def on_subscribe(): - cast(Callable[..., Any], workflow_based_app_execution_task.delay)(payload_json) + workflow_based_app_execution_task.delay(payload_json) on_subscribe = cls._build_streaming_task_on_subscribe(on_subscribe) return rate_limit.generate( @@ -538,7 +538,7 @@ class AppGenerateService: payload_json = payload.model_dump_json() def on_subscribe(): - cast(Callable[..., Any], workflow_based_app_execution_task.delay)(payload_json) + workflow_based_app_execution_task.delay(payload_json) on_subscribe = cls._build_streaming_task_on_subscribe(on_subscribe) return rate_limit.generate( diff --git a/api/services/code_execution/native_scope_transport.py b/api/services/code_execution/native_scope_transport.py index ed123dca2fe..721d27f837f 100644 --- a/api/services/code_execution/native_scope_transport.py +++ b/api/services/code_execution/native_scope_transport.py @@ -22,10 +22,9 @@ import re import shlex from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import Any, Protocol, cast +from typing import Any, Protocol from dify_agent.adapters.shell.protocols import ShellCommandResult -from dify_agent.adapters.shell.shellctl import ShellctlClientProtocol from dify_agent.runtime_backend.local import LocalNativeScopeClient from dify_agent.runtime_backend.native_test_scope import NativeScopeCommands, NativeScopeLease from dify_agent.runtime_backend.protocols import RuntimeLayout @@ -188,8 +187,8 @@ class NativeScopeCodeExecutionTransport: lease = NativeScopeLease( handle=f"native:{handle.scope_id}:{handle.generation}", layout=RuntimeLayout(home_dir="/home/dify", workspace_dir="/workspace"), - client=cast(ShellctlClientProtocol, client), - commands=NativeScopeCommands(client=cast(ShellctlClientProtocol, client)), + client=client, + commands=NativeScopeCommands(client=client), workspace_generation=handle.generation, ) self._check_current() diff --git a/api/services/dify_builder/agent/llm.py b/api/services/dify_builder/agent/llm.py index 036d55c44cc..f50aa87a35f 100644 --- a/api/services/dify_builder/agent/llm.py +++ b/api/services/dify_builder/agent/llm.py @@ -8,8 +8,8 @@ failure retry ONCE with a corrective hint before raising. """ import json -from collections.abc import Callable, Generator, Iterator, Mapping -from typing import Any, cast +from collections.abc import Callable, Iterator, Mapping +from typing import Any import json_repair from pydantic import BaseModel @@ -61,7 +61,7 @@ def _direct_reasoning(value: object) -> str: if isinstance(value, BaseModel): sources = (value.model_dump(), value.model_extra or {}) elif isinstance(value, Mapping): - sources = (cast(Mapping[str, object], value),) + sources = (value,) else: return "" for source in sources: @@ -180,7 +180,7 @@ def invoke_text_stream( return think_filter = ThinkStreamFilter() - for chunk in _stream_chunks(cast(Generator[LLMResultChunk], result)): + for chunk in _stream_chunks(result): check_current() native_reasoning = next( ( @@ -197,7 +197,7 @@ def invoke_text_stream( elif isinstance(content, list): delta = "".join( part.data if isinstance(part, TextPromptMessageContent) else part - for part in cast(list[object], content) + for part in content if isinstance(part, (TextPromptMessageContent, str)) ) else: diff --git a/api/services/dify_builder/cognition_model_gateway.py b/api/services/dify_builder/cognition_model_gateway.py index b3c5f22b2c3..536e7876fad 100644 --- a/api/services/dify_builder/cognition_model_gateway.py +++ b/api/services/dify_builder/cognition_model_gateway.py @@ -5,7 +5,6 @@ from typing import Any, Protocol from core.dify_builder.models import Actor from core.entities.model_entities import ModelWithProviderEntity -from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError from core.model_manager import ModelInstance, ModelManager from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelPropertyKey, ModelType from graphon.model_runtime.entities.provider_entities import ConfigurateMethod @@ -45,7 +44,7 @@ class NativeCognitionModels: str(model.model_properties.get(ModelPropertyKey.MODE, "chat")), {}, ) - except (ValueError, ProviderTokenNotInitError, ModelCurrentlyNotSupportError): + except ValueError: return None @staticmethod @@ -186,7 +185,7 @@ class NativeCognitionModels: if error.reason == "model_unavailable": return False raise - except (ValueError, ProviderTokenNotInitError, ModelCurrentlyNotSupportError): + except ValueError: return False diff --git a/api/services/dify_builder/effect_mock.py b/api/services/dify_builder/effect_mock.py index 401807b45ef..f817bfd7867 100644 --- a/api/services/dify_builder/effect_mock.py +++ b/api/services/dify_builder/effect_mock.py @@ -12,7 +12,7 @@ from __future__ import annotations import copy from functools import cache -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from domain.workflow.agent_node import is_dify_agent_node_data from graphon.entities.base_node_data import BaseNodeData @@ -53,8 +53,9 @@ class _MockedRun: ) -> None: # Keep Graphon's lifecycle/data contract without constructing the native # node's clients, credential owners, or other execution resources. + assert isinstance(self, Node) Node.__init__( - cast(Node, self), + self, node_id=node_id, data=data, init_params=init_params, diff --git a/api/services/dify_builder/native_api_output_contract.py b/api/services/dify_builder/native_api_output_contract.py index b314777e595..db9e45da714 100644 --- a/api/services/dify_builder/native_api_output_contract.py +++ b/api/services/dify_builder/native_api_output_contract.py @@ -8,7 +8,6 @@ existing dynamic-output handling; this owner performs no metadata/runtime lookup from collections.abc import Mapping, Sequence from re import Pattern -from typing import cast from uuid import UUID _NATIVE_OUTPUTS = frozenset({"text", "json", "files"}) @@ -98,7 +97,7 @@ def collect_native_api_selector_refs( node_id = node.get("id") data = node.get("data") if isinstance(node_id, str) and isinstance(data, Mapping): - if is_native_api_tool(cast(Mapping[str, object], data)): + if is_native_api_tool(data): known_ids.add(node_id) refs: set[tuple[str, str]] = set() @@ -107,7 +106,7 @@ def collect_native_api_selector_refs( return if not all(isinstance(part, str) for part in value): return - selector = [part.strip() for part in cast(list[str], value)] + selector = [part.strip() for part in value if isinstance(part, str)] if selector[0] in known_ids and all(selector): refs.add((selector[0], ".".join(selector[1:]))) @@ -118,7 +117,7 @@ def collect_native_api_selector_refs( if node_id in known_ids and variable: refs.add((node_id, variable)) elif isinstance(value, Mapping): - fields = cast(Mapping[str, object], value) + fields = value if fields.get("type") == "constant": return for name in _SELECTOR_FIELDS: @@ -174,7 +173,7 @@ def collect_native_api_selector_refs( def mapping_items(value: object) -> list[Mapping[str, object]]: if not isinstance(value, list): return [] - return [cast(Mapping[str, object], item) for item in value if isinstance(item, Mapping)] + return [item for item in value if isinstance(item, Mapping)] def collect_selector_list(value: object) -> None: if isinstance(value, list): @@ -248,6 +247,6 @@ def collect_native_api_selector_refs( for node in nodes: data = node.get("data") if isinstance(data, Mapping): - collect_runtime_fields(cast(Mapping[str, object], data)) + collect_runtime_fields(data) walk(data) return refs diff --git a/api/services/dify_builder/native_api_string_input_binding.py b/api/services/dify_builder/native_api_string_input_binding.py index b6edd6dd56e..6fa742dd491 100644 --- a/api/services/dify_builder/native_api_string_input_binding.py +++ b/api/services/dify_builder/native_api_string_input_binding.py @@ -8,7 +8,6 @@ The caller supplies its existing placeholder grammar; no runtime lookup occurs. from collections import Counter from collections.abc import Mapping, Sequence from re import Pattern -from typing import cast from services.dify_builder.native_api_output_contract import is_native_api_tool @@ -30,7 +29,7 @@ def normalize_native_api_string_bindings( Sources, declarations and original binding objects are never mutated. """ raw = tool_data.get("tool_parameters") - bindings = dict(cast(Mapping[str, object], raw)) if isinstance(raw, Mapping) else {} + bindings: dict[str, object] = dict(raw) if isinstance(raw, Mapping) else {} if not is_native_api_tool(tool_data): return bindings @@ -40,9 +39,10 @@ def normalize_native_api_string_bindings( node_id, data = node.get("id"), node.get("data") if not isinstance(node_id, str) or node_counts[node_id] != 1 or not isinstance(data, Mapping): continue - if data.get("type") != "start" or not isinstance(data.get("variables"), list): + raw_variables = data.get("variables") + if data.get("type") != "start" or not isinstance(raw_variables, list): continue - variables = [item for item in cast(list[object], data["variables"]) if isinstance(item, Mapping)] + variables = [item for item in raw_variables if isinstance(item, Mapping)] counts = Counter(item.get("variable") for item in variables if isinstance(item.get("variable"), str)) for variable in variables: name = variable.get("variable") @@ -66,7 +66,9 @@ def normalize_native_api_string_bindings( selector = binding.get("value") if not isinstance(selector, list) or len(selector) != 2 or not all(isinstance(part, str) for part in selector): continue - source_id, field = cast(list[str], selector) + source_id, field = selector + assert isinstance(source_id, str) + assert isinstance(field, str) # A dot inside the direct field would become a different nested selector # when the native template resolver splits its placeholder on dots. if "." in field or (source_id, field) not in string_selectors: diff --git a/api/services/dify_builder/native_docx_evidence.py b/api/services/dify_builder/native_docx_evidence.py index 5a2f1df235e..da57323d616 100644 --- a/api/services/dify_builder/native_docx_evidence.py +++ b/api/services/dify_builder/native_docx_evidence.py @@ -195,7 +195,6 @@ def _package_evidence(blob: bytes, *, max_uncompressed_bytes: int) -> FileAccept ParseError, DefusedXmlException, RuntimeError, - NotImplementedError, EOFError, ValueError, zlib.error, diff --git a/api/services/dify_builder/native_testing/agent_bridge.py b/api/services/dify_builder/native_testing/agent_bridge.py index fb9f64456b4..027b342b79f 100644 --- a/api/services/dify_builder/native_testing/agent_bridge.py +++ b/api/services/dify_builder/native_testing/agent_bridge.py @@ -9,7 +9,7 @@ import json import time from collections.abc import Callable from dataclasses import dataclass -from typing import Literal, Protocol, cast +from typing import Literal, Protocol from dify_agent.adapters.native_testing.client import AgentMaterialization, AgentPhysicalDispatchPayload from dify_agent.protocol.native_testing import ( @@ -26,7 +26,6 @@ from dify_agent.protocol.native_testing import ( from flask import current_app from sqlalchemy import select from sqlalchemy.orm import Session -from werkzeug.local import LocalProxy from core.dify_builder.models import Actor from extensions.ext_database import db @@ -533,13 +532,10 @@ def claim_agent_endpoint( actual services must still revalidate and claim at dispatch. Other native boundaries remain unavailable. A controller never spends a permit. """ - from flask import g, request + from flask import g + from flask.globals import request_ctx - context = ( - g.get("agent_runtime_context") - if g.get("agent_runtime_request") is cast(LocalProxy, request)._get_current_object() - else None - ) + context = g.get("agent_runtime_context") if g.get("agent_runtime_request") is request_ctx.request else None if context is None: if envelope is not None: raise BuilderExecutionPolicyError("native_endpoint_owner_mismatch") @@ -566,11 +562,12 @@ def claim_agent_endpoint( def require_agent_service_envelope(envelope: AgentPhysicalDispatchPayload | None) -> None: """A marked request cannot enter an ordinary service by omitting its envelope.""" - from flask import g, has_request_context, request + from flask import g, has_request_context + from flask.globals import request_ctx if ( has_request_context() - and g.get("agent_runtime_request") is cast(LocalProxy, request)._get_current_object() + and g.get("agent_runtime_request") is request_ctx.request and isinstance(g.get("agent_runtime_context"), NativeAgentEndpointContext) and envelope is None ): diff --git a/api/services/dify_builder/native_testing/agent_lineage.py b/api/services/dify_builder/native_testing/agent_lineage.py index f2e3983eb7f..a0d623f8a85 100644 --- a/api/services/dify_builder/native_testing/agent_lineage.py +++ b/api/services/dify_builder/native_testing/agent_lineage.py @@ -97,8 +97,6 @@ class NativeAgentEndpointLineage: DifyAgentClientError, ValueError, OSError, - TimeoutError, - BuilderExecutionPolicyError, ): raise BuilderExecutionPolicyError("native_agent_identity_unavailable") from None if ( diff --git a/api/services/dify_builder/native_testing/agent_preparation.py b/api/services/dify_builder/native_testing/agent_preparation.py index c75e939222b..59bd13cac6a 100644 --- a/api/services/dify_builder/native_testing/agent_preparation.py +++ b/api/services/dify_builder/native_testing/agent_preparation.py @@ -148,7 +148,7 @@ class AgentPreparationService: actual = AgentRuntimeIdentity.model_validate( self.backend.read_run_identity_sync(payload.agent_run_id, selection).model_dump(mode="json") ) - except (DifyAgentClientError, ValueError, BuilderExecutionPolicyError): + except (DifyAgentClientError, ValueError): raise BuilderExecutionPolicyError("native_agent_identity_unavailable") from None if ( actual.run_id, diff --git a/api/services/dify_builder/native_testing/api_service.py b/api/services/dify_builder/native_testing/api_service.py index 8f18c0d9b0c..3d23f920755 100644 --- a/api/services/dify_builder/native_testing/api_service.py +++ b/api/services/dify_builder/native_testing/api_service.py @@ -7,9 +7,10 @@ fail closed before admission. No default profile or ordinary executor is used. from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Literal, Protocol, cast +from typing import Literal, Protocol from uuid import uuid4 +from pydantic import TypeAdapter from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker @@ -61,6 +62,9 @@ from services.dify_builder.native_testing.repository import ( ) from services.dify_builder.native_testing.state_store import NativeExecutionStateStore, native_payload_read_scope +_WAIT_REASON = TypeAdapter(WaitReason) +_OPTIONAL_WAIT_REASON = TypeAdapter(WaitReason | None) + class NativeLaunchPort(Protocol): """Qualified initial dispatch owner must durably deduplicate by request ID.""" @@ -470,7 +474,7 @@ class NativeTestApiService: pending.append( PendingActionDisclosure( pending_operation_id=row.id, - wait_reason=cast(WaitReason, row.category), + wait_reason=_WAIT_REASON.validate_python(row.category), version=row.generation + 1, scope_digest=None, account_label=None, @@ -531,7 +535,7 @@ class NativeTestApiService: else "running" if native_run or root.state in {"cancelled", "queued"} else "prepared", - wait_reason=cast(WaitReason | None, root.wait_reason) + wait_reason=_OPTIONAL_WAIT_REASON.validate_python(root.wait_reason) if cancellation is None and root.state == "waiting" else None, native_run_id=native_run.id if native_run else None, diff --git a/api/services/dify_builder/native_testing/confirmed_agent_commands.py b/api/services/dify_builder/native_testing/confirmed_agent_commands.py index a58860e9503..c5f4d8961fb 100644 --- a/api/services/dify_builder/native_testing/confirmed_agent_commands.py +++ b/api/services/dify_builder/native_testing/confirmed_agent_commands.py @@ -8,7 +8,7 @@ never admits another parent or child execution. import logging from collections.abc import Callable from datetime import UTC -from typing import Literal, cast +from typing import Literal from celery.exceptions import SoftTimeLimitExceeded from dify_agent.protocol.execution_binding import KeepAliveExecutionBindingRequest, KeepAliveExecutionBindingResponse @@ -1340,8 +1340,8 @@ class ConfirmedAgentCommands: "wait_reason": waiting.wait_reason, }, ) - elif status in {"succeeded", "failed", "partial-succeeded", "stopped"}: - native_status = cast(Literal["succeeded", "failed", "partial-succeeded", "stopped"], status) + elif status in ("succeeded", "failed", "partial-succeeded", "stopped"): + native_status: Literal["succeeded", "failed", "partial-succeeded", "stopped"] = status outcome = NativeWorkflowOutcome(status="completed", **{**values, "native_status": native_status}) else: outcome = NativeWorkflowOutcome(status="unknown", **{**values, "reason_code": "native_resume_unknown"}) @@ -1554,7 +1554,7 @@ class ProfiledNativeTestCommands: return self.confirmed_factory() if confirmed else self.legacy_factory() def reconcile_pending( - self, *, actor, session_id, request_id: str, publish: Callable[..., None], limit: int = 100 + self, *, actor, session_id, request_id: str, publish: Callable[..., object], limit: int = 100 ) -> int: service = self._service(actor=actor, session_id=session_id, request_id=request_id) lifecycle = service.api.lifecycle if isinstance(service, ConfirmedAgentCommands) else service.lifecycle diff --git a/api/services/dify_builder/native_testing/context_runtime.py b/api/services/dify_builder/native_testing/context_runtime.py index c3c2eef0c6b..518f8559d41 100644 --- a/api/services/dify_builder/native_testing/context_runtime.py +++ b/api/services/dify_builder/native_testing/context_runtime.py @@ -12,7 +12,7 @@ from collections.abc import Generator from contextlib import AbstractContextManager, contextmanager from contextvars import Context, ContextVar, Token from threading import local -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from flask import Flask, has_app_context @@ -89,9 +89,11 @@ class NativeExecutionContext: def __init__(self, *, guard: NativeObservationGuard) -> None: captured = capture_flask_context() if has_app_context() else capture_current_context() - if type(guard) is not NativeObservationGuard or type(captured) not in (ExecutionContext, FlaskExecutionContext): + if type(guard) is not NativeObservationGuard or ( + type(captured) is not ExecutionContext and type(captured) is not FlaskExecutionContext + ): raise NativeObservationError() from None - self._captured = cast(ExecutionContext | FlaskExecutionContext, captured) + self._captured = captured self._guard = guard self._local = _NativeContextManagers() diff --git a/api/services/dify_builder/native_testing/control.py b/api/services/dify_builder/native_testing/control.py index 35ef3658781..64e34807584 100644 --- a/api/services/dify_builder/native_testing/control.py +++ b/api/services/dify_builder/native_testing/control.py @@ -10,7 +10,7 @@ from dataclasses import dataclass from datetime import datetime from functools import cache from sys import exc_info -from typing import Any, Literal, Protocol, cast, runtime_checkable +from typing import Any, ClassVar, Literal, Protocol, runtime_checkable from uuid import uuid4 from pydantic import ValidationError @@ -186,17 +186,11 @@ class BoundaryOwnerState: NativeEventStream = Generator[NodeEvent | ContainerAwaitRequest] -class _NativeNodeDelegate(Protocol): - def __init__(self, *args: Any, **kwargs: Any) -> None: ... - - def run(self) -> NativeEventStream: ... - - def resume_container(self, *, result: ContainerRunResult, started_at: datetime) -> NativeEventStream: ... - - class _NativeControlBoundary: """Mixin selected only by marked composition, never by registry replacement.""" + _native_control_node_class: ClassVar[type[Node]] + def __init__( self, *args: Any, @@ -205,19 +199,21 @@ class _NativeControlBoundary: **kwargs: Any, ) -> None: if ( - type(native_control_owner) is not BoundaryOwnerState + not isinstance(self, Node) + or type(native_control_owner) is not BoundaryOwnerState or not isinstance(native_control_sink, NativeControlSink) or not callable(native_control_sink.record_control) ): raise ValueError("native_control_owner_and_sink_required") self._native_control_owner = native_control_owner self._native_control_sink = native_control_sink - cast(_NativeNodeDelegate, super()).__init__(*args, **kwargs) + self._native_control_node = self + self._native_control_node_class.__init__(self._native_control_node, *args, **kwargs) def _abort_control(self, reason_code: str) -> None: """Contain only public abort-call failure; never relabel node failures.""" try: - cast(Node, self).runtime_state.graph_execution.abort(reason_code) + self._native_control_node.runtime_state.graph_execution.abort(reason_code) except BaseException: # No callback/host failure may escape a native worker. Native # settlement is unqualified if this public abort itself fails. @@ -225,7 +221,7 @@ class _NativeControlBoundary: def _control_execution_id(self) -> str | None: try: - execution_id = cast(Node, self).execution_id + execution_id = self._native_control_node.execution_id except RuntimeError: execution_id = "" if not execution_id: @@ -235,7 +231,7 @@ class _NativeControlBoundary: return execution_id def _record_control(self, control: NativeApprovalWait | NativePolicyStop, execution_id: str) -> NativeEventStream: - node = cast(Node, self) + node = self._native_control_node owner = self._native_control_owner record = NativeControlRecord( control=control, node_id=node.id, native_execution_id=execution_id, carrier_ref=uuid4().hex @@ -282,7 +278,7 @@ class _NativeControlBoundary: yield True return try: - scope = ownership.node_scope(node=cast(Node, self), phase=phase) + scope = ownership.node_scope(node=self._native_control_node, phase=phase) scope.__enter__() except BaseException: self._native_control_owner._unknown("native_control_observation_failed") @@ -308,7 +304,7 @@ class _NativeControlBoundary: if not entered: return try: - yield from cast(_NativeNodeDelegate, super()).run() + yield from self._native_control_node_class.run(self._native_control_node) except (NativeApprovalWait, NativePolicyStop) as control: yield from self._record_control(control, execution_id) @@ -320,7 +316,9 @@ class _NativeControlBoundary: if not entered: return try: - yield from cast(_NativeNodeDelegate, super()).resume_container(result=result, started_at=started_at) + yield from self._native_control_node_class.resume_container( + self._native_control_node, result=result, started_at=started_at + ) except (NativeApprovalWait, NativePolicyStop) as control: yield from self._record_control(control, execution_id) @@ -330,10 +328,13 @@ def _adapt_concrete_node(node_class: type[Node]) -> type[Node]: # Cache concrete classes only: no host/ref/sink/runtime captured in closure. # Native subclass registration increments the public registry version, so # downstream mapping caches must honor Graphon's version invalidation. - return cast( - type[Node], - type(f"NativeControl{node_class.__name__}", (_NativeControlBoundary, node_class), {"__module__": __name__}), + adapted = type( + f"NativeControl{node_class.__name__}", + (_NativeControlBoundary, node_class), + {"__module__": __name__, "_native_control_node_class": node_class}, ) + assert issubclass(adapted, Node) + return adapted def marked_native_node_class(node_class: type[Node], *, marked: bool) -> type[Node]: diff --git a/api/services/dify_builder/native_testing/host_execution.py b/api/services/dify_builder/native_testing/host_execution.py index 657da0fe30c..679fc449b34 100644 --- a/api/services/dify_builder/native_testing/host_execution.py +++ b/api/services/dify_builder/native_testing/host_execution.py @@ -23,7 +23,7 @@ from dataclasses import dataclass from math import isfinite from threading import Lock, RLock, Thread, current_thread from time import monotonic -from typing import Literal, Protocol, cast, override, runtime_checkable +from typing import Literal, Protocol, override, runtime_checkable from core.app.entities.app_invoke_entities import WorkflowAppGenerateEntity from core.repositories.sqlalchemy_workflow_execution_repository import ( @@ -455,7 +455,6 @@ class NativeHostLaunch: from libs.sensitive_http_telemetry import sensitive_http_telemetry from services.dify_builder.native_testing.context_runtime import NATIVE_LOG_EMITTERS from services.dify_builder.native_testing.observation_owner import create_http_observation_guard - from services.dify_builder.native_testing.state_store import NativeExecutionStateStore try: with self._registration: @@ -475,7 +474,7 @@ class NativeHostLaunch: raise NativeObservationError() with sensitive_http_telemetry(additional_emitters=NATIVE_LOG_EMITTERS): guard = create_http_observation_guard( - state=cast(NativeExecutionStateStore, state), + state=state, context=context, on_failure=self.mark_unknown, ) diff --git a/api/services/dify_builder/native_testing/lifecycle.py b/api/services/dify_builder/native_testing/lifecycle.py index 9c8672bbdf8..c0388d33022 100644 --- a/api/services/dify_builder/native_testing/lifecycle.py +++ b/api/services/dify_builder/native_testing/lifecycle.py @@ -9,9 +9,9 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta from re import fullmatch -from typing import TYPE_CHECKING, Literal, Protocol, Self, cast +from typing import TYPE_CHECKING, Literal, Protocol, Self -from pydantic import StrictBool, model_validator +from pydantic import StrictBool, TypeAdapter, model_validator from sqlalchemy import select from sqlalchemy.orm import Session @@ -69,6 +69,8 @@ from services.dify_builder.native_testing.transitions import ( if TYPE_CHECKING: from services.dify_builder.native_testing.host_capacity import NativeHostCapacity +_OUTCOME = TypeAdapter(Outcome) + def revoke_request_authority(session, *, request_id: str, tenant_id: str, cancellation_key: str) -> tuple[str, ...]: """SQL-only revocation shared by Cancel and conversation Clear. @@ -493,9 +495,8 @@ class NativeTestLifecycleService: ): raise BuilderExecutionPolicyError("native_wait_conflict") reasons = {row.category for row in rows} - reason = cast( - WaitReason, next(reason for reason in ("configuration", "interaction", "approval") if reason in reasons) - ) + priority: tuple[WaitReason, ...] = ("configuration", "interaction", "approval") + reason = next(reason for reason in priority if reason in reasons) state.state, state.checkpoint_id = "waiting", checkpoint.checkpoint_id state.pending_ids, state.wait_reason = list(pending_operation_ids), reason self._request_row(session, ref.request_id).state = "waiting" @@ -838,7 +839,9 @@ class NativeTestLifecycleService: request_id=dispatch.request_id, generation=dispatch.generation, dispatch_id=dispatch.id, status=status ) - def publish_pending(self, *, publish: Callable[..., None], limit: int = 100, request_id: str | None = None) -> int: + def publish_pending( + self, *, publish: Callable[..., object], limit: int = 100, request_id: str | None = None + ) -> int: """Every unclaimed outbox remains retryable, including lost broker acknowledgements.""" with self.loader.session_factory() as session: query = ( @@ -1596,8 +1599,7 @@ class NativeTestLifecycleService: ): if operation.invocation_id not in by_id: raise BuilderExecutionPolicyError("native_owner_mismatch") - operation_states[operation.id] = cast( - Outcome, + operation_states[operation.id] = _OUTCOME.validate_python( "unknown" if operation.state in {"claimed", "unknown"} else "not_dispatched" diff --git a/api/services/dify_builder/native_testing/observation_owner.py b/api/services/dify_builder/native_testing/observation_owner.py index d5a7337cdc7..501c179cfb2 100644 --- a/api/services/dify_builder/native_testing/observation_owner.py +++ b/api/services/dify_builder/native_testing/observation_owner.py @@ -7,7 +7,7 @@ from sqlalchemy import select from models.dify_builder import DifyBuilderExecutionRequest, DifyBuilderTestInput from runtime_contracts.builder.execution import canonical_digest from runtime_contracts.builder.native.contracts import NativeTestContextRef -from runtime_contracts.builder.native.runtime_contracts import ResolvedNativeTestContext +from runtime_contracts.builder.native.runtime_contracts import NativeStatePort, ResolvedNativeTestContext from services.dify_builder.native_testing.context_binding import NativeRuntimeProfile from services.dify_builder.native_testing.http_binding import _current_metadata from services.dify_builder.native_testing.observation_adapters import NativeObservationError, NativeObservationGuard @@ -115,7 +115,7 @@ def _stored_profile( def create_http_observation_guard( - *, state: NativeExecutionStateStore, context: ResolvedNativeTestContext, on_failure: Callable[[], None] + *, state: NativeStatePort | None, context: ResolvedNativeTestContext, on_failure: Callable[[], None] ) -> NativeObservationGuard: """Qualify only the current protected root HTTP profile; no substitute inputs. diff --git a/api/services/dify_builder/native_testing/observation_privacy.py b/api/services/dify_builder/native_testing/observation_privacy.py index 1c5b224519a..388520a685b 100644 --- a/api/services/dify_builder/native_testing/observation_privacy.py +++ b/api/services/dify_builder/native_testing/observation_privacy.py @@ -12,7 +12,7 @@ from __future__ import annotations import math from collections.abc import Mapping, Sequence from datetime import datetime, timezone -from typing import Any, Literal, NoReturn, Self, cast, overload +from typing import Any, Literal, NoReturn, Self, overload from graphon.engine.layer import ExecutionLimitsLayer, Layer from graphon.engine_events import ( @@ -241,8 +241,7 @@ def _shape(value: Any, *, depth: int = 0, budget: list[int] | None = None) -> di "length": len(value), "items": shapes[:8] if depth < 3 else [], } - _refuse() - raise AssertionError("unreachable") + return _refuse() def _pause(reason: Any) -> SchedulingPause: @@ -434,8 +433,7 @@ class NativeHttpObservationPolicy: return error if type(error) is str or isinstance(error, BaseException): return "native_execution_error" - _refuse() - raise AssertionError("unreachable") + return _refuse() def graph(self, graph: Mapping[str, Any]) -> dict[str, Any]: """Project only the pinned graph; expose topology, never node configuration.""" @@ -638,9 +636,9 @@ class NativeHttpObservationPolicy: _refuse() fields[name] = value elif name == "node_title": - if type(value) is not str: + if type(value) is not str or not isinstance(event, NodeEvent): _refuse() - fields[name] = cast(NodeEvent, event).node_type + fields[name] = event.node_type elif name in {"provider_type", "provider_id"}: if type(value) is not str: _refuse() @@ -669,7 +667,9 @@ class NativeHttpObservationPolicy: fields[name] = _REDACTED elif name == "selector": _shape(value) - fields[name] = [cast(NodeEvent, event).node_id, _REDACTED] + if not isinstance(event, NodeEvent): + _refuse() + fields[name] = [event.node_id, _REDACTED] elif name == "source_handle": if value is not None and type(value) is not str: _refuse() diff --git a/api/services/dify_builder/native_testing/observation_stream.py b/api/services/dify_builder/native_testing/observation_stream.py index ab4866a55ba..016dd91458b 100644 --- a/api/services/dify_builder/native_testing/observation_stream.py +++ b/api/services/dify_builder/native_testing/observation_stream.py @@ -3,7 +3,7 @@ from collections.abc import Mapping from copy import deepcopy from datetime import datetime, timezone -from typing import Any, cast +from typing import Any from core.app.entities import queue_entities as q from core.app.entities.app_invoke_entities import AppGenerateEntity @@ -182,7 +182,7 @@ def project_native_queue_event[E: q.AppQueueEvent]( validate_projection_host(host=native_host, guard=guard) event = native_host.diagnostic_event(event=event) - cls: type[q.AppQueueEvent] = type(event) + cls: type[E] = type(event) if cls not in _SUPPORTED or event.model_extra or event.event != cls.model_fields["event"].default: guard.fail() payload: Any = event @@ -278,4 +278,4 @@ def project_native_queue_event[E: q.AppQueueEvent]( fields[name] = value else: guard.fail() - return cast(E, cls.model_validate(fields)) + return cls.model_validate(fields) diff --git a/api/services/dify_builder/native_testing/sandbox/qualification.py b/api/services/dify_builder/native_testing/sandbox/qualification.py index 8e9ac8827ab..0c4e6eb7a8b 100644 --- a/api/services/dify_builder/native_testing/sandbox/qualification.py +++ b/api/services/dify_builder/native_testing/sandbox/qualification.py @@ -15,7 +15,7 @@ from dataclasses import asdict, dataclass from importlib.metadata import version from inspect import getsource from types import MappingProxyType -from typing import Annotated, NoReturn, Protocol, cast, runtime_checkable +from typing import Annotated, NoReturn, Protocol, runtime_checkable from urllib.parse import urlsplit from uuid import NAMESPACE_URL, uuid5 @@ -270,7 +270,9 @@ def supported(raw): refuse() -def describe_code_resource(*, qualification, node_id, raw, workflow_revision, app_id, workflow_id): +def describe_code_resource( + *, qualification, node_id, raw, workflow_revision, app_id, workflow_id +) -> dict[str, JsonValue]: if type(qualification) is not NativeCodeQualification: refuse() node, cls = supported(raw) @@ -287,7 +289,7 @@ def describe_code_resource(*, qualification, node_id, raw, workflow_revision, ap "graphon_version": version("graphon"), "implementation_revision": qualification.profile().implementation_revision, "qualification": qualification.descriptor(), - "code_limits": asdict(code_limits()), + "code_limits": dict[str, JsonValue](asdict(code_limits())), "template_limit": template_limit(), "transformer_digest": canonical_digest( { @@ -322,7 +324,7 @@ def _node(graph, node_id): return nodes[0] -def _entries(*, qualification, graph, revision, app_id, workflow_id): +def _entries(*, qualification, graph, revision, app_id, workflow_id) -> tuple[dict[str, JsonValue], ...]: # Other effect families are unavailable in this profile. Actual scalar # schemas/classes continue to be qualified by the existing native host. if any(n.get("data", {}).get("type") in {"http-request", "agent"} for n in graph.get("nodes", [])): @@ -368,15 +370,12 @@ class NativeCodeResourceSnapshot: ) if json.loads(workflow.graph) != graph or workflow._environment_variables not in (None, "", "[]", "{}"): refuse() - return cast( - tuple[dict[str, JsonValue], ...], - _entries( - qualification=self.qualification, - graph=graph, - revision=native_workflow_revision(workflow), - app_id=app_id, - workflow_id=workflow_id, - ), + return _entries( + qualification=self.qualification, + graph=graph, + revision=native_workflow_revision(workflow), + app_id=app_id, + workflow_id=workflow_id, ) diff --git a/api/services/dify_builder/native_testing/sandbox/sync_bridge.py b/api/services/dify_builder/native_testing/sandbox/sync_bridge.py index 3f911edcba8..ea504c0bdbd 100644 --- a/api/services/dify_builder/native_testing/sandbox/sync_bridge.py +++ b/api/services/dify_builder/native_testing/sandbox/sync_bridge.py @@ -14,7 +14,7 @@ from contextlib import suppress from dataclasses import dataclass from math import isfinite from time import monotonic, sleep -from typing import Any, cast +from typing import Any from gevent.monkey import get_original @@ -30,6 +30,10 @@ class _Selector(selectors.SelectSelector): class _Loop(asyncio.SelectorEventLoop): + _internal_fds: int + _add_reader: Callable[[int, Callable[[], None]], asyncio.Handle] + _read_from_self: Callable[[], None] + def __init__(self): super().__init__(selector=_Selector()) @@ -40,8 +44,8 @@ class _Loop(asyncio.SelectorEventLoop): self._ssock.setblocking(False) self._csock.setblocking(False) # CPython 3.13 private selector internals; pinned-process witness required. - cast(Any, self)._internal_fds += 1 - cast(Any, self)._add_reader(self._ssock.fileno(), cast(Any, self)._read_from_self) + self._internal_fds += 1 + self._add_reader(self._ssock.fileno(), self._read_from_self) class _MailboxBusyError(Exception): diff --git a/api/services/dify_builder/native_testing/state_store.py b/api/services/dify_builder/native_testing/state_store.py index 27c92f37573..7802ceaf29a 100644 --- a/api/services/dify_builder/native_testing/state_store.py +++ b/api/services/dify_builder/native_testing/state_store.py @@ -132,7 +132,7 @@ def decode_payload( if any(envelope.get(k) != v for k, v in expected.items()) or canonical_digest(envelope) != digest: raise ValueError("ownership or integrity") return envelope["payload"] - except (ValueError, TypeError, UnicodeError): + except (ValueError, TypeError): raise BuilderExecutionPolicyError("native_payload_invalid") from None diff --git a/api/services/dify_builder/real_call_consent_service.py b/api/services/dify_builder/real_call_consent_service.py index 7d36aaeb803..25f6b1857a2 100644 --- a/api/services/dify_builder/real_call_consent_service.py +++ b/api/services/dify_builder/real_call_consent_service.py @@ -51,7 +51,7 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from itertools import starmap -from typing import Any, Literal, cast +from typing import Any, Literal, TypeGuard from uuid import UUID, uuid4 from pydantic import ValidationError @@ -136,9 +136,6 @@ _WORKING_STATE_FOR: dict[PcState, PcState] = { PcState.FIX_AWAIT_VERIFY: PcState.FIX_VERIFY, PcState.FIX_AWAIT_DECISION: PcState.FIX_VERIFY, } -_STATES = frozenset( - {"prepared", "confirmed", "claimed", "completed", "failed", "unknown", "declined", "expired", "invalidated"} -) _RETRYABLE = frozenset({"declined", "expired", "invalidated"}) _FINAL_OUTCOMES = frozenset({"completed", "failed", "unknown"}) _DIGEST = re.compile(r"[0-9a-f]{64}\Z") @@ -161,7 +158,7 @@ def _refuse(reason: str) -> BuilderExecutionPolicyError: return BuilderExecutionPolicyError(reason) -def _canonical_uuid(value: object) -> bool: +def _canonical_uuid(value: object) -> TypeGuard[str]: if type(value) is not str or len(value) != 36: return False try: @@ -173,7 +170,7 @@ def _canonical_uuid(value: object) -> bool: def _uuid(value: object, reason: str) -> str: if not _canonical_uuid(value): raise _refuse(reason) - return cast(str, value) + return value def _bounded(value: object, reason: str) -> str: @@ -335,9 +332,20 @@ class StrandedObservation: def _state(row: DifyBuilderRealCallConsent) -> ConsentState: - if row.state not in _STATES: + state = row.state + if state not in ( + "prepared", + "confirmed", + "claimed", + "completed", + "failed", + "unknown", + "declined", + "expired", + "invalidated", + ): raise _refuse("consent_record_invalid") - return cast(ConsentState, row.state) + return state def _stored_binding(row: DifyBuilderRealCallConsent) -> ConsentBinding: @@ -1614,22 +1622,20 @@ class BuilderRealCallConsentService: raise _refuse("refresh_conflict") original, original_expiry = credential.encrypted_credentials, credential.expires_at # Compare-and-set on the exact snapshot the revision bound under the row lock. - result = cast( - CursorResult, - db.execute( - update(BuiltinToolProvider) - .where( - BuiltinToolProvider.id == owned.credential_id, - BuiltinToolProvider.tenant_id == actor.tenant_id, - BuiltinToolProvider.encrypted_credentials.is_(None) - if original is None - else BuiltinToolProvider.encrypted_credentials == original, - BuiltinToolProvider.expires_at == original_expiry, - ) - .values(encrypted_credentials=encrypted_credentials, expires_at=expires_at) - .execution_options(synchronize_session=False) - ), + result = db.execute( + update(BuiltinToolProvider) + .where( + BuiltinToolProvider.id == owned.credential_id, + BuiltinToolProvider.tenant_id == actor.tenant_id, + BuiltinToolProvider.encrypted_credentials.is_(None) + if original is None + else BuiltinToolProvider.encrypted_credentials == original, + BuiltinToolProvider.expires_at == original_expiry, + ) + .values(encrypted_credentials=encrypted_credentials, expires_at=expires_at) + .execution_options(synchronize_session=False) ) + assert isinstance(result, CursorResult) if result.rowcount != 1: raise _refuse("refresh_conflict") db.expire(credential) diff --git a/api/services/dify_builder/repair_gate.py b/api/services/dify_builder/repair_gate.py index 28e5307cc3d..14352737c64 100644 --- a/api/services/dify_builder/repair_gate.py +++ b/api/services/dify_builder/repair_gate.py @@ -1,9 +1,7 @@ """Stage Fix proposals and distinguish repair approval from an empty proposal.""" -from typing import cast - from core.dify_builder.contract import Action, ActionKind, DecisionItem -from core.dify_builder.models import Diagnosis, DifyBuilderContext, Session, Turn +from core.dify_builder.models import DifyBuilderContext, Session, Turn from core.dify_builder.progress import ProgressReporter from core.dify_builder.state import PcState from runtime_contracts.builder.copy_ids import BuilderCopyId @@ -52,6 +50,7 @@ def handle_propose(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> graph, revision = env.dify.read_graph(s.app_id, turn.actor) progress.activate("fix-propose-repair") # Both proposal states are entered after their diagnosis has committed. + assert fc.diagnosis is not None if fc.source != "checklist" and fc.failed_run_id: from services.dify_builder.condition_evidence import propose_failure @@ -60,14 +59,12 @@ def handle_propose(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> s.app_id, turn.actor, env.repo.get_run(fc.failed_run_id), - cast(Diagnosis, fc.diagnosis), + fc.diagnosis, graph, task=(s, fc, revision), ) else: - intents, risk = env.agent.propose_repair( - cast(Diagnosis, fc.diagnosis), graph, reply_language=fc.reply_language or None - ) + intents, risk = env.agent.propose_repair(fc.diagnosis, graph, reply_language=fc.reply_language or None) fc.staged_repair = intents fc.risk = risk diff --git a/api/services/dify_builder/repository.py b/api/services/dify_builder/repository.py index 0287c978d9a..e6a972352d9 100644 --- a/api/services/dify_builder/repository.py +++ b/api/services/dify_builder/repository.py @@ -26,7 +26,6 @@ import dataclasses from collections.abc import Callable, Iterable, Iterator from datetime import timedelta from itertools import islice -from typing import cast from sqlalchemy import func, select, update from sqlalchemy.engine import CursorResult @@ -908,15 +907,13 @@ class SqlDifyBuilderRepository: # replacement can take ownership without changing this session's head. self._lock_live_insert(db_session, session_id) new_version = base_version + 1 - result = cast( - CursorResult, - db_session.execute( - update(DifyBuilderSession) - .where(DifyBuilderSession.id == session_id, DifyBuilderSession.version == base_version) - .values(version=DifyBuilderSession.version + 1, current_state=str(next)) - .execution_options(synchronize_session=False) - ), + result = db_session.execute( + update(DifyBuilderSession) + .where(DifyBuilderSession.id == session_id, DifyBuilderSession.version == base_version) + .values(version=DifyBuilderSession.version + 1, current_state=str(next)) + .execution_options(synchronize_session=False) ) + assert isinstance(result, CursorResult) if result.rowcount == 0: if db_session.get(DifyBuilderSession, session_id) is None: diff --git a/api/services/dify_builder/restricted_execution.py b/api/services/dify_builder/restricted_execution.py index 18d026d45d9..10c72c1d700 100644 --- a/api/services/dify_builder/restricted_execution.py +++ b/api/services/dify_builder/restricted_execution.py @@ -208,7 +208,7 @@ def _valid_request(url: Any, kwargs: Mapping[str, Any]) -> bool: return False canonical_digest(dict(kwargs)) return len(json.dumps(kwargs, ensure_ascii=False, allow_nan=False).encode("utf-8")) <= _MAX_TEXT_BYTES - except (BuilderExecutionPolicyError, ValueError, TypeError, UnicodeError, RecursionError): + except (BuilderExecutionPolicyError, ValueError, TypeError, RecursionError): return False diff --git a/api/services/dify_builder/session_lock.py b/api/services/dify_builder/session_lock.py index 66154154d11..80564d98019 100644 --- a/api/services/dify_builder/session_lock.py +++ b/api/services/dify_builder/session_lock.py @@ -10,7 +10,6 @@ uses an explicit SET-NX-token + Lua compare-del, the same pattern used by from __future__ import annotations -from typing import cast from uuid import uuid4 from configs import dify_config @@ -104,5 +103,5 @@ def exists(session_id: str) -> bool: def current_owner(session_id: str) -> str | None: """Current dispatch/worker lease value, or None once the command is released.""" - value = cast(str | bytes | None, redis_client.get(_key(session_id))) + value: str | bytes | None = redis_client.get(_key(session_id)) return value.decode() if isinstance(value, bytes) else value diff --git a/api/services/dify_builder/task_lifecycle.py b/api/services/dify_builder/task_lifecycle.py index 3df1c9bc54f..cb83aeac1bb 100644 --- a/api/services/dify_builder/task_lifecycle.py +++ b/api/services/dify_builder/task_lifecycle.py @@ -10,7 +10,7 @@ from collections.abc import Generator from contextlib import contextmanager from contextvars import ContextVar from dataclasses import asdict -from typing import TYPE_CHECKING, Literal, cast +from typing import TYPE_CHECKING, Literal from pydantic import TypeAdapter @@ -112,8 +112,10 @@ def validated_task_summaries(values: object) -> list[TaskSummary]: def _test_evidence(run: Run | None) -> TaskTestEvidence: if run is None: return TaskTestEvidence() - status = run.status if run.status in {"running", "succeeded", "failed", "unknown", "stopped"} else "unknown" - result = TaskTestEvidence(run_id=run.id, native_run_id=run.dify_run_id, status=cast(TaskEvidenceStatus, status)) + status: TaskEvidenceStatus = ( + run.status if run.status in ("running", "succeeded", "failed", "unknown", "stopped") else "unknown" + ) + result = TaskTestEvidence(run_id=run.id, native_run_id=run.dify_run_id, status=status) verification = run.verification if verification is not None: result.execution_revision = verification.execution_revision diff --git a/api/services/dify_builder/template_execution.py b/api/services/dify_builder/template_execution.py index 35460cee1c5..2cb1eb5e851 100644 --- a/api/services/dify_builder/template_execution.py +++ b/api/services/dify_builder/template_execution.py @@ -8,7 +8,7 @@ the native adapter repeats inspection and bounds all selected values and output. import math import re from collections.abc import Iterator, Mapping -from typing import Any, NoReturn, cast, override +from typing import Any, NoReturn, override from jinja2 import Environment, nodes @@ -213,7 +213,8 @@ def validate_template_configuration(data: Mapping[str, Any]) -> None: ): _refuse("unsupported_template_selector") names.append(variable["variable"]) - inspect_template(cast(str, template), _names(names)) + assert isinstance(template, str) + inspect_template(template, _names(names)) class _Evaluation: @@ -239,7 +240,8 @@ class _Evaluation: if type(node) is nodes.TemplateData: return node.data if type(node) is nodes.Filter: - return _scalar_text(self.expression(cast(nodes.Expr, node.node))).strip() + assert node.node is not None + return _scalar_text(self.expression(node.node)).strip() if type(node) is nodes.Not: return not self.expression(node.node) if type(node) is nodes.And: diff --git a/api/services/dify_builder/wiring.py b/api/services/dify_builder/wiring.py index e2c4110efac..c2826563d5f 100644 --- a/api/services/dify_builder/wiring.py +++ b/api/services/dify_builder/wiring.py @@ -10,7 +10,7 @@ import logging import re import time from collections.abc import Callable, Generator, Iterator, Sequence -from typing import Any, cast +from typing import Any from flask import current_app from pydantic import TypeAdapter, ValidationError @@ -84,9 +84,7 @@ _WORKFLOW_PAYLOAD_ADAPTER = TypeAdapter(WorkflowStreamPayload) def _enqueue(session_id: str, action: Action, actor: Actor, token: str) -> None: - cast(Callable[..., Any], advance_session.delay)( - session_id, dataclasses.asdict(action), dataclasses.asdict(actor), token - ) + advance_session.delay(session_id, dataclasses.asdict(action), dataclasses.asdict(actor), token) def _authorize_app(actor: Actor, app_id: str, access: AppAccess) -> None: diff --git a/api/services/tools/ordinary_tool_credentials.py b/api/services/tools/ordinary_tool_credentials.py index a19b954deae..a205ccb6fc2 100644 --- a/api/services/tools/ordinary_tool_credentials.py +++ b/api/services/tools/ordinary_tool_credentials.py @@ -12,7 +12,7 @@ import json import logging import time from collections.abc import Mapping -from typing import Any, cast +from typing import Any import sqlalchemy as sa from sqlalchemy import select, update @@ -283,7 +283,8 @@ def _persist_ordinary_refresh( try: with Session(db.engine) as session, session.begin(): result = session.execute(statement.execution_options(synchronize_session=False)) - if cast(CursorResult[object], result).rowcount != 1: + assert isinstance(result, CursorResult) + if result.rowcount != 1: raise ToolProviderCredentialValidationError("Tool credential changed during refresh.") except ToolProviderCredentialValidationError: raise diff --git a/api/services/workflow_generation/runner.py b/api/services/workflow_generation/runner.py index 0aefda3d236..e171814cba9 100644 --- a/api/services/workflow_generation/runner.py +++ b/api/services/workflow_generation/runner.py @@ -1555,7 +1555,9 @@ class WorkflowGenerator: stage=stage, validate=validate_config, ) - return cast(dict[str, Any], parsed["config"]) + config = parsed["config"] + assert isinstance(config, dict) + return config @staticmethod def _hydrate_tool_nodes( @@ -3324,7 +3326,7 @@ class WorkflowGenerator: # re-homed every edge it could do so unambiguously; whatever is left # would hang off a handle that does not exist and its arm would never # run, while the run still reports "succeeded" (ESQ1-303). - edges_raw = list(cast(list[dict[str, Any]], graph.get("edges", []))) + edges_raw = [dict(edge) for edge in graph.get("edges", [])] for bad in graph_normalizers.undeclared_branch_handles(nodes, edges_raw): errors.append( _err( diff --git a/api/services/workflow_runtime/agent_node.py b/api/services/workflow_runtime/agent_node.py index e3f66eecc53..2804155ac8d 100644 --- a/api/services/workflow_runtime/agent_node.py +++ b/api/services/workflow_runtime/agent_node.py @@ -4,7 +4,7 @@ import logging from collections.abc import Generator, Mapping, Sequence from contextlib import contextmanager, suppress from time import perf_counter -from typing import TYPE_CHECKING, Any, Literal, cast, override +from typing import TYPE_CHECKING, Any, Literal, override from dify_agent.protocol import CancelRunRequest from dify_agent.protocol.snapshot import SessionSnapshot @@ -70,7 +70,7 @@ from services.workflow_runtime.agent_binding import WorkflowAgentBindingError, W if TYPE_CHECKING: from graphon.runtime import InitParams, RuntimeState from runtime_contracts.builder.native.agent_context import BuilderAgentExecutionContext - from services.agent.workflow_run_recovery import AgentCreateRecoveryClient, WorkflowAgentRunRecovery + from services.agent.workflow_run_recovery import WorkflowAgentRunRecovery from services.dify_builder.native_testing.agent_preparation import AgentPreparationService from services.dify_builder.native_testing.confirmed_agent_commands import ConfirmedAgentCommands @@ -619,7 +619,11 @@ class DifyAgentNode(Node[DifyAgentNodeData]): def _start_or_resume_confirmed(self, *, dify_context, session_scope, stored_session, process_data): from runtime_contracts.builder.execution import BuilderExecutionPolicyError, canonical_digest from runtime_contracts.builder.native.agent_contracts import AgentCreateInserted, AgentParentNodeRef - from services.agent.workflow_run_recovery import AgentCreateAcknowledgementUnknown, AgentCreateScope + from services.agent.workflow_run_recovery import ( + AgentCreateAcknowledgementUnknown, + AgentCreateRecoveryClient, + AgentCreateScope, + ) from services.dify_builder.native_testing.agent_confirmed_workspace import ( load_confirmed_agent_parent, prepare_confirmed_agent_request, @@ -639,6 +643,8 @@ class DifyAgentNode(Node[DifyAgentNodeData]): execution_id=self.execution_id, workflow_binding_id=session_scope.workflow_agent_binding_id, ) + client = self._agent_backend_client + assert isinstance(client, AgentCreateRecoveryClient) with _confirmed_node_phase(marker, "parent_restore"): parent = load_confirmed_agent_parent( state_store=self._agent_preparation.state_store, ref=marker.ref, execution_id=self.execution_id @@ -646,7 +652,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]): with _confirmed_node_phase(marker, "recover_existing"): response = self._agent_run_recovery.recover_existing( scope=scope, - client=cast("AgentCreateRecoveryClient", self._agent_backend_client), + client=client, on_persisted=process_data.update, ) if parent is not None: @@ -702,7 +708,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]): scope=scope, attempt=0, request=prepared.request, - client=cast("AgentCreateRecoveryClient", self._agent_backend_client), + client=client, on_persisted=process_data.update, ) return response, parent, None diff --git a/api/services/workflow_runtime/workflow_runner.py b/api/services/workflow_runtime/workflow_runner.py index b8119c51a17..c4786b0d544 100644 --- a/api/services/workflow_runtime/workflow_runner.py +++ b/api/services/workflow_runtime/workflow_runner.py @@ -3,11 +3,10 @@ from __future__ import annotations import logging import time from collections.abc import Sequence -from typing import TYPE_CHECKING, cast, override +from typing import TYPE_CHECKING, override from core.app.apps.base_app_queue_manager import AppQueueManager from core.app.apps.execution_coordinator import app_task_command_channel_key -from core.app.apps.workflow.app_config_manager import WorkflowAppConfig from core.app.apps.workflow.command_channels import ( CelerySignalCommandChannel, CombinedCommandChannel, @@ -106,7 +105,6 @@ class WorkflowAppRunner(WorkflowBasedAppRunner): def prepare(self) -> PreparedWorkflowRun | None: """Prepare graph execution and passive application observers.""" app_config = self.application_generate_entity.app_config - app_config = cast(WorkflowAppConfig, app_config) entity = self.application_generate_entity native_prepared = None if entity.native_test_required or entity.native_test_ref is not None: diff --git a/api/tests/e2e_tests/containers/core/workflow/builder_native_runtime_diagnostics.py b/api/tests/e2e_tests/containers/core/workflow/builder_native_runtime_diagnostics.py index ae568c23d41..38dca1d1cfd 100644 --- a/api/tests/e2e_tests/containers/core/workflow/builder_native_runtime_diagnostics.py +++ b/api/tests/e2e_tests/containers/core/workflow/builder_native_runtime_diagnostics.py @@ -19,7 +19,7 @@ from collections import Counter from collections.abc import Callable, Generator, Iterator from functools import wraps from threading import Lock -from typing import Protocol, cast +from typing import Protocol, runtime_checkable import pytest @@ -38,6 +38,7 @@ from services.dify_builder.native_testing.runtime import NativeHostResumeBinding from tests.e2e_tests.containers.core.workflow.builder_native_fixture_records import RuntimeDeliveryCase +@runtime_checkable class _ObservedNativeNode(Protocol): _native_control_owner: BoundaryOwnerState @@ -100,7 +101,8 @@ def native_runtime_resume_diagnostics( return # Ordering is explicit: wrap the fixture's synthetic transport only after # runtime_case has installed it. Never replace or bypass that transport. - case = cast(RuntimeDeliveryCase, request.getfixturevalue("runtime_case")) + case = request.getfixturevalue("runtime_case") + assert isinstance(case, RuntimeDeliveryCase) import httpx @@ -204,6 +206,10 @@ def native_runtime_resume_diagnostics( node = original_create(factory, node_config) try: bridge = factory._native_runtime + control_owner = None + if bridge is not None: + assert isinstance(node, _ObservedNativeNode) + control_owner = node._native_control_owner note( "factory_node", node_digest=_digest(node.id), @@ -212,10 +218,7 @@ def native_runtime_resume_diagnostics( required=factory._dify_context.native_test_required, has_ref=factory._dify_context.native_test_ref is not None, native_runtime=type(node.runtime_state) is NativeGraphRuntimeState, - boundary_owner=( - bridge is not None - and cast(_ObservedNativeNode, node)._native_control_owner is bridge._boundary_owner - ), + boundary_owner=bridge is not None and control_owner is bridge._boundary_owner, deferred_http=isinstance(node, HttpRequestNode) and type(node._http_client) is DeferredNativeHttpClient, ) except Exception: diff --git a/api/tests/e2e_tests/containers/core/workflow/test_builder_agent_continuation.py b/api/tests/e2e_tests/containers/core/workflow/test_builder_agent_continuation.py index 7fb9782fa8f..71038da791f 100644 --- a/api/tests/e2e_tests/containers/core/workflow/test_builder_agent_continuation.py +++ b/api/tests/e2e_tests/containers/core/workflow/test_builder_agent_continuation.py @@ -6,13 +6,11 @@ fixture double; this suite proves no physical READY/Redis/Agent execution. from __future__ import annotations -from typing import TYPE_CHECKING, NotRequired, Unpack, cast +from typing import TYPE_CHECKING, NotRequired, Unpack from dify_agent.protocol.native_testing import ReadAgentRuntimeIdentity -from flask import Flask from pydantic import JsonValue from typing_extensions import TypedDict -from werkzeug.local import LocalProxy from runtime_contracts.builder.native.contracts import NativeTestContextRef from services.dify_builder.native_testing.repository import OwnedNativeTestLoader @@ -49,7 +47,7 @@ from dify_agent.protocol.native_testing import ( AgentPrepareRunPayload, AgentRuntimeIdentity, ) -from flask import current_app +from flask.globals import app_ctx from sqlalchemy import select from models.agent import AgentConfigSnapshot, AgentWorkingResourceStatus, AgentWorkspace @@ -140,7 +138,7 @@ def test_real_pg_concurrent_initial_reservation_has_one_local_post_owner( association_case: _fixture_types_0.AgentAssociationCase, ) -> None: c = association_case - app = cast("LocalProxy[Flask]", current_app)._get_current_object() + app = app_ctx.app def attempt(_index) -> AgentCreateExisting | AgentCreateInserted: with app.app_context(): diff --git a/api/tests/e2e_tests/containers/core/workflow/test_builder_agent_workspace.py b/api/tests/e2e_tests/containers/core/workflow/test_builder_agent_workspace.py index 618ddfee927..eb518074c50 100644 --- a/api/tests/e2e_tests/containers/core/workflow/test_builder_agent_workspace.py +++ b/api/tests/e2e_tests/containers/core/workflow/test_builder_agent_workspace.py @@ -7,7 +7,7 @@ protected context storage, and materialized binding/workspace rows are real. from __future__ import annotations from collections.abc import Callable, Generator -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from dify_agent.protocol.execution_binding import CreateExecutionBindingRequest, DestroyExecutionBindingRequest from flask import Flask @@ -309,7 +309,10 @@ def test_postgres_concurrent_prepare_reserves_one_identity(workspace_case: Agent from flask import current_app c = workspace_case - app = cast("LocalProxy[Flask]", current_app)._get_current_object() + proxy: object = current_app + assert isinstance(proxy, LocalProxy) + app = proxy._get_current_object() + assert isinstance(app, Flask) def run_prepare() -> AgentTestWorkspaceRef: with app.app_context(): diff --git a/api/tests/e2e_tests/containers/core/workflow/test_builder_execution_policy_persistence.py b/api/tests/e2e_tests/containers/core/workflow/test_builder_execution_policy_persistence.py index efdfd449cda..136edd8e6cc 100644 --- a/api/tests/e2e_tests/containers/core/workflow/test_builder_execution_policy_persistence.py +++ b/api/tests/e2e_tests/containers/core/workflow/test_builder_execution_policy_persistence.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, TypedDict, cast +from typing import TYPE_CHECKING, TypedDict if TYPE_CHECKING: pass @@ -237,7 +237,9 @@ def test_real_postgres_forward_merge_preserves_both_branches(flask_app_with_cont from extensions.ext_database import db merge_revision = "f9a3c62de418" - current_head = "6c84f1d90235" + current_head = "7d95a2e61346" + main_head = "53c10126d551" + fix_intents_head = "6c84f1d90235" writer_head = "5b73e0c8f124" material_head = "4a62d9b7e013" coordination_head = "e832a94f210b" @@ -256,7 +258,10 @@ def test_real_postgres_forward_merge_preserves_both_branches(flask_app_with_cont config.set_main_option("script_location", str(Path(__file__).parents[5] / "migrations")) scripts = ScriptDirectory.from_config(config) assert scripts.get_heads() == [current_head] - fix_intents_revision = scripts.get_revision(current_head) + main_merge = scripts.get_revision(current_head) + assert main_merge is not None + assert main_merge.down_revision == (fix_intents_head, main_head) + fix_intents_revision = scripts.get_revision(fix_intents_head) assert fix_intents_revision is not None assert fix_intents_revision.down_revision == writer_head writer_revision = scripts.get_revision(writer_head) @@ -353,12 +358,11 @@ def test_real_postgres_forward_merge_preserves_both_branches(flask_app_with_cont return [ RevisionStep.downgrade_from_script(scripts.revision_map, revision) for revision in to_revert ] - # Alembic passes head tuples here; its upgrade planner annotation declares a scalar. - planner = cast( - Callable[[str, tuple[str, ...]], list[RevisionStep]], - scripts._upgrade_revs, - ) - return planner(target, current) + planned = scripts.iterate_revisions(target, current, implicit_base=True) + return [ + RevisionStep.upgrade_from_script(scripts.revision_map, revision) + for revision in reversed(list(planned)) + ] # Main builds its task index concurrently; let Alembic own each transaction # and keep the isolated schema in the session search path across commits. @@ -675,8 +679,8 @@ def test_real_postgres_forward_merge_preserves_both_branches(flask_app_with_cont # run provenance without altering historical values or constraints. parents = MigrationContext.configure(connection, opts={"version_table_schema": schema}) parents.stamp(scripts, coordination_head) - parents.stamp(scripts, knowledge_fs_head) - assert set(parents.get_current_heads()) == {coordination_head, knowledge_fs_head} + parents.stamp(scripts, main_head) + assert set(parents.get_current_heads()) == {coordination_head, main_head} assert migrate("head") == (current_head,) assert native_ledger_witness() == ledger_before assert archive_witness() == archive_before diff --git a/api/tests/e2e_tests/containers/core/workflow/test_builder_json_tool_native.py b/api/tests/e2e_tests/containers/core/workflow/test_builder_json_tool_native.py index c7bdcc2ebb9..337c66a43ea 100644 --- a/api/tests/e2e_tests/containers/core/workflow/test_builder_json_tool_native.py +++ b/api/tests/e2e_tests/containers/core/workflow/test_builder_json_tool_native.py @@ -12,7 +12,7 @@ from collections.abc import Callable, Iterator from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from pathlib import Path -from typing import NotRequired, Protocol, TypedDict, cast +from typing import NotRequired, Protocol, TypedDict, runtime_checkable from uuid import uuid4 import pytest @@ -58,6 +58,7 @@ class _JsonSealCapture(TypedDict): FIXTURE = Path(__file__).parents[4] / "fixtures/workflow/builder_restricted/json_parse.yml" +@runtime_checkable class ThreadTarget(Protocol): _target: Callable[..., object] | None @@ -105,7 +106,8 @@ def test_json_caller_explicit_read_session_closes_before_native_spawn( def capture_spawn(thread: threading.Thread) -> None: from types import FunctionType, MethodType - target = cast(ThreadTarget, thread)._target + assert isinstance(thread, ThreadTarget) + target = thread._target if isinstance(target, (FunctionType, MethodType)) and target.__name__ == "_generate_worker": assert caller_session is not None at_spawn.append(caller_session.in_transaction()) diff --git a/api/tests/e2e_tests/containers/core/workflow/test_builder_native_code_physical_state.py b/api/tests/e2e_tests/containers/core/workflow/test_builder_native_code_physical_state.py index 4d8e8d29d15..9fe500d119f 100644 --- a/api/tests/e2e_tests/containers/core/workflow/test_builder_native_code_physical_state.py +++ b/api/tests/e2e_tests/containers/core/workflow/test_builder_native_code_physical_state.py @@ -150,7 +150,10 @@ def physical_migration(migrated_dispatch_claims: None, flask_app_with_containers config = Config() config.set_main_option("script_location", str(Path(__file__).parents[5] / "migrations")) scripts = ScriptDirectory.from_config(config) - assert scripts.get_heads() == ["6c84f1d90235"] + assert scripts.get_heads() == ["7d95a2e61346"] + main_merge = scripts.get_revision("7d95a2e61346") + assert main_merge is not None + assert main_merge.down_revision == ("6c84f1d90235", "53c10126d551") fix_intents = scripts.get_revision("6c84f1d90235") assert fix_intents is not None assert fix_intents.down_revision == "5b73e0c8f124" @@ -175,7 +178,7 @@ def physical_migration(migrated_dispatch_claims: None, flask_app_with_containers assert merge.down_revision == ("c6f4a90d8b31", "7d2b9c4e61a8") for current in ("base", "c6f4a90d8b31", "7d2b9c4e61a8"): planned = scripts._upgrade_revs("head", current) - assert planned[-1].revision.revision == "6c84f1d90235" + assert planned[-1].revision.revision == "7d95a2e61346" steps = list(scripts.iterate_revisions("7d2b9c4e61a8", "e59b7d32a6c1")) assert [step.revision for step in steps] == ["7d2b9c4e61a8"] for step in reversed(steps): diff --git a/api/tests/e2e_tests/containers/core/workflow/test_builder_native_continuation.py b/api/tests/e2e_tests/containers/core/workflow/test_builder_native_continuation.py index 81aa720edd7..5d0ac35d1be 100644 --- a/api/tests/e2e_tests/containers/core/workflow/test_builder_native_continuation.py +++ b/api/tests/e2e_tests/containers/core/workflow/test_builder_native_continuation.py @@ -7,12 +7,10 @@ from __future__ import annotations from collections.abc import Callable, Generator from multiprocessing.connection import Connection as ProcessConnection -from typing import TYPE_CHECKING, Literal, Never, Unpack, cast, override +from typing import TYPE_CHECKING, Literal, Never, Unpack, override -from flask import Flask from pydantic import JsonValue from sqlalchemy import Engine -from werkzeug.local import LocalProxy from models.workflow import Workflow from runtime_contracts.builder.native.contracts import ApprovalWait, DispatchPermit, EffectIntent, RecordedResult @@ -2584,7 +2582,7 @@ def test_terminal_child_dispatch_stays_terminal_after_ancestor_recovery( def test_child_terminal_settlement_while_recovery_proof_is_held( continuation_case: ContinuationCase, status: Literal["completed", "stopped", "cancelled"] ) -> None: - from flask import current_app + from flask.globals import app_ctx c = continuation_case case = _root_with_child_dispatch(c, claimed=True) @@ -2610,7 +2608,7 @@ def test_child_terminal_settlement_while_recovery_proof_is_held( c.lifecycle.recover_dispatch(ref=c.ref, dispatch_id=root_dispatch.dispatch_id) assert service.get_outcome(child_ref=child) is None assert c.store.load_context(ref=child).ref == child - app = cast("LocalProxy[Flask]", current_app)._get_current_object() + app = app_ctx.app def recover_child() -> None: with app.app_context(): diff --git a/api/tests/e2e_tests/containers/core/workflow/test_builder_native_http_binding.py b/api/tests/e2e_tests/containers/core/workflow/test_builder_native_http_binding.py index b5ec2b02ca0..b4b6a7aec0d 100644 --- a/api/tests/e2e_tests/containers/core/workflow/test_builder_native_http_binding.py +++ b/api/tests/e2e_tests/containers/core/workflow/test_builder_native_http_binding.py @@ -10,12 +10,10 @@ whole-run Graphon trace/privacy/driver activation. from __future__ import annotations from collections.abc import Callable -from typing import TYPE_CHECKING, Literal, NotRequired, Unpack, cast +from typing import TYPE_CHECKING, Literal, NotRequired, Unpack -from flask import Flask from pydantic import JsonValue from typing_extensions import TypedDict -from werkzeug.local import LocalProxy from models.workflow import Workflow from runtime_contracts.builder.native.contracts import DispatchPermit, NativeTestContextRef @@ -376,7 +374,7 @@ def test_real_http_snapshot_and_equal_install_preserve_other_entries(http_case: def test_real_http_conflict_is_immutable_and_concurrent_merge_preserves_entries(http_case: HttpBindingCase) -> None: from threading import Barrier - from flask import current_app + from flask.globals import app_ctx c = http_case first = prepare(c) @@ -390,7 +388,7 @@ def test_real_http_conflict_is_immutable_and_concurrent_merge_preserves_entries( intent=conflict, ) assert bindings(c) == before - app = cast("LocalProxy[Flask]", current_app)._get_current_object() + app = app_ctx.app start = Barrier(2) def prepare_in_worker(_) -> PreparedHttpCase: diff --git a/api/tests/e2e_tests/containers/core/workflow/test_builder_task_controls.py b/api/tests/e2e_tests/containers/core/workflow/test_builder_task_controls.py index 1fdefe5ba8c..b936a0ccd6d 100644 --- a/api/tests/e2e_tests/containers/core/workflow/test_builder_task_controls.py +++ b/api/tests/e2e_tests/containers/core/workflow/test_builder_task_controls.py @@ -11,12 +11,12 @@ import json import socket import threading import time -from collections.abc import Callable, Generator, Iterator +from collections.abc import Generator, Iterator from concurrent.futures import ThreadPoolExecutor from contextvars import ContextVar from copy import deepcopy from dataclasses import asdict, dataclass -from typing import Literal, cast +from typing import Literal from uuid import uuid4 import pytest @@ -66,14 +66,15 @@ type Enqueued = tuple[str, Action, Actor, str] @pytest.fixture(scope="module") def task_postgres_engine() -> Iterator[Engine]: """A disposable PostgreSQL database; never read local application DB config.""" - container = PostgresContainer("postgres:14-alpine", tmpfs={"/var/lib/postgresql/data": "rw,size=256m"}) + container = PostgresContainer("postgres:14-alpine").with_tmpfs_mount( + "/var/lib/postgresql/data", size="rw,size=256m" + ) # Match the migration witnesses: Docker Desktop's empty HostPort mapping - # can accept and reset TCP connections. Bind an OS-selected IPv4 port. + # can accept and reset TCP connections. Bind an OS-selected host port. with socket.socket() as probe: probe.bind(("127.0.0.1", 0)) port = probe.getsockname()[1] - bind = cast(Callable[[int, tuple[str, int]], object], container.with_bind_ports) - bind(5432, ("127.0.0.1", port)) + container.with_bind_ports(5432, port) container.waiting_for( ExecWaitStrategy( [ diff --git a/api/tests/e2e_tests/contracts/migrations/test_knowledge_fs_migration_chain.py b/api/tests/e2e_tests/contracts/migrations/test_knowledge_fs_migration_chain.py index 0f079b1e744..5d5a5d89e02 100644 --- a/api/tests/e2e_tests/contracts/migrations/test_knowledge_fs_migration_chain.py +++ b/api/tests/e2e_tests/contracts/migrations/test_knowledge_fs_migration_chain.py @@ -35,6 +35,7 @@ def test_knowledge_fs_chain_ends_in_the_single_head() -> None: assert scripts.get_heads() == ["7d95a2e61346"] current = scripts.get_revision("7d95a2e61346") assert current is not None + assert isinstance(current.down_revision, tuple) assert set(current.down_revision) == {"6c84f1d90235", "53c10126d551"} fix_intents = scripts.get_revision("6c84f1d90235") assert fix_intents is not None diff --git a/api/tests/test_containers_integration_tests/migrations/test_builder_native_migration_dialects.py b/api/tests/test_containers_integration_tests/migrations/test_builder_native_migration_dialects.py index 7a38be3b2ac..bfba6537a8c 100644 --- a/api/tests/test_containers_integration_tests/migrations/test_builder_native_migration_dialects.py +++ b/api/tests/test_containers_integration_tests/migrations/test_builder_native_migration_dialects.py @@ -1,12 +1,11 @@ """Real database migration parity for retained native execution evidence.""" import importlib.util -from collections.abc import Callable, Iterator +from collections.abc import Iterator from datetime import UTC, datetime from io import StringIO from pathlib import Path from types import ModuleType -from typing import cast from uuid import uuid4 import pytest @@ -43,11 +42,9 @@ def migrated_connection(request: pytest.FixtureRequest, free_tcp_port: int) -> I if request.param == "postgresql" else MySqlContainer("mysql:8.0", dialect="pymysql", username="root", password="test") ) - # Docker accepts host-IP tuples; testcontainers annotates only scalar ports. # Use an OS-assigned unused port: Docker's empty HostPort allocation collided - # on the Linux CI daemon. Loopback also avoids Docker Desktop proxy resets. - bind = cast(Callable[[int, tuple[str, int]], object], container.with_bind_ports) - bind(container.port, ("127.0.0.1", free_tcp_port)) + # on the Linux CI daemon. + container.with_bind_ports(container.port, free_tcp_port) probe = ( 'PGPASSWORD="$POSTGRES_PASSWORD" psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" ' '--host 127.0.0.1 -tAc "SELECT 1"' diff --git a/api/tests/unit_tests/celery_fixtures.py b/api/tests/unit_tests/celery_fixtures.py index d8ea73e48a1..f80b8ccd9e4 100644 --- a/api/tests/unit_tests/celery_fixtures.py +++ b/api/tests/unit_tests/celery_fixtures.py @@ -1,7 +1,7 @@ """Restore Celery app ownership after tests initialize a real Flask-backed app.""" from collections.abc import Iterator -from typing import Protocol, cast +from typing import Protocol, runtime_checkable import pytest from celery import Celery, _state @@ -11,6 +11,7 @@ class _CeleryThreadState(Protocol): current_app: Celery | None +@runtime_checkable class _CeleryGlobalState(Protocol): default_app: Celery | None _tls: _CeleryThreadState @@ -23,7 +24,8 @@ class _CeleryGlobalState(Protocol): @pytest.fixture def preserve_celery_apps() -> Iterator[None]: # Celery owns these state exports; its bundled typing omits their private names. - state = cast(_CeleryGlobalState, _state) + assert isinstance(_state, _CeleryGlobalState) + state = _state previous_default = state.default_app previous_current = state._tls.current_app try: diff --git a/api/tests/unit_tests/controllers/console/test_dify_builder_routes.py b/api/tests/unit_tests/controllers/console/test_dify_builder_routes.py index 72f062b75e9..cfcd95c16b1 100644 --- a/api/tests/unit_tests/controllers/console/test_dify_builder_routes.py +++ b/api/tests/unit_tests/controllers/console/test_dify_builder_routes.py @@ -12,7 +12,7 @@ from datetime import UTC, datetime from inspect import unwrap from pathlib import Path from types import FunctionType -from typing import Annotated, NamedTuple, NoReturn, Self, TypedDict, cast, get_args, override +from typing import Annotated, NamedTuple, NoReturn, Self, get_args, override from unittest.mock import Mock, call import pytest @@ -67,7 +67,6 @@ from models.base import Base, TypeBase from models.model import App, AppMode, DifySetup from services.dify_builder import wiring from services.dify_builder.application.execution_policy import BuilderExecutionPolicyError -from services.dify_builder.application.ports import Repository from services.dify_builder.real_call_consent_service import BuilderRealCallConsentService from services.dify_builder.repository import SqlDifyBuilderRepository from services.dify_builder.service import DifyBuilderService, SessionView @@ -1229,21 +1228,39 @@ def _decorator_chain(method: Callable[..., object]) -> list[str]: names: list[str] = [] def record(wrapper: object) -> bool: - names.append(cast(FunctionType, wrapper).__code__.co_qualname.split(".")[0]) + assert isinstance(wrapper, FunctionType) + names.append(wrapper.__code__.co_qualname.split(".")[0]) return False unwrap(method, stop=record) return names -class _ApiDoc(TypedDict, total=False): - expect: list[SchemaModel] - params: dict[str, object] - responses: dict[str, tuple[str, SchemaModel, dict[str, object]]] +def _apidoc(method: Callable[..., object]) -> dict[str, object]: + documentation = vars(method)["__apidoc__"] + assert isinstance(documentation, dict) + return documentation -def _apidoc(method: Callable[..., object]) -> _ApiDoc: - return cast(_ApiDoc, vars(method)["__apidoc__"]) +def _schema_names(value: object) -> list[str]: + assert isinstance(value, list) + names: list[str] = [] + for model in value: + assert isinstance(model, SchemaModel) + names.append(model.name) + return names + + +def _response_model_names(value: object) -> dict[str, str]: + assert isinstance(value, dict) + names: dict[str, str] = {} + for code, response in value.items(): + assert isinstance(code, str) + assert isinstance(response, tuple) + model = response[1] + assert isinstance(model, SchemaModel) + names[code] = model.name + return names def test_execute_cannot_change_parameters_or_forge_credentials() -> None: @@ -1736,7 +1753,7 @@ def _coordinator( authorized.append(f"{actor.account_id}:{app_id}:{access}") service = DifyBuilderService( - cast(Repository, repo), + repo, lease, enqueue, authorize_app_fn=authorize, @@ -1944,7 +1961,7 @@ def test_consent_route_docs_declare_bodies_only_for_posts() -> None: mod.DifyBuilderRealCallConsentExecuteApi.post: "DifyBuilderExecuteRealCallPayload", } for method, name in expected_bodies.items(): - assert [model.name for model in _apidoc(method)["expect"]] == [name] + assert _schema_names(_apidoc(method)["expect"]) == [name] status_doc = _apidoc(mod.DifyBuilderRealCallConsentApi.get) assert "expect" not in status_doc assert "params" not in status_doc @@ -1955,12 +1972,12 @@ def test_consent_route_docs_declare_bodies_only_for_posts() -> None: mod.DifyBuilderRealCallConsentExecuteApi.post: "202", } for method, code in success.items(): - responses = _apidoc(method)["responses"] - assert responses[code][1].name == "DifyBuilderRealCallConsentResponse" + responses = _response_model_names(_apidoc(method)["responses"]) + assert responses[code] == "DifyBuilderRealCallConsentResponse" # Every 403 that can reach these routes, not only the domain refusal. - assert responses["403"][1].name == "DifyBuilderRealCallForbiddenResponse" - assert responses["404"][1].name == "DifyBuilderErrorResponse" - assert responses["409"][1].name == "DifyBuilderRealCallRefusalResponse" + assert responses["403"] == "DifyBuilderRealCallForbiddenResponse" + assert responses["404"] == "DifyBuilderErrorResponse" + assert responses["409"] == "DifyBuilderRealCallRefusalResponse" @pytest.mark.parametrize( @@ -1991,7 +2008,8 @@ def test_consent_routes_propagate_current_tenant_and_user_through_decorators( with Flask(__name__).test_request_context(method="GET" if body is None else "POST", json=body): result = view(object(), session_id=_SESSION_ID, **path_kwargs) - assert cast(tuple[dict[str, object], int], result)[1] == status + assert isinstance(result, tuple) + assert result[1] == status called = installed_mock(consent_method).call_args assert called.kwargs["actor"] == Actor(account_id="acc-9", tenant_id="ten-9") assert called.kwargs["session_id"] == _SESSION_ID @@ -2374,7 +2392,7 @@ def request_sql_world( enqueued.append((session_id, action, actor, token)) service = DifyBuilderService( - cast(Repository, repo), + repo, _ObservedLease(), enqueue, authorize_app_fn=wiring._authorize_app, @@ -2642,8 +2660,8 @@ def test_task_control_routes_delegate_fixed_operation_and_return_session_json( "dify_builder_required", ] docs = _apidoc(resource.post) - assert [model.name for model in docs["expect"]] == ["DifyBuilderTaskControlPayload"] - assert docs["responses"]["200"][1].name == "DifyBuilderSessionViewResponse" + assert _schema_names(docs["expect"]) == ["DifyBuilderTaskControlPayload"] + assert _response_model_names(docs["responses"])["200"] == "DifyBuilderSessionViewResponse" assert "x-typed-event-stream" not in docs diff --git a/api/tests/unit_tests/controllers/inner_api/agent/test_llm.py b/api/tests/unit_tests/controllers/inner_api/agent/test_llm.py index 04ed47ac63f..93974917bdd 100644 --- a/api/tests/unit_tests/controllers/inner_api/agent/test_llm.py +++ b/api/tests/unit_tests/controllers/inner_api/agent/test_llm.py @@ -5,15 +5,14 @@ import warnings from collections.abc import Generator from contextlib import contextmanager from copy import deepcopy -from typing import cast, override +from typing import override from unittest.mock import patch from uuid import uuid4 import pytest -from flask import Flask, Request, g -from flask import request as flask_request +from flask import Flask, g +from flask.globals import request_ctx from pydantic import JsonValue -from werkzeug.local import LocalProxy from controllers.inner_api import bp as inner_api_bp from core.errors.error import QuotaExceededError @@ -360,7 +359,7 @@ def test_native_stream_error_is_bounded_even_if_provider_raises_private_text( def admitted_transport() -> None: # This test exercises the emitter after the independently tested runtime admission guard. g.agent_runtime_admitted = True - g.agent_runtime_request = cast(LocalProxy[Request], flask_request)._get_current_object() + g.agent_runtime_request = request_ctx.request with ( _agent_inner_auth(), @@ -500,7 +499,7 @@ def test_native_model_failure_stage_survives_real_gateway_without_private_text( @app.before_request def admitted_transport() -> None: g.agent_runtime_admitted = True - g.agent_runtime_request = cast(LocalProxy[Request], flask_request)._get_current_object() + g.agent_runtime_request = request_ctx.request with _agent_inner_auth(): response = app.test_client().post("/inner/api/agent/llm/invoke", json=request.model_dump(mode="json")) diff --git a/api/tests/unit_tests/controllers/inner_api/test_agent_config_runtime_access.py b/api/tests/unit_tests/controllers/inner_api/test_agent_config_runtime_access.py index 7896e2a78d7..1f4e66698e3 100644 --- a/api/tests/unit_tests/controllers/inner_api/test_agent_config_runtime_access.py +++ b/api/tests/unit_tests/controllers/inner_api/test_agent_config_runtime_access.py @@ -34,11 +34,15 @@ def test_legacy_config_route_refuses_native_header_before_read(app: Flask) -> No service.assert_not_called() -def test_confirmed_config_route_validates_read_before_existing_service(app: Flask) -> None: +def test_confirmed_config_route_validates_read_before_existing_service( + app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: lineage, _, transport, _ = owner() original = lineage.backend.read_run_identity_sync - lineage.backend.read_run_identity_sync = lambda run_id, selection: original(run_id, selection).model_copy( - update={"native_test_profile": "confirmed"} + monkeypatch.setattr( + lineage.backend, + "read_run_identity_sync", + lambda run_id, selection: original(run_id, selection).model_copy(update={"native_test_profile": "confirmed"}), ) app.extensions["native_agent_endpoint_lineage"] = lineage assert transport.parent_node is not None @@ -66,11 +70,13 @@ def test_confirmed_config_route_validates_read_before_existing_service(app: Flas service.return_value.manifest.assert_called_once() -def test_config_mutation_stays_closed_for_confirmed_runtime(app: Flask) -> None: +def test_config_mutation_stays_closed_for_confirmed_runtime(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: lineage, _, transport, _ = owner() original = lineage.backend.read_run_identity_sync - lineage.backend.read_run_identity_sync = lambda run_id, selection: original(run_id, selection).model_copy( - update={"native_test_profile": "confirmed"} + monkeypatch.setattr( + lineage.backend, + "read_run_identity_sync", + lambda run_id, selection: original(run_id, selection).model_copy(update={"native_test_profile": "confirmed"}), ) app.extensions["native_agent_endpoint_lineage"] = lineage headers = { diff --git a/api/tests/unit_tests/core/app/apps/test_builder_execution_admission.py b/api/tests/unit_tests/core/app/apps/test_builder_execution_admission.py index fad53b562e2..f6b96877cc8 100644 --- a/api/tests/unit_tests/core/app/apps/test_builder_execution_admission.py +++ b/api/tests/unit_tests/core/app/apps/test_builder_execution_admission.py @@ -4,15 +4,14 @@ from __future__ import annotations from collections.abc import Generator from dataclasses import dataclass -from typing import Never, cast +from typing import Never import pytest import pytest_mock from _pytest.monkeypatch import MonkeyPatch -from flask import Flask +from flask.globals import app_ctx from sqlalchemy import Connection, Engine, Select from sqlalchemy.orm import Session -from werkzeug.local import LocalProxy from core.app.entities.app_invoke_entities import ( AppGenerateEntity, @@ -535,7 +534,6 @@ def test_worker_readmission_rejects_reload_races_inside_queue_lifecycle( import threading from contextlib import contextmanager - from flask import current_app from sqlalchemy import delete import services.workflow_runtime.workflow_generator as module @@ -609,7 +607,7 @@ def test_worker_readmission_rejects_reload_races_inside_queue_lifecycle( worker = threading.Thread( target=module.WorkflowAppGenerator(execution_driver=_forbidden_driver)._generate_worker, kwargs={ - "flask_app": cast(LocalProxy[Flask], current_app)._get_current_object(), + "flask_app": app_ctx.app, "application_generate_entity": entity, "queue_manager": queue, "context": contextvars.copy_context(), @@ -683,8 +681,6 @@ def test_worker_claims_once_and_shares_recorder_with_runner_and_completion( import contextvars from contextlib import nullcontext - from flask import current_app - import services.workflow_runtime.workflow_generator as module from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager from core.app.entities.app_invoke_entities import WorkflowAppGenerateEntity @@ -718,7 +714,7 @@ def test_worker_claims_once_and_shares_recorder_with_runner_and_completion( queue = workflow_queue module.WorkflowAppGenerator(execution_driver=record_driver)._generate_worker( - flask_app=cast(LocalProxy[Flask], current_app)._get_current_object(), + flask_app=app_ctx.app, application_generate_entity=entity, queue_manager=queue, context=contextvars.copy_context(), @@ -798,10 +794,6 @@ def test_native_worker_outer_finally_acknowledges_context_entry_failure( import contextvars import threading from contextlib import contextmanager - from typing import cast - - from flask import current_app - from werkzeug.local import LocalProxy import services.workflow_runtime.workflow_generator as module from core.app.entities.app_invoke_entities import WorkflowAppGenerateEntity @@ -820,7 +812,7 @@ def test_native_worker_outer_finally_acknowledges_context_entry_failure( entity = WorkflowAppGenerateEntity.model_construct( builder_execution=marker, workflow_execution_id="allocated", task_id="task" ) - app = cast(LocalProxy[Flask], current_app)._get_current_object() + app = app_ctx.app errors: list[str] = [] def target() -> None: diff --git a/api/tests/unit_tests/core/dify_builder/test_execution_policy.py b/api/tests/unit_tests/core/dify_builder/test_execution_policy.py index 95fae7287de..ac272423a43 100644 --- a/api/tests/unit_tests/core/dify_builder/test_execution_policy.py +++ b/api/tests/unit_tests/core/dify_builder/test_execution_policy.py @@ -3,7 +3,7 @@ import importlib from collections.abc import Mapping from types import ModuleType -from typing import TYPE_CHECKING, NoReturn, cast +from typing import TYPE_CHECKING, NoReturn import pytest from pydantic import ValidationError @@ -236,7 +236,8 @@ def test_http_default_large_integer_refused_before_native_validation( p = policy() raw_graph = graph(http=True) - data = cast(dict[str, object], raw_graph["nodes"][1]["data"]) + data = raw_graph["nodes"][1]["data"] + assert isinstance(data, dict) data["default_value"] = [{"type": "number", "key": "status_code", "value": value}] assert p.canonical_digest(raw_graph) @@ -256,7 +257,8 @@ def test_http_default_large_integer_refused_before_native_validation( def test_http_default_finite_number_preserves_raw_binding(value: int | float) -> None: p = policy() raw_graph = graph(http=True) - data = cast(dict[str, object], raw_graph["nodes"][1]["data"]) + data = raw_graph["nodes"][1]["data"] + assert isinstance(data, dict) data["default_value"] = [{"type": "number", "key": "status_code", "value": value}] bindings = p.admitted_node_bindings(snapshot(raw_graph)) assert bindings[1].normalized_config_digest == p.canonical_digest({**data, "version": "1"}) diff --git a/api/tests/unit_tests/core/dify_builder/test_form_fields.py b/api/tests/unit_tests/core/dify_builder/test_form_fields.py index 0f5a62a3175..d37fa31dfde 100644 --- a/api/tests/unit_tests/core/dify_builder/test_form_fields.py +++ b/api/tests/unit_tests/core/dify_builder/test_form_fields.py @@ -76,7 +76,7 @@ _FLOWS = [ def _query_env( - mode: str = "advanced-chat", variables: list[Graph] | None = None + monkeypatch: pytest.MonkeyPatch, mode: str = "advanced-chat", variables: list[Graph] | None = None ) -> tuple[FixtureEnv, InMemoryRepository, Session, DifyBuilderContext]: dify = FakeDifyPort() dify.app_mode = mode @@ -88,7 +88,7 @@ def _query_env( _ = prior_failed return {v["variable"]: "mock message" for v in schema["variables"] if v["type"] not in {"file", "file-list"}} - agent.generate_mock_inputs = generate_mock_inputs + monkeypatch.setattr(agent, "generate_mock_inputs", generate_mock_inputs) repo = InMemoryRepository() env = FixtureEnv(dify=dify, agent=agent, repo=repo, now=lambda: datetime.min) return ( @@ -119,10 +119,12 @@ def _form(result: StepResult) -> Graph: @pytest.mark.parametrize("flow", _FLOWS) @pytest.mark.parametrize("variables", [[], [{"variable": "query", "type": "text-input", "required": True}]]) def test_chatflow_form_has_a_distinct_required_system_message( - flow: tuple[Handler, str, Handler, Handler, PcState, PcState], variables: list[Graph] + monkeypatch: pytest.MonkeyPatch, + flow: tuple[Handler, str, Handler, Handler, PcState, PcState], + variables: list[Graph], ) -> None: gate, action, _prepare, _run, _await_state, _success = flow - env, _, session, fc = _query_env(variables=variables) + env, _, session, fc = _query_env(monkeypatch, variables=variables) before = copy.deepcopy(env.fake_dify.graph) result = gate(env, _query_turn(action), session, fc) fields = _form(result)["fields"] @@ -137,21 +139,26 @@ def test_chatflow_form_has_a_distinct_required_system_message( @pytest.mark.parametrize("flow", _FLOWS) @pytest.mark.parametrize("query", [None, " ", 7]) def test_invalid_chatflow_query_returns_to_inputs_before_launch( - flow: tuple[Handler, str, Handler, Handler, PcState, PcState], query: str | int | None + monkeypatch: pytest.MonkeyPatch, + flow: tuple[Handler, str, Handler, Handler, PcState, PcState], + query: str | int | None, ) -> None: _gate, _action, prepare, run, await_state, _success = flow env, repo, session, fc = _query_env( + monkeypatch, variables=[ {"variable": "report_pdf", "type": "file", "required": True}, {"variable": "endpoint", "type": "text-input"}, - ] + ], ) supplied = {"sys.query": query, "report_pdf": {"id": "file-1"}, "endpoint": "https://example.com"} prepared = prepare(env, _query_turn(inputs=supplied), session, fc) assert prepared.next == await_state assert fc.test_input_ref == "" assert _form(prepared)["values"] == supplied - env.fake_dify.run_draft = lambda *_args, **_kwargs: pytest.fail("invalid query reached runtime") + monkeypatch.setattr( + env.fake_dify, "run_draft", lambda *_args, **_kwargs: pytest.fail("invalid query reached runtime") + ) ti = TestInput(session_id=session.id, inputs=supplied) repo.save_test_input(ti) fc.test_input_ref = ti.id @@ -166,11 +173,13 @@ def test_invalid_chatflow_query_returns_to_inputs_before_launch( @pytest.mark.parametrize("flow", _FLOWS) @pytest.mark.parametrize("mode", ["advanced-chat", "workflow"]) def test_mock_and_manual_inputs_share_mode_aware_schema_hash( - flow: tuple[Handler, str, Handler, Handler, PcState, PcState], mode: str + monkeypatch: pytest.MonkeyPatch, flow: tuple[Handler, str, Handler, Handler, PcState, PcState], mode: str ) -> None: _gate, _action, prepare, run, _await_state, success = flow env, repo, session, fc = _query_env( - mode=mode, variables=[{"variable": "query", "type": "text-input"}, {"variable": "report_pdf", "type": "file"}] + monkeypatch, + mode=mode, + variables=[{"variable": "query", "type": "text-input"}, {"variable": "report_pdf", "type": "file"}], ) prepare(env, _query_turn(mode="mock"), session, fc) mocked = repo.get_test_input(fc.test_input_ref) @@ -189,14 +198,17 @@ def test_mock_and_manual_inputs_share_mode_aware_schema_hash( @pytest.mark.parametrize("flow", _FLOWS) def test_chatflow_saved_workflow_inputs_are_stale( + monkeypatch: pytest.MonkeyPatch, flow: tuple[Handler, str, Handler, Handler, PcState, PcState], ) -> None: _gate, _action, prepare, run, await_state, _success = flow - env, repo, session, fc = _query_env(mode="workflow") + env, repo, session, fc = _query_env(monkeypatch, mode="workflow") prepare(env, _query_turn(inputs={"query": "custom"}), session, fc) old_hash = repo.get_test_input(fc.test_input_ref).start_schema_hash env.fake_dify.app_mode = "advanced-chat" - env.fake_dify.run_draft = lambda *_args, **_kwargs: pytest.fail("stale inputs reached runtime") + monkeypatch.setattr( + env.fake_dify, "run_draft", lambda *_args, **_kwargs: pytest.fail("stale inputs reached runtime") + ) result = run(env, _query_turn(), session, fc) assert result.next in {PcState.BUILD_AWAIT_REPAIR, PcState.EDIT_AWAIT_REPAIR, PcState.FIX_AWAIT_DECISION} assert fc.test_input_ref != "" @@ -224,15 +236,18 @@ def test_query_related_graph_errors_still_need_repair(error: str) -> None: @pytest.mark.parametrize("flow", _FLOWS) def test_chatflow_rejects_a_stale_schema_even_when_query_is_valid( + monkeypatch: pytest.MonkeyPatch, flow: tuple[Handler, str, Handler, Handler, PcState, PcState], ) -> None: _gate, _action, prepare, run, await_state, _success = flow - env, repo, session, fc = _query_env() + env, repo, session, fc = _query_env(monkeypatch) prepare(env, _query_turn(inputs={"sys.query": "message", "report_pdf": {"id": "file-1"}}), session, fc) env.fake_dify.graph["nodes"][0]["data"]["variables"] = [ {"variable": "topic", "type": "text-input", "required": True} ] - env.fake_dify.run_draft = lambda *_args, **_kwargs: pytest.fail("stale input contract reached runtime") + monkeypatch.setattr( + env.fake_dify, "run_draft", lambda *_args, **_kwargs: pytest.fail("stale input contract reached runtime") + ) result = run(env, _query_turn(), session, fc) assert result.next in {PcState.BUILD_AWAIT_REPAIR, PcState.EDIT_AWAIT_REPAIR, PcState.FIX_AWAIT_DECISION} assert result.run is not None @@ -242,10 +257,13 @@ def test_chatflow_rejects_a_stale_schema_even_when_query_is_valid( @pytest.mark.parametrize("flow", _FLOWS) def test_workflow_legacy_inputs_and_reserved_looking_variable_remain_ordinary( + monkeypatch: pytest.MonkeyPatch, flow: tuple[Handler, str, Handler, Handler, PcState, PcState], ) -> None: _gate, _action, _prepare, run, _await_state, success = flow - env, repo, session, fc = _query_env(mode="workflow", variables=[{"variable": "sys.query", "type": "text-input"}]) + env, repo, session, fc = _query_env( + monkeypatch, mode="workflow", variables=[{"variable": "sys.query", "type": "text-input"}] + ) saved = TestInput(session_id=session.id, inputs={"sys.query": 7, "query": "custom"}) repo.save_test_input(saved) fc.test_input_ref = saved.id @@ -254,13 +272,15 @@ def test_workflow_legacy_inputs_and_reserved_looking_variable_remain_ordinary( assert env.fake_dify.run_draft_inputs == saved.inputs -def test_workflow_fix_input_failures_keep_the_existing_decision_route() -> None: +def test_workflow_fix_input_failures_keep_the_existing_decision_route(monkeypatch: pytest.MonkeyPatch) -> None: from core.dify_builder.models import Run - env, repo, session, fc = _query_env(mode="workflow") + env, repo, session, fc = _query_env(monkeypatch, mode="workflow") prepare = handlers_fix.handle_await_testdata prepare(env, _query_turn(inputs={"query": "custom"}), session, fc) - env.fake_dify.run_draft = lambda *_args, **_kwargs: Run(status="failed", error="missing input: custom field") + monkeypatch.setattr( + env.fake_dify, "run_draft", lambda *_args, **_kwargs: Run(status="failed", error="missing input: custom field") + ) result = handlers_fix.handle_verify(env, _query_turn(), session, fc) assert result.next == PcState.FIX_AWAIT_DECISION diff --git a/api/tests/unit_tests/core/dify_builder/test_handlers_build.py b/api/tests/unit_tests/core/dify_builder/test_handlers_build.py index 7c9d03134d9..ccf00ae5269 100644 --- a/api/tests/unit_tests/core/dify_builder/test_handlers_build.py +++ b/api/tests/unit_tests/core/dify_builder/test_handlers_build.py @@ -126,16 +126,20 @@ def test_build_form_fields_whitelists_and_coerces_type() -> None: assert fields[1].type == "text" # unknown type coerced to text -def test_capability_check_renders_agent_fields() -> None: +def test_capability_check_renders_agent_fields(monkeypatch: pytest.MonkeyPatch) -> None: # StubAgent.analyze_goal returns dynamic fields; the form card and # fc.form_fields must reflect them (not a hardcoded constant). from services.dify_builder.application.handlers_build import handle_capability_check env, _ = _new_env() - env.agent.analyze_goal = lambda goal_text, *, reply_language=None: { # noqa: ARG005 - "fields": [{"key": "categories", "label": "Categories", "type": "text"}], - "values": {"categories": "billing, refunds"}, - } + monkeypatch.setattr( + env.agent, + "analyze_goal", + lambda goal_text, *, reply_language=None: { # noqa: ARG005 + "fields": [{"key": "categories", "label": "Categories", "type": "text"}], + "values": {"categories": "billing, refunds"}, + }, + ) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_CAPABILITY_CHECK) fc = DifyBuilderContext(goal_text="triage support tickets") result = handle_capability_check(env, Turn(actor=_actor()), s, fc) @@ -652,7 +656,7 @@ def test_plan_approval_ignores_non_approve_action() -> None: assert res.next == PcState.BUILD_PLAN_APPROVAL -def test_plan_approval_empty_build_surfaces_error_and_keeps_canvas() -> None: +def test_plan_approval_empty_build_surfaces_error_and_keeps_canvas(monkeypatch: pytest.MonkeyPatch) -> None: """If build_nodes yields no create_node intents (generation ultimately failed), the handler must NOT delete the placeholder start, must NOT claim a successful build, and must stay in plan_approval -- otherwise the canvas is emptied while @@ -679,7 +683,7 @@ def test_plan_approval_empty_build_surfaces_error_and_keeps_canvas() -> None: del plan_items, resource_ids, trusted_text, reply_language return BuildNodesResult(intents=[], error="UNRESOLVED_REFERENCE: Reference {#node4.x#} not declared") - env.agent.build_nodes = build_nodes + monkeypatch.setattr(env.agent, "build_nodes", build_nodes) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_PLAN_APPROVAL) fc = DifyBuilderContext(plan_items=["x"]) @@ -693,7 +697,9 @@ def test_plan_approval_empty_build_surfaces_error_and_keeps_canvas() -> None: assert "Workflow draft created" not in english_text(assistant.payload["reply_text"]) # no false success claim -def test_plan_approval_records_diagnostics_in_backend_logs(caplog: pytest.LogCaptureFixture) -> None: +def test_plan_approval_records_diagnostics_in_backend_logs( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: """Structured graph-generation diagnostics stay backend-only.""" from services.dify_builder.application.handlers_build import handle_plan_approval from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort @@ -727,7 +733,7 @@ def test_plan_approval_records_diagnostics_in_backend_logs(caplog: pytest.LogCap intents=[], error="Reference {#node2.response#} not declared on node 'node2'", diagnostics=diag ) - env.agent.build_nodes = build_nodes + monkeypatch.setattr(env.agent, "build_nodes", build_nodes) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_PLAN_APPROVAL) fc = DifyBuilderContext(plan_items=["x"]) @@ -739,7 +745,7 @@ def test_plan_approval_records_diagnostics_in_backend_logs(caplog: pytest.LogCap assert record.__dict__["diagnostics"] == diag -def test_plan_approval_deletes_pre_existing_start_on_from_scratch_build() -> None: +def test_plan_approval_deletes_pre_existing_start_on_from_scratch_build(monkeypatch: pytest.MonkeyPatch) -> None: from core.dify_builder.models import MutationIntent from services.dify_builder.application.handlers_build import handle_plan_approval from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort @@ -776,7 +782,7 @@ def test_plan_approval_deletes_pre_existing_start_on_from_scratch_build() -> Non ] ) - env.agent.build_nodes = build_nodes + monkeypatch.setattr(env.agent, "build_nodes", build_nodes) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_PLAN_APPROVAL) fc = DifyBuilderContext(plan_items=["x"]) handle_plan_approval(env, Turn(actor=_actor(), action=Action(kind="approve_repair")), s, fc) @@ -787,7 +793,9 @@ def test_plan_approval_deletes_pre_existing_start_on_from_scratch_build() -> Non assert types == {"start", "end"} # exactly one start -def test_plan_approval_survives_generator_reusing_the_deleted_placeholder_start_id() -> None: +def test_plan_approval_survives_generator_reusing_the_deleted_placeholder_start_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: """M2 fix (final review, Minor/latent): on a from-scratch build, delete_intents drops the draft's placeholder start(s) by id, and _already_present filters generator creates against the PRE-delete existing_node_ids. If the generator's @@ -831,7 +839,7 @@ def test_plan_approval_survives_generator_reusing_the_deleted_placeholder_start_ ] ) - env.agent.build_nodes = build_nodes + monkeypatch.setattr(env.agent, "build_nodes", build_nodes) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_PLAN_APPROVAL) fc = DifyBuilderContext(plan_items=["x"]) handle_plan_approval(env, Turn(actor=_actor(), action=Action(kind="approve_repair")), s, fc) @@ -979,7 +987,9 @@ def test_test_and_repair_fail_routes_to_await_repair_with_staged_repair() -> Non ("", False), ], ) -def test_test_and_repair_fail_with_no_proposed_repair_still_routes_to_gate(reason: str, expected_reason: bool) -> None: +def test_test_and_repair_fail_with_no_proposed_repair_still_routes_to_gate( + monkeypatch: pytest.MonkeyPatch, reason: str, expected_reason: bool +) -> None: """When propose_repair finds no safe fix (empty intents), the fail path must still route to the gate, but WITHOUT a change_set card, and with the "no safe automatic fix" reply_text variant -- the `if intents` branch the @@ -997,7 +1007,7 @@ def test_test_and_repair_fail_with_no_proposed_repair_still_routes_to_gate(reaso _ = diagnosis, graph, reply_language return ([], Risk(level="high", reason=reason, has_external_side_effect=False)) - env.agent.propose_repair = propose_repair + monkeypatch.setattr(env.agent, "propose_repair", propose_repair) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_TEST_AND_REPAIR) fc = DifyBuilderContext(built_node_ids=["llm"]) result = handle_test_and_repair(env, Turn(actor=_actor()), s, fc) @@ -1145,6 +1155,7 @@ def test_test_and_repair_model_config_failure_surfaces_without_repair() -> None: def test_missing_proof_running_result_stops_at_review( + monkeypatch: pytest.MonkeyPatch, mocker: pytest_mock.MockerFixture, ) -> None: """A truncated verify result requires review and never enters diagnosis.""" @@ -1157,11 +1168,15 @@ def test_missing_proof_running_result_stops_at_review( mocker.patch.object(agent, "diagnose", side_effect=lambda *a, **kw: diagnose_calls.append((a, kw))) env, _ = _new_env(agent=agent) env.fake_dify = FakeBuildDifyPort() - env.fake_dify.run_draft = lambda *_a, **_k: Run( - dify_run_id="", - status="running", - per_node=[], - error="the workflow run's progress stream ended before the run did, so its outcome is unknown", + monkeypatch.setattr( + env.fake_dify, + "run_draft", + lambda *_a, **_k: Run( + dify_run_id="", + status="running", + per_node=[], + error="the workflow run's progress stream ended before the run did, so its outcome is unknown", + ), ) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_TEST_AND_REPAIR) fc = DifyBuilderContext(built_node_ids=["llm"]) @@ -1184,7 +1199,7 @@ def test_missing_proof_running_result_stops_at_review( assert "Execution safety requires review" in english_text(assistant.payload["reply_text"]) -def test_test_and_repair_run_draft_raises_routes_to_await_repair_failed() -> None: +def test_test_and_repair_run_draft_raises_routes_to_await_repair_failed(monkeypatch: pytest.MonkeyPatch) -> None: """run_draft raising must not crash the advance -- the try/except degrade path converts the exception into a failed run and still routes to the build.await_repair gate.""" @@ -1193,7 +1208,7 @@ def test_test_and_repair_run_draft_raises_routes_to_await_repair_failed() -> Non env, _ = _new_env() env.fake_dify = FakeBuildDifyPort() - env.fake_dify.run_draft = lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("boom")) + monkeypatch.setattr(env.fake_dify, "run_draft", lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("boom"))) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_TEST_AND_REPAIR) fc = DifyBuilderContext(built_node_ids=["llm"]) @@ -1204,7 +1219,7 @@ def test_test_and_repair_run_draft_raises_routes_to_await_repair_failed() -> Non assert result.run.status == "failed" -def test_test_and_repair_run_draft_raises_captures_error_on_run() -> None: +def test_test_and_repair_run_draft_raises_captures_error_on_run(monkeypatch: pytest.MonkeyPatch) -> None: """Regression: a launch-time exception must be captured on run.error (logged + stored), not silently discarded into a blank failed stub. A non-input error still routes to the config-repair gate.""" @@ -1213,7 +1228,9 @@ def test_test_and_repair_run_draft_raises_captures_error_on_run() -> None: env, _ = _new_env() env.fake_dify = FakeBuildDifyPort() - env.fake_dify.run_draft = lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("kaboom-provider")) + monkeypatch.setattr( + env.fake_dify, "run_draft", lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("kaboom-provider")) + ) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_TEST_AND_REPAIR) fc = DifyBuilderContext(built_node_ids=["llm"]) @@ -1224,7 +1241,7 @@ def test_test_and_repair_run_draft_raises_captures_error_on_run() -> None: assert "kaboom-provider" in (result.run.error or "") -def test_launch_input_error_without_proof_stops_at_review() -> None: +def test_launch_input_error_without_proof_stops_at_review(monkeypatch: pytest.MonkeyPatch) -> None: """Launch error text is preserved, but cannot authorize diagnosis or automatic retry.""" from core.dify_builder.models import TestInput from services.dify_builder.application.handlers_build import handle_test_and_repair @@ -1232,7 +1249,11 @@ def test_launch_input_error_without_proof_stops_at_review() -> None: env, _ = _new_env() env.fake_dify = FakeBuildDifyPort() - env.fake_dify.run_draft = lambda *_a, **_k: (_ for _ in ()).throw(ValueError("query is required in input form")) + monkeypatch.setattr( + env.fake_dify, + "run_draft", + lambda *_a, **_k: (_ for _ in ()).throw(ValueError("query is required in input form")), + ) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_TEST_AND_REPAIR) env.repo.save_test_input(TestInput(id="ti-1", session_id=s.id, source="mock", inputs={})) fc = DifyBuilderContext(built_node_ids=["llm"], test_input_ref="ti-1") @@ -1805,7 +1826,7 @@ def test_run_test_skips_gate_when_input_prepared() -> None: assert result.next == PcState.BUILD_TEST_AND_REPAIR -def test_run_test_prefills_the_form_with_mock_values() -> None: +def test_run_test_prefills_the_form_with_mock_values(monkeypatch: pytest.MonkeyPatch) -> None: """The user never has to invent test data -- but they do get to see and edit it. Inventing the values is the cost item 5 removed; confirming them with one click is not, and a green check produced by inputs nobody saw is @@ -1832,7 +1853,7 @@ def test_run_test_prefills_the_form_with_mock_values() -> None: del schema, prior_failed return {"topic": "A synthetic topic"} - env.agent.generate_mock_inputs = generate_mock_inputs + monkeypatch.setattr(env.agent, "generate_mock_inputs", generate_mock_inputs) result = handle_execution(env, Turn(actor=_actor(), action=Action(kind="run_test")), s, fc) assert result.next == PcState.BUILD_AWAIT_TESTDATA form = next(i for i in result.items if i.kind == "form") @@ -1841,7 +1862,7 @@ def test_run_test_prefills_the_form_with_mock_values() -> None: assert form.payload["values"] == {"topic": "A synthetic topic"} -def test_run_test_prefill_omits_a_placeholder_endpoint_but_keeps_other_fields() -> None: +def test_run_test_prefill_omits_a_placeholder_endpoint_but_keeps_other_fields(monkeypatch: pytest.MonkeyPatch) -> None: """ESQ1-302 fix round 1: ``_ground_placeholder_endpoints`` re-points a placeholder http-request URL at a required start variable, but the mock generator invents just as plausible a URL for a text-input as it does for @@ -1878,7 +1899,7 @@ def test_run_test_prefill_omits_a_placeholder_endpoint_but_keeps_other_fields() _ = schema, prior_failed return {"topic": "quarterly report", "h_url": "https://api.example.com/invented"} - env.agent.generate_mock_inputs = generate_mock_inputs + monkeypatch.setattr(env.agent, "generate_mock_inputs", generate_mock_inputs) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_EXECUTION) fc = DifyBuilderContext(test_input_ref="") result = handle_execution(env, Turn(actor=_actor(), action=Action(kind="run_test")), s, fc) @@ -1888,7 +1909,9 @@ def test_run_test_prefill_omits_a_placeholder_endpoint_but_keeps_other_fields() assert [f["type"] for f in form.payload["fields"]] == ["text-input", "text-input"] # both still asked for -def test_run_test_prefill_omits_a_placeholder_credential_but_keeps_other_fields() -> None: +def test_run_test_prefill_omits_a_placeholder_credential_but_keeps_other_fields( + monkeypatch: pytest.MonkeyPatch, +) -> None: """The credential counterpart of the placeholder-endpoint prefill guard (Task 3): a mocked API key would let the run launch with an invented secret and fail deep inside the http-request node instead of at input @@ -1931,7 +1954,7 @@ def test_run_test_prefill_omits_a_placeholder_credential_but_keeps_other_fields( _ = schema, prior_failed return {"topic": "quarterly report", "h_api_key": "sk-mock-invented"} - env.agent.generate_mock_inputs = generate_mock_inputs + monkeypatch.setattr(env.agent, "generate_mock_inputs", generate_mock_inputs) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_EXECUTION) fc = DifyBuilderContext(test_input_ref="") result = handle_execution(env, Turn(actor=_actor(), action=Action(kind="run_test")), s, fc) @@ -1974,7 +1997,7 @@ def test_run_test_still_asks_when_a_file_is_declared_among_other_variables() -> assert [f["type"] for f in form.payload["fields"]] == ["text-input", "file"] -def test_await_testdata_mock_prepares_input_and_advances() -> None: +def test_await_testdata_mock_prepares_input_and_advances(monkeypatch: pytest.MonkeyPatch) -> None: from services.dify_builder.application.handlers_build import handle_await_testdata from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort @@ -1986,7 +2009,7 @@ def test_await_testdata_mock_prepares_input_and_advances() -> None: _ = schema, prior_failed return {"topic": "hi"} - env.agent.generate_mock_inputs = generate_mock_inputs + monkeypatch.setattr(env.agent, "generate_mock_inputs", generate_mock_inputs) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_AWAIT_TESTDATA) fc = DifyBuilderContext() result = handle_await_testdata( @@ -2083,14 +2106,14 @@ class _EngineRunDraft: ) -def test_await_testdata_mock_leaves_the_endpoint_for_the_form() -> None: +def test_await_testdata_mock_leaves_the_endpoint_for_the_form(monkeypatch: pytest.MonkeyPatch) -> None: from services.dify_builder.application.handlers_build import handle_await_testdata from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort env, _ = _new_env() env.fake_dify = FakeBuildDifyPort() env.fake_dify.graph = _endpoint_graph() - env.agent.generate_mock_inputs = _mock_with_an_invented_endpoint + monkeypatch.setattr(env.agent, "generate_mock_inputs", _mock_with_an_invented_endpoint) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_AWAIT_TESTDATA) result = handle_await_testdata( @@ -2105,7 +2128,7 @@ def test_await_testdata_mock_leaves_the_endpoint_for_the_form() -> None: assert env.repo.get_test_input(result.context.test_input_ref).inputs == {"topic": "quarterly report"} -def test_mock_inputs_route_a_missing_endpoint_back_to_the_testdata_gate() -> None: +def test_mock_inputs_route_a_missing_endpoint_back_to_the_testdata_gate(monkeypatch: pytest.MonkeyPatch) -> None: """ESQ1-302's repair thrash: "use mock data" invented a URL for the endpoint variable, the engine accepted it, the node failed on connection, and ``is_input_failure`` (rightly) called that a config failure -- so the flow @@ -2118,8 +2141,9 @@ def test_mock_inputs_route_a_missing_endpoint_back_to_the_testdata_gate() -> Non env, _ = _new_env() env.fake_dify = FakeBuildDifyPort() env.fake_dify.graph = _endpoint_graph() - env.fake_dify.run_draft = engine = _EngineRunDraft(env.fake_dify.graph) - env.agent.generate_mock_inputs = _mock_with_an_invented_endpoint + engine = _EngineRunDraft(env.fake_dify.graph) + monkeypatch.setattr(env.fake_dify, "run_draft", engine) + monkeypatch.setattr(env.agent, "generate_mock_inputs", _mock_with_an_invented_endpoint) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_AWAIT_TESTDATA) mocked = handle_await_testdata( env, @@ -2144,15 +2168,16 @@ def test_mock_inputs_route_a_missing_endpoint_back_to_the_testdata_gate() -> Non ) -def test_test_and_repair_defensive_mock_leaves_the_endpoint_for_the_form() -> None: +def test_test_and_repair_defensive_mock_leaves_the_endpoint_for_the_form(monkeypatch: pytest.MonkeyPatch) -> None: from services.dify_builder.application.handlers_build import handle_test_and_repair from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort env, _ = _new_env() env.fake_dify = FakeBuildDifyPort() env.fake_dify.graph = _endpoint_graph() - env.fake_dify.run_draft = engine = _EngineRunDraft(env.fake_dify.graph) - env.agent.generate_mock_inputs = _mock_with_an_invented_endpoint + engine = _EngineRunDraft(env.fake_dify.graph) + monkeypatch.setattr(env.fake_dify, "run_draft", engine) + monkeypatch.setattr(env.agent, "generate_mock_inputs", _mock_with_an_invented_endpoint) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_TEST_AND_REPAIR) result = handle_test_and_repair( @@ -2943,7 +2968,7 @@ def _esq1_302_launch_failed_run(*_a: object, **_k: object) -> Run: ) -def test_launch_error_without_native_proof_never_stages_repair() -> None: +def test_launch_error_without_native_proof_never_stages_repair(monkeypatch: pytest.MonkeyPatch) -> None: """Repeated launch errors retain their text and stay at review without diagnosis.""" from core.dify_builder.models import TestInput from services.dify_builder.application.handlers_build import ( @@ -2955,7 +2980,7 @@ def test_launch_error_without_native_proof_never_stages_repair() -> None: env, _ = _new_env(agent=StubAgent()) env.fake_dify = FakeBuildDifyPort() - env.fake_dify.run_draft = _esq1_302_launch_failed_run + monkeypatch.setattr(env.fake_dify, "run_draft", _esq1_302_launch_failed_run) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_TEST_AND_REPAIR) env.repo.save_test_input(TestInput(id="ti-1", session_id=s.id, source="mock", inputs={})) fc = DifyBuilderContext(test_input_ref="ti-1") @@ -2987,7 +3012,9 @@ def test_launch_error_without_native_proof_never_stages_repair() -> None: ) -def test_plan_approval_surfaces_a_draft_that_would_not_start_instead_of_crashing() -> None: +def test_plan_approval_surfaces_a_draft_that_would_not_start_instead_of_crashing( + monkeypatch: pytest.MonkeyPatch, +) -> None: """``apply_repair`` now dry-validates the graph it is about to write and raises ``PreflightError`` (a ``DraftWouldNotStartError``). Before this, the error left the handler uncaught and the advance died mid-step; now it is a reply and @@ -3006,7 +3033,7 @@ def test_plan_approval_surfaces_a_draft_that_would_not_start_instead_of_crashing "the draft would not start: node 'llm' (llm): 2 validation errors for LLMNodeData" ) - dify.apply_repair = would_not_start + monkeypatch.setattr(dify, "apply_repair", would_not_start) events: list[dict[str, object]] = [] env, repo = _new_env(dify=dify, emit_canvas=events.append) s = _seed_build_session( @@ -3027,7 +3054,9 @@ def test_plan_approval_surfaces_a_draft_that_would_not_start_instead_of_crashing assert events == [] -def test_plan_approval_does_not_call_an_unapplicable_graph_a_workflow_that_cannot_start() -> None: +def test_plan_approval_does_not_call_an_unapplicable_graph_a_workflow_that_cannot_start( + monkeypatch: pytest.MonkeyPatch, +) -> None: """A graph_ops rejection ("node not found") is a ValueError too, but the draft was never checked for startability -- the generated intents just did not apply. Calling that "The workflow can't start" sends the user @@ -3042,7 +3071,7 @@ def test_plan_approval_does_not_call_an_unapplicable_graph_a_workflow_that_canno def does_not_apply(*_a: object, **_k: object) -> NoReturn: raise ValueError("node not found: x") - dify.apply_repair = does_not_apply + monkeypatch.setattr(dify, "apply_repair", does_not_apply) events: list[dict[str, object]] = [] env, repo = _new_env(dify=dify, emit_canvas=events.append) s = _seed_build_session( @@ -3100,7 +3129,7 @@ def _succeeded_run(per_node_ids: list[str]) -> Callable[..., Run]: return run_draft -def test_a_succeeded_run_that_took_a_branch_and_reached_no_end_is_not_a_pass() -> None: +def test_a_succeeded_run_that_took_a_branch_and_reached_no_end_is_not_a_pass(monkeypatch: pytest.MonkeyPatch) -> None: """ESQ1-303's silent false pass. The engine reported ``succeeded`` because nothing failed; only node1 and node2 ran. That is not "All checks passed".""" from core.dify_builder.models import TestInput @@ -3110,7 +3139,7 @@ def test_a_succeeded_run_that_took_a_branch_and_reached_no_end_is_not_a_pass() - events: list[dict[str, object]] = [] dify = FakeBuildDifyPort() dify.graph = _BRANCHING_GRAPH - dify.run_draft = _succeeded_run(["node1", "node2"]) + monkeypatch.setattr(dify, "run_draft", _succeeded_run(["node1", "node2"])) env, _ = _new_env(dify=dify, emit_canvas=events.append) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_TEST_AND_REPAIR) env.repo.save_test_input(TestInput(id="ti-1", session_id=s.id, source="mock", inputs={})) @@ -3132,14 +3161,16 @@ def test_a_succeeded_run_that_took_a_branch_and_reached_no_end_is_not_a_pass() - assert events == [] -def test_a_succeeded_run_that_took_a_branch_and_reached_the_end_is_still_a_pass() -> None: +def test_a_succeeded_run_that_took_a_branch_and_reached_the_end_is_still_a_pass( + monkeypatch: pytest.MonkeyPatch, +) -> None: from core.dify_builder.models import TestInput from services.dify_builder.application.handlers_build import handle_test_and_repair from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort dify = FakeBuildDifyPort() dify.graph = _BRANCHING_GRAPH - dify.run_draft = _succeeded_run(["node1", "node2", "node3", "node6"]) + monkeypatch.setattr(dify, "run_draft", _succeeded_run(["node1", "node2", "node3", "node6"])) env, _ = _new_env(dify=dify) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_TEST_AND_REPAIR) env.repo.save_test_input(TestInput(id="ti-1", session_id=s.id, source="mock", inputs={})) @@ -3175,7 +3206,7 @@ _SIDE_EFFECT_ARM_GRAPH: Graph = { } -def test_a_side_effect_only_arm_that_ran_to_completion_is_still_a_pass() -> None: +def test_a_side_effect_only_arm_that_ran_to_completion_is_still_a_pass(monkeypatch: pytest.MonkeyPatch) -> None: """Reviewer Important #1: the run took node2's true-arm through to node4 (a side-effect node with no End of its own) and never touched node5's End -- but node3/node4 DO appear in per_node, so node2 did not route nowhere. @@ -3186,7 +3217,7 @@ def test_a_side_effect_only_arm_that_ran_to_completion_is_still_a_pass() -> None dify = FakeBuildDifyPort() dify.graph = _SIDE_EFFECT_ARM_GRAPH - dify.run_draft = _succeeded_run(["node1", "node2", "node3", "node4"]) + monkeypatch.setattr(dify, "run_draft", _succeeded_run(["node1", "node2", "node3", "node4"])) env, _ = _new_env(dify=dify) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_TEST_AND_REPAIR) env.repo.save_test_input(TestInput(id="ti-1", session_id=s.id, source="mock", inputs={})) @@ -3214,14 +3245,14 @@ _FAIL_BRANCH_LINEAR_GRAPH: Graph = { } -def test_a_succeeded_fail_branch_node_in_a_linear_graph_is_still_a_pass() -> None: +def test_a_succeeded_fail_branch_node_in_a_linear_graph_is_still_a_pass(monkeypatch: pytest.MonkeyPatch) -> None: from core.dify_builder.models import TestInput from services.dify_builder.application.handlers_build import handle_test_and_repair from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort dify = FakeBuildDifyPort() dify.graph = _FAIL_BRANCH_LINEAR_GRAPH - dify.run_draft = _succeeded_run(["node1", "node2", "node6"]) + monkeypatch.setattr(dify, "run_draft", _succeeded_run(["node1", "node2", "node6"])) env, _ = _new_env(dify=dify) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_TEST_AND_REPAIR) env.repo.save_test_input(TestInput(id="ti-1", session_id=s.id, source="mock", inputs={})) @@ -3241,7 +3272,7 @@ def _unknown_outcome_run(*_a: object, **_k: object) -> Run: ) -def test_unknown_outcome_requires_review_on_every_attempt() -> None: +def test_unknown_outcome_requires_review_on_every_attempt(monkeypatch: pytest.MonkeyPatch) -> None: """Missing execution proof stops immediately at review on each attempt.""" from core.dify_builder.models import TestInput from services.dify_builder.application.handlers_build import handle_test_and_repair @@ -3250,7 +3281,7 @@ def test_unknown_outcome_requires_review_on_every_attempt() -> None: assert MAX_UNKNOWN_OUTCOMES == 2 dify = FakeBuildDifyPort() - dify.run_draft = _unknown_outcome_run + monkeypatch.setattr(dify, "run_draft", _unknown_outcome_run) env, _ = _new_env(dify=dify) s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_TEST_AND_REPAIR) env.repo.save_test_input(TestInput(id="ti-1", session_id=s.id, source="mock", inputs={})) @@ -3300,7 +3331,7 @@ def test_confirmed_build_test_uses_saved_input_without_repair_and_returns_to_rev assert_confirmed_handler_outcome("build", PcState.BUILD_TEST_AND_REPAIR, PcState.BUILD_REVIEW) -def test_default_build_test_never_passes_consent_even_with_a_pending_reference() -> None: +def test_default_build_test_never_passes_consent_even_with_a_pending_reference(monkeypatch: pytest.MonkeyPatch) -> None: from collections.abc import Callable, Mapping from core.dify_builder.models import Inputs, NodeEvent, Run @@ -3338,7 +3369,7 @@ def test_default_build_test_never_passes_consent_even_with_a_pending_reference() effect_confirmation=effect_confirmation, ) - env.fake_dify.run_draft = recording_run_draft + monkeypatch.setattr(env.fake_dify, "run_draft", recording_run_draft) handle_test_and_repair(env, Turn(actor=_actor()), s, fc) assert seen == [(None, None)] diff --git a/api/tests/unit_tests/core/dify_builder/test_handlers_edit.py b/api/tests/unit_tests/core/dify_builder/test_handlers_edit.py index 0432007ce92..45382d53dc3 100644 --- a/api/tests/unit_tests/core/dify_builder/test_handlers_edit.py +++ b/api/tests/unit_tests/core/dify_builder/test_handlers_edit.py @@ -234,7 +234,7 @@ def test_sufficient_goal_reaches_plan_gate_or_actionable_retry_with_monotonic_pr assert not any(item.kind == "form" for item in items) -def test_edit_capability_check_renders_agent_fields() -> None: +def test_edit_capability_check_renders_agent_fields(monkeypatch: pytest.MonkeyPatch) -> None: # PlaceholderAgent.analyze_impact returns dynamic fields/values/targets; # the form card and fc.form_fields/edit_rules/edit_target_node_ids must # reflect them (not the old hardcoded _EDIT_RULE_FIELDS constant). @@ -250,7 +250,7 @@ def test_edit_capability_check_renders_agent_fields() -> None: "target_node_ids": ["llm"], } - env.agent.analyze_impact = analyze_impact + monkeypatch.setattr(env.agent, "analyze_impact", analyze_impact) s = _session(entry_mode=EntryMode.EDIT, current_state=PcState.EDIT_CAPABILITY_CHECK) fc = DifyBuilderContext(goal_text="make it formal") result = handle_capability_check( @@ -462,7 +462,9 @@ def test_edit_test_fail_routes_to_await_repair() -> None: ("", False), ], ) -def test_edit_test_fail_with_no_proposed_repair_still_routes_to_gate(reason: str, expected_reason: bool) -> None: +def test_edit_test_fail_with_no_proposed_repair_still_routes_to_gate( + monkeypatch: pytest.MonkeyPatch, reason: str, expected_reason: bool +) -> None: """When propose_repair finds no safe fix (empty intents), the fail path must still route to the gate, but WITHOUT a change_set card, and with the "no safe automatic fix" reply_text variant -- the `if intents` branch the @@ -479,7 +481,7 @@ def test_edit_test_fail_with_no_proposed_repair_still_routes_to_gate(reason: str _ = diagnosis, graph return ([], Risk(level="high", reason=reason, has_external_side_effect=False)) - env.agent.propose_repair = propose_repair + monkeypatch.setattr(env.agent, "propose_repair", propose_repair) s = _session(entry_mode=EntryMode.EDIT, current_state=PcState.EDIT_TEST_AFFECTED_PATHS) fc = DifyBuilderContext(edit_target_node_ids=["llm"]) result = handle_test_affected_paths(env, Turn(actor=_actor()), s, fc) @@ -517,14 +519,14 @@ def test_edit_test_reuses_persisted_inputs_on_retest() -> None: assert fc.test_input_ref == ref # reused, not regenerated -def test_edit_test_run_draft_raises_routes_to_await_repair_failed() -> None: +def test_edit_test_run_draft_raises_routes_to_await_repair_failed(monkeypatch: pytest.MonkeyPatch) -> None: """run_draft raising must not crash the advance -- the try/except degrade path converts the exception into a failed run and still routes to the edit.await_repair gate.""" from services.dify_builder.application.handlers_edit import handle_test_affected_paths env, _ = _new_env() - env.fake_dify.run_draft = lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("boom")) + monkeypatch.setattr(env.fake_dify, "run_draft", lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("boom"))) s = _session(entry_mode=EntryMode.EDIT, current_state=PcState.EDIT_TEST_AFFECTED_PATHS) fc = DifyBuilderContext(edit_target_node_ids=["llm"]) @@ -535,13 +537,15 @@ def test_edit_test_run_draft_raises_routes_to_await_repair_failed() -> None: assert result.run.status == "failed" -def test_edit_test_run_draft_raises_captures_error_on_run() -> None: +def test_edit_test_run_draft_raises_captures_error_on_run(monkeypatch: pytest.MonkeyPatch) -> None: """Regression: a launch-time exception must be captured on run.error, not silently discarded. A non-input error still routes to the config-repair gate.""" from services.dify_builder.application.handlers_edit import handle_test_affected_paths env, _ = _new_env() - env.fake_dify.run_draft = lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("kaboom-provider")) + monkeypatch.setattr( + env.fake_dify, "run_draft", lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("kaboom-provider")) + ) s = _session(entry_mode=EntryMode.EDIT, current_state=PcState.EDIT_TEST_AFFECTED_PATHS) fc = DifyBuilderContext(edit_target_node_ids=["llm"]) @@ -552,13 +556,17 @@ def test_edit_test_run_draft_raises_captures_error_on_run() -> None: assert "kaboom-provider" in (result.run.error or "") -def test_edit_launch_input_error_without_proof_stops_at_review() -> None: +def test_edit_launch_input_error_without_proof_stops_at_review(monkeypatch: pytest.MonkeyPatch) -> None: """Unproven launch errors retain inputs for review without diagnosis.""" from core.dify_builder.models import TestInput from services.dify_builder.application.handlers_edit import handle_test_affected_paths env, _ = _new_env() - env.fake_dify.run_draft = lambda *_a, **_k: (_ for _ in ()).throw(ValueError("query is required in input form")) + monkeypatch.setattr( + env.fake_dify, + "run_draft", + lambda *_a, **_k: (_ for _ in ()).throw(ValueError("query is required in input form")), + ) s = _session(entry_mode=EntryMode.EDIT, current_state=PcState.EDIT_TEST_AFFECTED_PATHS) env.repo.save_test_input(TestInput(id="ti-1", session_id=s.id, source="mock", inputs={})) fc = DifyBuilderContext(edit_target_node_ids=["llm"], test_input_ref="ti-1") @@ -864,7 +872,9 @@ def test_edit_await_repair_refuses_to_apply_an_empty_staged_repair() -> None: assert "No fix is staged" in english_text(result.items[0].payload["reply_text"]) -def test_edit_await_repair_surfaces_a_stale_intent_instead_of_failing_the_session() -> None: +def test_edit_await_repair_surfaces_a_stale_intent_instead_of_failing_the_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: """A stale staged intent degrades to assistant text, not an exception.""" from services.dify_builder.application.handlers_edit import handle_await_repair @@ -873,7 +883,7 @@ def test_edit_await_repair_surfaces_a_stale_intent_instead_of_failing_the_sessio def _stale(*_args: object, **_kwargs: object) -> NoReturn: raise ValueError("node not found: llm") - env.fake_dify.apply_repair = _stale # type: ignore[method-assign] + monkeypatch.setattr(env.fake_dify, "apply_repair", _stale) s = _session(entry_mode=EntryMode.EDIT, current_state=PcState.EDIT_AWAIT_REPAIR) fc = DifyBuilderContext( staged_repair=[MutationIntent(op="set_node_config", args={"node_id": "llm", "path": "code", "value": ""})], @@ -890,7 +900,7 @@ def test_edit_await_repair_surfaces_a_stale_intent_instead_of_failing_the_sessio assert result.context.staged_repair == [] -def test_edit_await_repair_says_a_fix_that_would_not_start_is_not_a_stale_fix() -> None: +def test_edit_await_repair_says_a_fix_that_would_not_start_is_not_a_stale_fix(monkeypatch: pytest.MonkeyPatch) -> None: """A fix the preflight rejected applied fine; it would leave a draft that fails at Graph.init. Same recovery as a stale intent, true reason.""" from core.dify_builder.errors import DraftWouldNotStartError @@ -901,7 +911,7 @@ def test_edit_await_repair_says_a_fix_that_would_not_start_is_not_a_stale_fix() def _would_not_start(*_args: object, **_kwargs: object) -> NoReturn: raise DraftWouldNotStartError("the draft would not start: node 'llm' (llm): 1 validation error") - env.fake_dify.apply_repair = _would_not_start # type: ignore[method-assign] + monkeypatch.setattr(env.fake_dify, "apply_repair", _would_not_start) s = _session(entry_mode=EntryMode.EDIT, current_state=PcState.EDIT_AWAIT_REPAIR) fc = DifyBuilderContext( staged_repair=[MutationIntent(op="set_node_config", args={"node_id": "llm", "path": "code", "value": ""})], @@ -1128,7 +1138,7 @@ def test_run_affected_tests_routes_to_testdata_gate_when_no_input() -> None: assert any(i.kind == "form" and i.payload["variant"] == "testdata" for i in result.items) -def test_edit_await_testdata_mock_prepares_and_advances() -> None: +def test_edit_await_testdata_mock_prepares_and_advances(monkeypatch: pytest.MonkeyPatch) -> None: from services.dify_builder.application.handlers_edit import handle_await_testdata env, _ = _new_env() @@ -1138,7 +1148,7 @@ def test_edit_await_testdata_mock_prepares_and_advances() -> None: _ = schema, prior_failed return {"q": "x"} - env.agent.generate_mock_inputs = generate_mock_inputs + monkeypatch.setattr(env.agent, "generate_mock_inputs", generate_mock_inputs) s = _session(entry_mode=EntryMode.EDIT, current_state=PcState.EDIT_AWAIT_TESTDATA) result = handle_await_testdata( env, @@ -1182,7 +1192,7 @@ def _mock_with_an_invented_endpoint( return {"topic": "quarterly report", "h_url": "https://api.example.com/ppt/generate"} -def test_edit_await_testdata_mock_leaves_the_endpoint_for_the_form() -> None: +def test_edit_await_testdata_mock_leaves_the_endpoint_for_the_form(monkeypatch: pytest.MonkeyPatch) -> None: """A mocked endpoint URL is accepted at launch and fails on connection -- a config failure that feeds the repair loop (ESQ1-302). Left out, the missing required key fails the launch as an input the form then asks for.""" @@ -1190,7 +1200,7 @@ def test_edit_await_testdata_mock_leaves_the_endpoint_for_the_form() -> None: env, _ = _new_env() env.fake_dify.graph = _endpoint_graph() - env.agent.generate_mock_inputs = _mock_with_an_invented_endpoint + monkeypatch.setattr(env.agent, "generate_mock_inputs", _mock_with_an_invented_endpoint) s = _session(entry_mode=EntryMode.EDIT, current_state=PcState.EDIT_AWAIT_TESTDATA) result = handle_await_testdata( @@ -1205,12 +1215,14 @@ def test_edit_await_testdata_mock_leaves_the_endpoint_for_the_form() -> None: assert env.repo.get_test_input(result.context.test_input_ref).inputs == {"topic": "quarterly report"} -def test_edit_test_affected_paths_defensive_mock_leaves_the_endpoint_for_the_form() -> None: +def test_edit_test_affected_paths_defensive_mock_leaves_the_endpoint_for_the_form( + monkeypatch: pytest.MonkeyPatch, +) -> None: from services.dify_builder.application.handlers_edit import handle_test_affected_paths env, _ = _new_env() env.fake_dify.graph = _endpoint_graph() - env.agent.generate_mock_inputs = _mock_with_an_invented_endpoint + monkeypatch.setattr(env.agent, "generate_mock_inputs", _mock_with_an_invented_endpoint) s = _session(entry_mode=EntryMode.EDIT, current_state=PcState.EDIT_TEST_AFFECTED_PATHS) result = handle_test_affected_paths( @@ -1262,7 +1274,7 @@ def test_revert_then_retry_after_revert_reapprove_is_idempotent() -> None: assert len(dify.graph["nodes"]) == 4 -def test_edit_launch_error_is_preserved_at_unknown_review_gate() -> None: +def test_edit_launch_error_is_preserved_at_unknown_review_gate(monkeypatch: pytest.MonkeyPatch) -> None: """Launch errors retain their text but lack execution proof for diagnosis.""" from core.dify_builder.models import Run, TestInput from services.dify_builder.application.handlers_edit import handle_test_affected_paths @@ -1273,7 +1285,7 @@ def test_edit_launch_error_is_preserved_at_unknown_review_gate() -> None: return Run(kind="verify", immutable=True, dify_run_id="", status="failed", per_node=[], error=launch_error) env, repo = _new_env(agent=StubAgent()) - env.fake_dify.run_draft = launch_failed + monkeypatch.setattr(env.fake_dify, "run_draft", launch_failed) s = _session(entry_mode=EntryMode.EDIT, current_state=PcState.EDIT_TEST_AFFECTED_PATHS) repo.save_test_input(TestInput(id="ti-1", session_id=s.id, source="mock", inputs={})) @@ -1286,7 +1298,9 @@ def test_edit_launch_error_is_preserved_at_unknown_review_gate() -> None: assert "notice" not in [i.kind for i in res.items] -def test_plan_approval_surfaces_an_edit_that_would_not_start_instead_of_crashing() -> None: +def test_plan_approval_surfaces_an_edit_that_would_not_start_instead_of_crashing( + monkeypatch: pytest.MonkeyPatch, +) -> None: """Same preflight rejection as Build's: ``apply_repair`` raises a ``DraftWouldNotStartError`` for an edit whose result would fail at Graph.init. The edit session must survive it with a reply and stay at the @@ -1299,7 +1313,7 @@ def test_plan_approval_surfaces_an_edit_that_would_not_start_instead_of_crashing def would_not_start(*_a: object, **_k: object) -> NoReturn: raise DraftWouldNotStartError("the draft would not start: node 'llm' (llm): 1 validation error for LLMNodeData") - dify.apply_repair = would_not_start + monkeypatch.setattr(dify, "apply_repair", would_not_start) env, repo = _new_env(dify=dify) s = _seed_edit_session( repo, @@ -1319,7 +1333,9 @@ def test_plan_approval_surfaces_an_edit_that_would_not_start_instead_of_crashing assert "Continue adjusting" in english_text(assistant.payload["reply_text"]) -def test_plan_approval_does_not_call_an_unapplicable_edit_a_workflow_that_cannot_start() -> None: +def test_plan_approval_does_not_call_an_unapplicable_edit_a_workflow_that_cannot_start( + monkeypatch: pytest.MonkeyPatch, +) -> None: """A graph_ops rejection ("node not found") never reached the startability check -- the edit intents just did not apply. Same recovery as the preflight reply (nothing written, plan still approvable), honest reason.""" @@ -1330,7 +1346,7 @@ def test_plan_approval_does_not_call_an_unapplicable_edit_a_workflow_that_cannot def does_not_apply(*_a: object, **_k: object) -> NoReturn: raise ValueError("node not found: x") - dify.apply_repair = does_not_apply + monkeypatch.setattr(dify, "apply_repair", does_not_apply) env, repo = _new_env(dify=dify) s = _seed_edit_session( repo, @@ -1353,7 +1369,7 @@ def test_plan_approval_does_not_call_an_unapplicable_edit_a_workflow_that_cannot assert "Continue adjusting" in english_text(assistant.payload["reply_text"]) -def test_a_succeeded_affected_path_run_that_reached_no_end_is_not_a_pass() -> None: +def test_a_succeeded_affected_path_run_that_reached_no_end_is_not_a_pass(monkeypatch: pytest.MonkeyPatch) -> None: from core.dify_builder.models import NodeOutput, Run, TestInput from services.dify_builder.application.handlers_edit import handle_test_affected_paths @@ -1372,13 +1388,17 @@ def test_a_succeeded_affected_path_run_that_reached_no_end_is_not_a_pass() -> No {"source": "node2", "target": "node6", "sourceHandle": "billing"}, ], } - dify.run_draft = lambda *_a, **_k: Run( - kind="verify", - immutable=True, - dify_run_id="run-e", - verification=successful_verification(dify, no_output=True), - status="succeeded", - per_node=[NodeOutput(node_id="node1", status="succeeded"), NodeOutput(node_id="node2", status="succeeded")], + monkeypatch.setattr( + dify, + "run_draft", + lambda *_a, **_k: Run( + kind="verify", + immutable=True, + dify_run_id="run-e", + verification=successful_verification(dify, no_output=True), + status="succeeded", + per_node=[NodeOutput(node_id="node1", status="succeeded"), NodeOutput(node_id="node2", status="succeeded")], + ), ) env, repo = _new_env(dify=dify) s = _session(entry_mode=EntryMode.EDIT, current_state=PcState.EDIT_TEST_AFFECTED_PATHS) @@ -1396,14 +1416,18 @@ def test_a_succeeded_affected_path_run_that_reached_no_end_is_not_a_pass() -> No assert res.run.culprit_node_id == "node2" -def test_each_unknown_outcome_stops_at_the_edit_gate() -> None: +def test_each_unknown_outcome_stops_at_the_edit_gate(monkeypatch: pytest.MonkeyPatch) -> None: from core.dify_builder.models import Diagnosis, Run, TestInput from services.dify_builder.application.handlers_edit import handle_test_affected_paths from services.dify_builder.run_mapping import TRUNCATED_STREAM_ERROR env, repo = _new_env() - env.fake_dify.run_draft = lambda *_a, **_k: Run( - kind="verify", immutable=True, status="running", per_node=[], error=TRUNCATED_STREAM_ERROR + monkeypatch.setattr( + env.fake_dify, + "run_draft", + lambda *_a, **_k: Run( + kind="verify", immutable=True, status="running", per_node=[], error=TRUNCATED_STREAM_ERROR + ), ) s = _session(entry_mode=EntryMode.EDIT, current_state=PcState.EDIT_TEST_AFFECTED_PATHS) repo.save_test_input(TestInput(id="ti-1", session_id=s.id, source="mock", inputs={})) @@ -1556,7 +1580,7 @@ class _RejectionRecordingAgent(PlaceholderAgent): ) -def _refusing_port(*errors: Exception) -> FakeEditDifyPort: +def _refusing_port(monkeypatch: pytest.MonkeyPatch, *errors: Exception) -> FakeEditDifyPort: """A ``FakeEditDifyPort`` whose ``apply_repair`` raises ``errors`` in turn.""" dify = FakeEditDifyPort() remaining = list(errors) @@ -1564,7 +1588,7 @@ def _refusing_port(*errors: Exception) -> FakeEditDifyPort: def refuse(*_a: object, **_k: object) -> NoReturn: raise remaining.pop(0) - dify.apply_repair = refuse + monkeypatch.setattr(dify, "apply_repair", refuse) return dify @@ -1575,10 +1599,10 @@ def _approve(env: Env, session: Session, fc: DifyBuilderContext) -> StepResult: return handle_plan_approval(env, turn, session, fc) -def test_a_refused_edit_remembers_the_engines_own_reason() -> None: +def test_a_refused_edit_remembers_the_engines_own_reason(monkeypatch: pytest.MonkeyPatch) -> None: from core.dify_builder.errors import DraftWouldNotStartError - env, repo = _new_env(dify=_refusing_port(DraftWouldNotStartError(_KLINGON))) + env, repo = _new_env(dify=_refusing_port(monkeypatch, DraftWouldNotStartError(_KLINGON))) s = _seed_edit_session( repo, PcState.EDIT_PLAN_APPROVAL, edit_rules={"risk_threshold": "high"}, edit_target_node_ids=["node2"] ) @@ -1591,10 +1615,10 @@ def test_a_refused_edit_remembers_the_engines_own_reason() -> None: assert res.context.last_edit_rejection == _KLINGON -def test_an_edit_that_simply_did_not_apply_is_remembered_too() -> None: +def test_an_edit_that_simply_did_not_apply_is_remembered_too(monkeypatch: pytest.MonkeyPatch) -> None: """A ``graph_ops`` refusal never reached the startability check, but it is just as blind on re-approval, so it is carried forward the same way.""" - env, repo = _new_env(dify=_refusing_port(ValueError("node not found: x"))) + env, repo = _new_env(dify=_refusing_port(monkeypatch, ValueError("node not found: x"))) s = _seed_edit_session( repo, PcState.EDIT_PLAN_APPROVAL, edit_rules={"risk_threshold": "high"}, edit_target_node_ids=["node2"] ) @@ -1633,7 +1657,7 @@ def test_a_first_approval_passes_no_rejection_at_all() -> None: assert agent.rejections == [None] # "" would read as a refusal with no text -def test_a_second_refusal_at_the_same_location_replaces_the_first() -> None: +def test_a_second_refusal_at_the_same_location_replaces_the_first(monkeypatch: pytest.MonkeyPatch) -> None: """The case the dry run's own key cannot cover. ``preflight.new_preflight_problems`` is keyed on pydantic error LOCATIONS, @@ -1648,7 +1672,8 @@ def test_a_second_refusal_at_the_same_location_replaces_the_first() -> None: agent = _RejectionRecordingAgent() env, repo = _new_env( - dify=_refusing_port(DraftWouldNotStartError(_KLINGON), DraftWouldNotStartError(_MARTIAN)), agent=agent + dify=_refusing_port(monkeypatch, DraftWouldNotStartError(_KLINGON), DraftWouldNotStartError(_MARTIAN)), + agent=agent, ) s = _seed_edit_session( repo, PcState.EDIT_PLAN_APPROVAL, edit_rules={"risk_threshold": "high"}, edit_target_node_ids=["node2"] @@ -1711,13 +1736,13 @@ def test_a_recovery_reset_forgets_the_refusal_of_a_batch_that_is_gone() -> None: assert fc.last_edit_rejection == "" -def test_a_huge_refusal_is_capped_before_it_is_persisted_and_re_prompted() -> None: +def test_a_huge_refusal_is_capped_before_it_is_persisted_and_re_prompted(monkeypatch: pytest.MonkeyPatch) -> None: """A batch refusal concatenates one message per node, and this text is both stored in the session context blob and prepended to the next prompt.""" from core.dify_builder.errors import DraftWouldNotStartError from services.dify_builder.application.handlers_edit import _MAX_REJECTION_CHARS, _REJECTION_TRUNCATED_MARKER - env, repo = _new_env(dify=_refusing_port(DraftWouldNotStartError("x" * (_MAX_REJECTION_CHARS + 500)))) + env, repo = _new_env(dify=_refusing_port(monkeypatch, DraftWouldNotStartError("x" * (_MAX_REJECTION_CHARS + 500)))) s = _seed_edit_session( repo, PcState.EDIT_PLAN_APPROVAL, edit_rules={"risk_threshold": "high"}, edit_target_node_ids=["node2"] ) @@ -1735,14 +1760,14 @@ def _gate_turn(kind: str) -> Turn: return Turn(action=Action(kind=kind, base_version=1), actor=_actor()) -def test_a_refused_edit_can_be_adjusted_instead_of_only_re_approved() -> None: +def test_a_refused_edit_can_be_adjusted_instead_of_only_re_approved(monkeypatch: pytest.MonkeyPatch) -> None: """The dead end this closes: a refused approval left the user at the gate with ``approve_plan`` as the only move, while the card told them to adjust (triage edit-branch-failure-2026-09-22).""" from core.dify_builder.errors import DraftWouldNotStartError from services.dify_builder.application.handlers_edit import handle_plan_approval - env, repo = _new_env(dify=_refusing_port(DraftWouldNotStartError(_KLINGON))) + env, repo = _new_env(dify=_refusing_port(monkeypatch, DraftWouldNotStartError(_KLINGON))) s = _seed_edit_session( repo, PcState.EDIT_PLAN_APPROVAL, @@ -1802,7 +1827,7 @@ def test_routing_back_to_the_form_does_not_forget_the_refusal() -> None: assert from_review.context.last_edit_rejection == _KLINGON -def _refuse_once_then_apply(error: Exception) -> FakeEditDifyPort: +def _refuse_once_then_apply(monkeypatch: pytest.MonkeyPatch, error: Exception) -> FakeEditDifyPort: """A ``FakeEditDifyPort`` that refuses the first ``apply_repair`` and then behaves normally -- the shape of the loop these sequences live in.""" dify = FakeEditDifyPort() @@ -1838,16 +1863,18 @@ def _refuse_once_then_apply(error: Exception) -> FakeEditDifyPort: expected_node_resources=expected_node_resources, ) - dify.apply_repair = apply + monkeypatch.setattr(dify, "apply_repair", apply) return dify -def _refused_at_the_gate(agent: DifyBuilderAgent) -> tuple[Runner, InMemoryRepository, Session, Session]: +def _refused_at_the_gate( + monkeypatch: pytest.MonkeyPatch, agent: DifyBuilderAgent +) -> tuple[Runner, InMemoryRepository, Session, Session]: """Drive a real Edit session to a refused approval parked at the gate.""" from core.dify_builder.errors import DraftWouldNotStartError from services.dify_builder.application.handlers_edit import edit_registry - env, repo = _new_env(dify=_refuse_once_then_apply(DraftWouldNotStartError(_KLINGON)), agent=agent) + env, repo = _new_env(dify=_refuse_once_then_apply(monkeypatch, DraftWouldNotStartError(_KLINGON)), agent=agent) s = _seed_edit_session(repo, PcState.EDIT_CAPABILITY_CHECK) runner = Runner(env, edit_registry()) out = runner.advance( @@ -1867,14 +1894,14 @@ def _submit(runner: Runner, s: Session, out: Session, kind: str, payload: dict[s ) -def test_a_gate_revert_keeps_the_refusal_its_retry_still_needs() -> None: +def test_a_gate_revert_keeps_the_refusal_its_retry_still_needs(monkeypatch: pytest.MonkeyPatch) -> None: """The ONE path that reaches edit.reverted carrying a refusal. A revert taken at the plan gate wrote nothing, so the restored draft IS the refused draft and the rules are unchanged -- and edit.reverted offers only Retry, which re-proposes the identical plan. Clearing here would guarantee the identical refusal and throw away the whole point of remembering it.""" agent = _RejectionRecordingAgent() - runner, repo, s, out = _refused_at_the_gate(agent) + runner, repo, s, out = _refused_at_the_gate(monkeypatch, agent) out = _submit(runner, s, out, "undo") assert out.current_state == PcState.EDIT_REVERTED @@ -1888,11 +1915,11 @@ def test_a_gate_revert_keeps_the_refusal_its_retry_still_needs() -> None: assert agent.rejections == [None, _KLINGON] # the retry was told what the engine said -def test_changing_the_rules_forgets_the_refusal_the_old_ones_earned() -> None: +def test_changing_the_rules_forgets_the_refusal_the_old_ones_earned(monkeypatch: pytest.MonkeyPatch) -> None: """The mirror case: once the rules the engine refused are gone, its complaint describes nothing the next approval will try.""" agent = _RejectionRecordingAgent() - runner, repo, s, out = _refused_at_the_gate(agent) + runner, repo, s, out = _refused_at_the_gate(monkeypatch, agent) out = _submit(runner, s, out, "re_fix") # continue adjusting assert out.current_state == PcState.EDIT_IMPACT_ANALYSIS @@ -1911,11 +1938,11 @@ def test_changing_the_rules_forgets_the_refusal_the_old_ones_earned() -> None: assert agent.rejections == [None, None] # nothing stale quoted at the new rules -def test_resubmitting_the_very_same_rules_keeps_the_refusal() -> None: +def test_resubmitting_the_very_same_rules_keeps_the_refusal(monkeypatch: pytest.MonkeyPatch) -> None: """Reaching the form and pressing submit without editing anything leaves the inputs byte-identical, so the refusal still describes them.""" agent = _RejectionRecordingAgent() - runner, repo, s, out = _refused_at_the_gate(agent) + runner, repo, s, out = _refused_at_the_gate(monkeypatch, agent) out = _submit(runner, s, out, "re_fix") out = _submit(runner, s, out, "submit_edit_rules", {"risk_threshold": "medium"}) @@ -2267,7 +2294,7 @@ def test_approved_edit_uses_the_presented_plan_snapshot_for_generation() -> None assert result.next == PcState.EDIT_PLAN_APPROVAL -def test_disconnected_tool_failure_surfaces_current_checklist_and_bounded_exit() -> None: +def test_disconnected_tool_failure_surfaces_current_checklist_and_bounded_exit(monkeypatch: pytest.MonkeyPatch) -> None: from core.dify_builder.models import Run, TestInput from services.dify_builder.application.handlers_edit import handle_test_affected_paths from services.dify_builder.application.handlers_fix import MAX_REPEATED_REPAIRS @@ -2278,12 +2305,16 @@ def test_disconnected_tool_failure_surfaces_current_checklist_and_bounded_exit() dify.graph["nodes"].append( {"id": "tool-draft", "data": {"type": "tool", "title": "Unbound action", "provider_id": "", "tool_name": ""}} ) - dify.run_draft = lambda *_a, **_k: Run( - kind="verify", - immutable=True, - status="failed", - error="node tool-draft is unconnected", - verification=successful_verification(dify), + monkeypatch.setattr( + dify, + "run_draft", + lambda *_a, **_k: Run( + kind="verify", + immutable=True, + status="failed", + error="node tool-draft is unconnected", + verification=successful_verification(dify), + ), ) env, repo = _new_env(dify=dify, agent=StubAgent()) s = _session(entry_mode=EntryMode.EDIT, current_state=PcState.EDIT_TEST_AFFECTED_PATHS) diff --git a/api/tests/unit_tests/core/dify_builder/test_handlers_fix.py b/api/tests/unit_tests/core/dify_builder/test_handlers_fix.py index 6add9d30fe1..458e591aa87 100644 --- a/api/tests/unit_tests/core/dify_builder/test_handlers_fix.py +++ b/api/tests/unit_tests/core/dify_builder/test_handlers_fix.py @@ -9,6 +9,7 @@ from dataclasses import replace from datetime import datetime from typing import NoReturn, Unpack +import pytest import pytest_mock from core.dify_builder.contract import PreflightContextCard, PreflightIssue @@ -146,13 +147,13 @@ def test_diagnose_then_propose_high_risk_stops_at_await_approval() -> None: assert not canvas_read_only(out.current_state), "await states leave the canvas editable" -def test_handle_propose_empty_intents_explains_the_missing_repair() -> None: +def test_handle_propose_empty_intents_explains_the_missing_repair(monkeypatch: pytest.MonkeyPatch) -> None: # propose_repair may legitimately return no intents alongside a high-risk # surface-to-human Risk (Task 3) -- the reply must explain that instead of # claiming "0 change(s)". env, _ = _new_env() s = _session() - fc = DifyBuilderContext() + fc = DifyBuilderContext(diagnosis=Diagnosis(culprit_node_id="output", root_cause="Missing input")) def propose_repair( diagnosis: Diagnosis, graph: Graph, *, reply_language: str | None = None @@ -161,7 +162,7 @@ def test_handle_propose_empty_intents_explains_the_missing_repair() -> None: _ = diagnosis, graph return ([], Risk(level="high", reason="none", has_external_side_effect=False)) - env.agent.propose_repair = propose_repair + monkeypatch.setattr(env.agent, "propose_repair", propose_repair) result = handle_propose(env, Turn(actor=_actor()), s, fc) @@ -224,7 +225,7 @@ def test_apply_refuses_to_apply_an_empty_staged_repair() -> None: assert not env.fake_dify.applied, "nothing staged -> the adapter must not be called" -def test_apply_surfaces_a_stale_intent_instead_of_failing_the_session() -> None: +def test_apply_surfaces_a_stale_intent_instead_of_failing_the_session(monkeypatch: pytest.MonkeyPatch) -> None: """ESQ1-271: apply_repair re-validates against the draft as it is NOW. An uncaught ValueError here reaches the runner and kills the session.""" from core.dify_builder.models import MutationIntent @@ -234,7 +235,7 @@ def test_apply_surfaces_a_stale_intent_instead_of_failing_the_session() -> None: def _stale(*_args: object, **_kwargs: object) -> NoReturn: raise ValueError("path 'outputs.result': no key 'outputs' at outputs") - env.fake_dify.apply_repair = _stale # type: ignore[method-assign] + monkeypatch.setattr(env.fake_dify, "apply_repair", _stale) s = _session(current_state=PcState.FIX_APPLY) fc = DifyBuilderContext( staged_repair=[MutationIntent(op="set_node_config", args={"node_id": "code", "path": "o.r", "value": 1})] @@ -251,7 +252,7 @@ def test_apply_surfaces_a_stale_intent_instead_of_failing_the_session() -> None: assert result.context.staged_repair == [] -def test_apply_says_a_fix_that_would_not_start_is_not_a_stale_fix() -> None: +def test_apply_says_a_fix_that_would_not_start_is_not_a_stale_fix(monkeypatch: pytest.MonkeyPatch) -> None: """A fix the preflight rejected applied fine; it would leave a draft that fails at Graph.init. Same recovery as a stale intent, true reason.""" from core.dify_builder.errors import DraftWouldNotStartError @@ -262,7 +263,7 @@ def test_apply_says_a_fix_that_would_not_start_is_not_a_stale_fix() -> None: def _would_not_start(*_args: object, **_kwargs: object) -> NoReturn: raise DraftWouldNotStartError("the draft would not start: node 'code' (code): 1 validation error") - env.fake_dify.apply_repair = _would_not_start # type: ignore[method-assign] + monkeypatch.setattr(env.fake_dify, "apply_repair", _would_not_start) s = _session(current_state=PcState.FIX_APPLY) fc = DifyBuilderContext( staged_repair=[MutationIntent(op="set_node_config", args={"node_id": "code", "path": "o.r", "value": 1})] @@ -416,7 +417,7 @@ def test_run_validation_exposes_testdata_form_for_start_inputs() -> None: assert assistant.payload["cards"] == ["form"] -def test_mock_validation_inputs_use_the_workflow_start_schema() -> None: +def test_mock_validation_inputs_use_the_workflow_start_schema(monkeypatch: pytest.MonkeyPatch) -> None: env, repo = _new_env() env.fake_dify.graph = { "nodes": [ @@ -439,7 +440,7 @@ def test_mock_validation_inputs_use_the_workflow_start_schema() -> None: captured_schema.update(schema) return {"topic": "mock topic"} - env.agent.generate_mock_inputs = generate_mock_inputs + monkeypatch.setattr(env.agent, "generate_mock_inputs", generate_mock_inputs) runner, s = _drive_to_await_verify(env, repo) out = runner.advance( s.id, @@ -536,7 +537,7 @@ def test_verify_fail_freezes_run_allows_re_fix() -> None: assert verify.immutable -def test_verify_running_without_proof_stops_at_decision() -> None: +def test_verify_running_without_proof_stops_at_decision(monkeypatch: pytest.MonkeyPatch) -> None: """Missing proof stops at the existing review gate with publication blocked.""" from core.dify_builder.models import Run @@ -547,11 +548,15 @@ def test_verify_running_without_proof_stops_at_decision() -> None: out = runner.advance(s.id, turn) assert out.current_state == PcState.FIX_AWAIT_TESTDATA - env.fake_dify.run_draft = lambda *_a, **_k: Run( - dify_run_id="", - status="running", - per_node=[], - error="the workflow run's progress stream ended before the run did, so its outcome is unknown", + monkeypatch.setattr( + env.fake_dify, + "run_draft", + lambda *_a, **_k: Run( + dify_run_id="", + status="running", + per_node=[], + error="the workflow run's progress stream ended before the run did, so its outcome is unknown", + ), ) turn = Turn( action=Action(kind="provide_testdata", payload={"mode": "mock"}, base_version=out.version), actor=_actor() @@ -1060,7 +1065,7 @@ def test_without_endpoint_values_keeps_everything_when_no_url_reads_a_start_vari assert without_endpoint_values(graph, {"topic": "x"}) == {"topic": "x"} -def test_fix_await_testdata_mock_leaves_the_endpoint_for_the_form() -> None: +def test_fix_await_testdata_mock_leaves_the_endpoint_for_the_form(monkeypatch: pytest.MonkeyPatch) -> None: """A mocked URL for an endpoint variable is accepted at launch and fails on connection -- a config failure, not an input one, so it feeds the repair loop (ESQ1-302). Dropped, the missing required key is rejected at launch as @@ -1073,7 +1078,7 @@ def test_fix_await_testdata_mock_leaves_the_endpoint_for_the_form() -> None: _ = schema, prior_failed return {"topic": "quarterly report", "h_url": "https://api.example.com/ppt/generate"} - env.agent.generate_mock_inputs = generate_mock_inputs + monkeypatch.setattr(env.agent, "generate_mock_inputs", generate_mock_inputs) s = _session(current_state=PcState.FIX_AWAIT_TESTDATA) result = handle_await_testdata( @@ -1311,13 +1316,17 @@ def test_build_change_set_survives_non_string_changed_nodes() -> None: assert scope == "configuration" -def test_verify_keeps_the_ports_run_error() -> None: +def test_verify_keeps_the_ports_run_error(monkeypatch: pytest.MonkeyPatch) -> None: """Fix's ``Run(...)`` literal had no ``error=`` at all, so a launch failure was persisted with an empty error and diagnose saw nothing.""" launch_error = "node 'node4' (http-request): body.data.0.type Field required [invalid_param]" env, _ = _new_env() - env.fake_dify.run_draft = lambda *_a, **_k: Run( - kind="verify", immutable=True, dify_run_id="", status="failed", per_node=[], error=launch_error + monkeypatch.setattr( + env.fake_dify, + "run_draft", + lambda *_a, **_k: Run( + kind="verify", immutable=True, dify_run_id="", status="failed", per_node=[], error=launch_error + ), ) s = _session(current_state=PcState.FIX_VERIFY) @@ -1329,11 +1338,13 @@ def test_verify_keeps_the_ports_run_error() -> None: assert res.run.error == launch_error -def test_verify_survives_a_run_draft_that_raises() -> None: +def test_verify_survives_a_run_draft_that_raises(monkeypatch: pytest.MonkeyPatch) -> None: """Build and Edit already degrade a raising ``run_draft`` to a failed Run; Fix's ``handle_verify`` had no try/except and killed the advance.""" env, _ = _new_env() - env.fake_dify.run_draft = lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("kaboom-provider")) + monkeypatch.setattr( + env.fake_dify, "run_draft", lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("kaboom-provider")) + ) s = _session(current_state=PcState.FIX_VERIFY) res = handle_verify(env, Turn(actor=_actor()), s, saved_test_context(env.repo, s.id)) @@ -1345,7 +1356,7 @@ def test_verify_survives_a_run_draft_that_raises() -> None: assert res.run.culprit_node_id == "" -def test_verify_marks_a_succeeded_run_that_reached_no_end_as_no_output() -> None: +def test_verify_marks_a_succeeded_run_that_reached_no_end_as_no_output(monkeypatch: pytest.MonkeyPatch) -> None: from core.dify_builder.models import NodeOutput env, _ = _new_env() @@ -1363,13 +1374,17 @@ def test_verify_marks_a_succeeded_run_that_reached_no_end_as_no_output() -> None {"source": "node2", "target": "node6", "sourceHandle": "else"}, ], } - env.fake_dify.run_draft = lambda *_a, **_k: Run( - kind="verify", - immutable=True, - dify_run_id="run-f", - verification=successful_verification(env.fake_dify, no_output=True), - status="succeeded", - per_node=[NodeOutput(node_id="node1", status="succeeded"), NodeOutput(node_id="node2", status="succeeded")], + monkeypatch.setattr( + env.fake_dify, + "run_draft", + lambda *_a, **_k: Run( + kind="verify", + immutable=True, + dify_run_id="run-f", + verification=successful_verification(env.fake_dify, no_output=True), + status="succeeded", + per_node=[NodeOutput(node_id="node1", status="succeeded"), NodeOutput(node_id="node2", status="succeeded")], + ), ) s = _session(current_state=PcState.FIX_VERIFY) @@ -1460,12 +1475,16 @@ def test_run_finished_without_output_is_false_without_an_end_node() -> None: assert run_finished_without_output(graph, per_node) is False -def test_each_unknown_outcome_stops_at_the_fix_decision_gate() -> None: +def test_each_unknown_outcome_stops_at_the_fix_decision_gate(monkeypatch: pytest.MonkeyPatch) -> None: from services.dify_builder.run_mapping import TRUNCATED_STREAM_ERROR env, _ = _new_env() - env.fake_dify.run_draft = lambda *_a, **_k: Run( - kind="verify", immutable=True, status="running", per_node=[], error=TRUNCATED_STREAM_ERROR + monkeypatch.setattr( + env.fake_dify, + "run_draft", + lambda *_a, **_k: Run( + kind="verify", immutable=True, status="running", per_node=[], error=TRUNCATED_STREAM_ERROR + ), ) s = _session(current_state=PcState.FIX_VERIFY) @@ -1482,13 +1501,17 @@ def test_each_unknown_outcome_stops_at_the_fix_decision_gate() -> None: ) -def test_re_fix_after_unknown_requires_new_inputs() -> None: +def test_re_fix_after_unknown_requires_new_inputs(monkeypatch: pytest.MonkeyPatch) -> None: """Re-fix clears the input reference; another verification requires new inputs.""" from services.dify_builder.run_mapping import TRUNCATED_STREAM_ERROR env, _ = _new_env() - env.fake_dify.run_draft = lambda *_a, **_k: Run( - kind="verify", immutable=True, status="running", per_node=[], error=TRUNCATED_STREAM_ERROR + monkeypatch.setattr( + env.fake_dify, + "run_draft", + lambda *_a, **_k: Run( + kind="verify", immutable=True, status="running", per_node=[], error=TRUNCATED_STREAM_ERROR + ), ) s = _session(current_state=PcState.FIX_VERIFY) @@ -1526,6 +1549,7 @@ _UNFED_AGGREGATOR_REASON = ( def test_a_repair_the_guards_condemned_reaches_the_gate_with_its_reason( + monkeypatch: pytest.MonkeyPatch, mocker: pytest_mock.MockerFixture, ) -> None: """Parity with Edit's refusal. ``propose_repair`` already refused to stage a @@ -1536,7 +1560,7 @@ def test_a_repair_the_guards_condemned_reaches_the_gate_with_its_reason( is precisely what makes a manual fix possible.""" env, _ = _new_env() s = _session() - fc = DifyBuilderContext() + fc = DifyBuilderContext(diagnosis=Diagnosis(culprit_node_id="output", root_cause="Missing input")) written: list[object] = [] mocker.patch.object(env.fake_dify, "apply_repair", side_effect=lambda *a, **k: written.append((a, k))) @@ -1547,7 +1571,7 @@ def test_a_repair_the_guards_condemned_reaches_the_gate_with_its_reason( _ = diagnosis, graph return ([], Risk(level="high", reason=_UNFED_AGGREGATOR_REASON, has_external_side_effect=False)) - env.agent.propose_repair = propose_repair + monkeypatch.setattr(env.agent, "propose_repair", propose_repair) result = handle_propose(env, Turn(actor=_actor()), s, fc) @@ -1561,12 +1585,12 @@ def test_a_repair_the_guards_condemned_reaches_the_gate_with_its_reason( assert turns[-1].payload["cards"] == [] -def test_a_staged_repair_still_carries_no_error_card() -> None: +def test_a_staged_repair_still_carries_no_error_card(monkeypatch: pytest.MonkeyPatch) -> None: """The other half: the card appears only when there is nothing to stage, so an ordinary proposal is unchanged.""" env, _ = _new_env() s = _session() - fc = DifyBuilderContext() + fc = DifyBuilderContext(diagnosis=Diagnosis(culprit_node_id="output", root_cause="Missing input")) from core.dify_builder.models import MutationIntent def propose_repair( @@ -1579,7 +1603,7 @@ def test_a_staged_repair_still_carries_no_error_card() -> None: Risk(level="low", reason="Config-only fix.", has_external_side_effect=False), ) - env.agent.propose_repair = propose_repair + monkeypatch.setattr(env.agent, "propose_repair", propose_repair) result = handle_propose(env, Turn(actor=_actor()), s, fc) diff --git a/api/tests/unit_tests/core/helper/code_executor/test_code_executor.py b/api/tests/unit_tests/core/helper/code_executor/test_code_executor.py index 7eb419e315d..4aba415bc9d 100644 --- a/api/tests/unit_tests/core/helper/code_executor/test_code_executor.py +++ b/api/tests/unit_tests/core/helper/code_executor/test_code_executor.py @@ -3,7 +3,8 @@ from __future__ import annotations import json from collections.abc import Callable, Iterator from contextlib import ExitStack -from typing import Any, Never, cast +from operator import methodcaller +from typing import Any, Never import httpx import pytest @@ -15,7 +16,7 @@ from core.helper.code_executor.python3.python3_transformer import Python3Templat def test_execute_workflow_code_template_raises_for_unsupported_language() -> None: with pytest.raises(code_executor_module.CodeExecutionError, match="Unsupported language"): - code_executor_module.CodeExecutor.execute_workflow_code_template(cast(Any, "ruby"), "print(1)", {}) + methodcaller("execute_workflow_code_template", "ruby", "print(1)", {})(code_executor_module.CodeExecutor) @pytest.fixture @@ -309,5 +310,5 @@ def test_concurrent_injected_transports_do_not_cross_scopes() -> None: def test_injected_transport_does_not_bypass_unsupported_language() -> None: transport = RecordingTransport("unused") with pytest.raises(code_executor_module.CodeExecutionError, match="Unsupported language ruby"): - code_executor_module.CodeExecutor.execute_code(cast(Any, "ruby"), "", "", transport=transport) + methodcaller("execute_code", "ruby", "", "", transport=transport)(code_executor_module.CodeExecutor) assert transport.calls == [] diff --git a/api/tests/unit_tests/core/workflow/generator/test_native_loop_generation_contract.py b/api/tests/unit_tests/core/workflow/generator/test_native_loop_generation_contract.py index b6a112af526..d32d6e2e5b6 100644 --- a/api/tests/unit_tests/core/workflow/generator/test_native_loop_generation_contract.py +++ b/api/tests/unit_tests/core/workflow/generator/test_native_loop_generation_contract.py @@ -2,9 +2,10 @@ import json from copy import deepcopy -from typing import TypedDict, cast +from typing import TypedDict import pytest +from pydantic import TypeAdapter from core.workflow.generator.prompts.node_builder_prompts import ( format_node_outputs_section, @@ -46,7 +47,7 @@ class _LoopConfig(TypedDict): def _prompt_loop_config() -> _LoopConfig: prompt = get_node_builder_system_prompt("loop") config, _ = json.JSONDecoder().raw_decode(prompt.split("- loop:", 1)[1].lstrip()) - return cast(_LoopConfig, config) + return TypeAdapter(_LoopConfig).validate_python(config) def test_loop_prompt_declares_native_state_instead_of_just_exit_conditions() -> None: diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_confirmed_agent_node.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_confirmed_agent_node.py index 8856912c814..19677516d80 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_confirmed_agent_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_confirmed_agent_node.py @@ -197,12 +197,16 @@ def _confirmed( # A Workflow context has no owning chat conversation. pool = node.runtime_state.variable_pool old_get = pool.get - pool.get = lambda selector: ( - StringSegment(value=context.native_run_id) - if tuple(selector) == ("sys", "workflow_run_id") - else None - if tuple(selector) == ("sys", "conversation_id") - else old_get(selector) + monkeypatch.setattr( + pool, + "get", + lambda selector: ( + StringSegment(value=context.native_run_id) + if tuple(selector) == ("sys", "workflow_run_id") + else None + if tuple(selector) == ("sys", "conversation_id") + else old_get(selector) + ), ) parent = AgentParentNodeRef( ref=context.ref, execution_id=node.execution_id, input_result_ref="input-ref", input_digest="a" * 64 diff --git a/api/tests/unit_tests/core/workflow/test_graph_normalizers.py b/api/tests/unit_tests/core/workflow/test_graph_normalizers.py index 7bc5b080e1f..cbe914ce959 100644 --- a/api/tests/unit_tests/core/workflow/test_graph_normalizers.py +++ b/api/tests/unit_tests/core/workflow/test_graph_normalizers.py @@ -8,7 +8,7 @@ JSON number 60 (``Condition.value: str | Sequence[str] | bool | None``).""" import copy import json from pathlib import Path -from typing import NoReturn, cast, get_args +from typing import NoReturn, get_args import pytest @@ -177,12 +177,13 @@ class TestNormalizeConditions: assert nodes[2]["data"]["filter_by"]["conditions"][0]["value"] == "7" assert nodes[3]["data"]["conditions"][0]["value"] == "2" - def test_other_node_types_and_malformed_shapes_are_left_alone(self) -> None: + @pytest.mark.parametrize("malformed_node", ["not-a-node"]) + def test_other_node_types_and_malformed_shapes_are_left_alone(self, malformed_node: Graph) -> None: nodes: list[Graph] = [ {"id": "llm", "data": {"type": "llm", "value": 3}}, {"id": "odd", "data": {"type": "if-else", "cases": "not-a-list"}}, {"id": "odd2", "data": {"type": "if-else", "cases": [{"conditions": ["junk", None]}]}}, - cast(Graph, "not-a-node"), + malformed_node, ] before = copy.deepcopy(nodes) diff --git a/api/tests/unit_tests/core/workflow/test_node_runtime.py b/api/tests/unit_tests/core/workflow/test_node_runtime.py index e0b4a9421e5..6fb286f059a 100644 --- a/api/tests/unit_tests/core/workflow/test_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/test_node_runtime.py @@ -17,7 +17,7 @@ from contextlib import contextmanager from datetime import UTC, datetime from decimal import Decimal from types import SimpleNamespace -from typing import cast, override +from typing import override from unittest.mock import MagicMock, Mock, patch, sentinel from uuid import uuid4 @@ -2999,13 +2999,14 @@ def test_failing_or_mismatched_factory_never_falls_through_to_ordinary_resolutio get_runtime.assert_not_called() -def test_factory_returning_no_guard_refuses(mocker) -> None: +def test_factory_returning_no_guard_refuses(mocker, monkeypatch: pytest.MonkeyPatch) -> None: get_runtime = mocker.patch.object(node_runtime.ToolManager, "get_workflow_tool_runtime") - def factory(_node_id: str, _node_execution_id: str) -> ConfirmedToolGuard: - return cast(ConfirmedToolGuard, None) + def factory(_node_id: str, _node_execution_id: str) -> None: + return None - runtime = DifyToolNodeRuntime(_build_run_context(), confirmed_guard_factory=factory) + runtime = DifyToolNodeRuntime(_build_run_context(), confirmed_guard_factory=_GuardFactory([])) + monkeypatch.setattr(runtime, "_confirmed_guard_factory", factory) with pytest.raises(node_runtime.ToolRuntimeResolutionError, match="confirmed_guard_unavailable"): runtime.get_runtime( node_id="tool-node", node_data=_builtin_node_data(), variable_pool=None, node_execution_id="actual-exec-7" diff --git a/api/tests/unit_tests/core/workflow/test_restricted_execution.py b/api/tests/unit_tests/core/workflow/test_restricted_execution.py index 9cb8ba601bd..4464a69e45d 100644 --- a/api/tests/unit_tests/core/workflow/test_restricted_execution.py +++ b/api/tests/unit_tests/core/workflow/test_restricted_execution.py @@ -4,11 +4,12 @@ from __future__ import annotations import json import math -from collections.abc import Callable from copy import deepcopy -from typing import Never, TypedDict, cast +from operator import methodcaller +from typing import Never, TypedDict import pytest +from pydantic import TypeAdapter from graphon.file import File, FileTransferMethod, FileType from graphon.http import HttpClientProtocol @@ -114,7 +115,7 @@ def test_runtime_kwargs_deny_before_fixture_return_and_append_receipt(owned: Own assert isinstance(node, HttpRequestNode) client = node.http_client with pytest.raises(PolicyTransportError, match="execution_policy: http_request_arguments"): - cast(Callable[..., object], client.get)("https://sample.invalid/private", **kwargs) + methodcaller("get", "https://sample.invalid/private", **kwargs)(client) assert receipts(owned)[0]["kind"] == "effect_blocked" assert "private" not in json.dumps(receipts(owned)) @@ -273,14 +274,14 @@ def test_with_runtime_state_keeps_exact_policy_recorder_and_private_key(owned: O ], ) def test_http_defaults_stay_bounded_scalar_and_known_keys(bad: dict[str, object]) -> None: - value = cast(RawGraph, graph(http=True)) + value = TypeAdapter(RawGraph).validate_python(graph(http=True)) value["nodes"][1]["data"]["default_value"] = [bad] with pytest.raises(BuilderExecutionPolicyError): admitted_node_bindings(snapshot(value)) def test_fixed_utf8_header_and_scalar_defaults_are_admitted() -> None: - value = cast(RawGraph, graph(http=True)) + value = TypeAdapter(RawGraph).validate_python(graph(http=True)) value["nodes"][1]["data"].update( headers="Content-Type: application/json; charset=utf-8", default_value=[ @@ -349,7 +350,7 @@ def test_response_file_or_unknown_fixture_payload_is_rejected(raw: dict[str, obj "mutation", ["defaults_alias", "duplicate_defaults", "header_variable", "header_charset", "hidden_auth", "options"] ) def test_unreviewed_raw_http_shapes_remain_unavailable(mutation: str) -> None: - value = cast(RawGraph, graph(http=True)) + value = TypeAdapter(RawGraph).validate_python(graph(http=True)) data = value["nodes"][1]["data"] if mutation == "defaults_alias": data["default_values"] = [{"key": "body", "type": "string", "value": "x"}] @@ -360,7 +361,9 @@ def test_unreviewed_raw_http_shapes_remain_unavailable(mutation: str) -> None: elif mutation == "header_charset": data["headers"] = "Content-Type: text/plain; charset=latin-1" elif mutation == "hidden_auth": - cast(dict[str, object], data["authorization"])["config"] = {"type": "bearer", "api_key": "private"} + authorization = data["authorization"] + assert isinstance(authorization, dict) + authorization["config"] = {"type": "bearer", "api_key": "private"} else: data["method"] = "OPTIONS" with pytest.raises(BuilderExecutionPolicyError): diff --git a/api/tests/unit_tests/events/event_handlers/test_delete_tool_parameters_cache_when_sync_draft_workflow.py b/api/tests/unit_tests/events/event_handlers/test_delete_tool_parameters_cache_when_sync_draft_workflow.py index f417d389ae1..b8fb2dbcd9d 100644 --- a/api/tests/unit_tests/events/event_handlers/test_delete_tool_parameters_cache_when_sync_draft_workflow.py +++ b/api/tests/unit_tests/events/event_handlers/test_delete_tool_parameters_cache_when_sync_draft_workflow.py @@ -1,5 +1,7 @@ import json import logging +from collections.abc import Callable +from typing import Never import pytest @@ -105,14 +107,14 @@ def test_missing_tool_provider_does_not_log_error_traceback( ], ) def test_native_draft_cache_receiver_deletes_exact_key_without_resolving_runtime( - monkeypatch, - provider_type, - provider_id, - provider_name, - tool_name, - credential_id, - expected_key, -): + monkeypatch: pytest.MonkeyPatch, + provider_type: str, + provider_id: str, + provider_name: str, + tool_name: str, + credential_id: str | None, + expected_key: str, +) -> None: """Saving a Tool never needs declaration, credential, decrypt or OAuth owners. Real graph adaptation, ToolEntity parsing, receiver and ToolParameterCache @@ -124,17 +126,17 @@ def test_native_draft_cache_receiver_deletes_exact_key_without_resolving_runtime from core.plugin.impl.oauth import OAuthHandler from core.plugin.impl.tool import PluginToolManager - deleted = [] - forbidden = [] + deleted: list[str] = [] + forbidden: list[str] = [] class RedisBoundary: @staticmethod - def delete(key): + def delete(key: str) -> int: deleted.append(key) return 1 - def forbidden_owner(name): - def fail(*_args, **_kwargs): + def forbidden_owner(name: str) -> Callable[..., Never]: + def fail(*_args: object, **_kwargs: object) -> Never: forbidden.append(name) raise AssertionError(f"normal draft cache invalidation must not call {name}") diff --git a/api/tests/unit_tests/libs/test_sensitive_http_telemetry.py b/api/tests/unit_tests/libs/test_sensitive_http_telemetry.py index 3c6f1471fc2..dd30edac98e 100644 --- a/api/tests/unit_tests/libs/test_sensitive_http_telemetry.py +++ b/api/tests/unit_tests/libs/test_sensitive_http_telemetry.py @@ -2,8 +2,9 @@ import io import logging -from collections.abc import Callable, Iterator +from collections.abc import Callable, Generator, Iterator from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager from pathlib import Path from threading import Barrier from typing import Never @@ -67,7 +68,8 @@ def persisted(logs: tuple[io.StringIO, Path]) -> str: return logs[0].getvalue() + logs[1].read_text() -def synthetic_client() -> httpx.Client: +@contextmanager +def synthetic_client() -> Generator[httpx.Client]: """Real HTTPX/HTTPTransport path; replace only the socket-owning httpcore pool.""" transport = httpx.HTTPTransport() assert isinstance(transport._pool, httpcore.ConnectionPool) @@ -76,8 +78,10 @@ def synthetic_client() -> httpx.Client: assert request.url.host == b"example.invalid" return httpcore.Response(200, content=b"ok") - transport._pool.handle_request = response - return httpx.Client(transport=transport, trust_env=False) + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(transport._pool, "handle_request", response) + with httpx.Client(transport=transport, trust_env=False) as client: + yield client def test_current_httpx_info_really_persists_full_query( diff --git a/api/tests/unit_tests/migrations/versions/test_2026_07_23_0203-f6e4c5686857_replace_agent_runtime_sessions_with_.py b/api/tests/unit_tests/migrations/versions/test_2026_07_23_0203-f6e4c5686857_replace_agent_runtime_sessions_with_.py index cc5fa86ec4b..9d78c108206 100644 --- a/api/tests/unit_tests/migrations/versions/test_2026_07_23_0203-f6e4c5686857_replace_agent_runtime_sessions_with_.py +++ b/api/tests/unit_tests/migrations/versions/test_2026_07_23_0203-f6e4c5686857_replace_agent_runtime_sessions_with_.py @@ -3,8 +3,9 @@ from __future__ import annotations import importlib.util from collections.abc import Callable from pathlib import Path -from typing import Protocol, cast +from typing import Protocol, runtime_checkable +import pytest import sqlalchemy as sa from alembic.migration import MigrationContext from alembic.operations import Operations @@ -15,10 +16,13 @@ _MIGRATION_PATH = ( ) +@runtime_checkable class _MigrationModule(Protocol): - op: Operations - upgrade: Callable[[], None] - downgrade: Callable[[], None] + @property + def upgrade(self) -> Callable[[], None]: ... + + @property + def downgrade(self) -> Callable[[], None]: ... def _load_migration_module() -> _MigrationModule: @@ -27,7 +31,8 @@ def _load_migration_module() -> _MigrationModule: raise RuntimeError("failed to load Agent Workspace migration") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) - return cast(_MigrationModule, module) + assert isinstance(module, _MigrationModule) + return module def _create_pre_upgrade_schema(engine: sa.Engine) -> None: @@ -95,12 +100,9 @@ def _create_pre_upgrade_schema(engine: sa.Engine) -> None: def _run_upgrade(module: _MigrationModule, engine: sa.Engine) -> None: with engine.begin() as connection: operations = Operations(MigrationContext.configure(connection)) - original_op = module.op - module.op = operations - try: + with pytest.MonkeyPatch.context() as patch: + patch.setattr(module, "op", operations) module.upgrade() - finally: - module.op = original_op def test_upgrade_replaces_runtime_sessions_with_workspace_schema() -> None: @@ -150,10 +152,12 @@ def test_mysql_workspace_downgrade_restores_portable_runtime_session_types() -> module = _load_migration_module() output = StringIO() - module.op = Operations( + operations = Operations( MigrationContext.configure(dialect_name="mysql", opts={"as_sql": True, "output_buffer": output}) ) - module.downgrade() + with pytest.MonkeyPatch.context() as patch: + patch.setattr(module, "op", operations) + module.downgrade() sql = output.getvalue() assert "id CHAR(36) NOT NULL" in sql assert "session_snapshot LONGTEXT NOT NULL" in sql diff --git a/api/tests/unit_tests/models/test_dify_builder_models.py b/api/tests/unit_tests/models/test_dify_builder_models.py index aba3f1339ad..261472f19ce 100644 --- a/api/tests/unit_tests/models/test_dify_builder_models.py +++ b/api/tests/unit_tests/models/test_dify_builder_models.py @@ -6,10 +6,9 @@ values (`id`, `version`, timestamps) populate on flush, and the """ from collections.abc import Iterator -from typing import cast import pytest -from sqlalchemy import create_engine +from sqlalchemy import create_engine, update from sqlalchemy.engine import Engine from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -166,8 +165,6 @@ def test_run_and_test_input_round_trip(session: Session) -> None: kind="verify", dify_run_id="run-123", status="succeeded", - # Preserve this legacy JSON shape: the round trip must not coerce it. - per_node=cast(list[dict[str, object]], {"node-1": "ok"}), culprit_node_id=None, inputs_ref="ref-1", tokens=42, @@ -182,9 +179,13 @@ def test_run_and_test_input_round_trip(session: Session) -> None: ) session.add_all([run, test_input]) session.flush() + # Seed the legacy JSON shape at the database boundary, outside the typed constructor. + table = Base.metadata.tables[DifyBuilderRun.__tablename__] + session.execute(update(table).where(table.c.id == run.id).values(per_node={"node-1": "ok"})) session.refresh(run) session.refresh(test_input) assert run.id assert run.immutable is True + assert run.per_node == {"node-1": "ok"} assert test_input.id diff --git a/api/tests/unit_tests/models/test_dify_builder_typed_models.py b/api/tests/unit_tests/models/test_dify_builder_typed_models.py index e8e9236dc59..c8d044eda4c 100644 --- a/api/tests/unit_tests/models/test_dify_builder_typed_models.py +++ b/api/tests/unit_tests/models/test_dify_builder_typed_models.py @@ -2,9 +2,7 @@ import dataclasses import inspect -from collections.abc import Callable from datetime import UTC, datetime -from typing import cast from uuid import uuid4 import pytest @@ -30,7 +28,7 @@ def _table(model: type[TypeBase]) -> sa.Table: def _new[T: TypeBase](model: type[T], values: dict[str, object]) -> T: # SQL metadata supplies heterogeneous constructor fields for each real model. - return cast(Callable[..., T], model)(**values) + return model(**values) @pytest.mark.parametrize("model", MODELS, ids=lambda model: model.__name__) diff --git a/api/tests/unit_tests/repositories/test_workflow_draft_sync_repository.py b/api/tests/unit_tests/repositories/test_workflow_draft_sync_repository.py index 95db7262fd9..bb77fbad50d 100644 --- a/api/tests/unit_tests/repositories/test_workflow_draft_sync_repository.py +++ b/api/tests/unit_tests/repositories/test_workflow_draft_sync_repository.py @@ -13,7 +13,7 @@ def test_graphs_stay_opaque_to_lua_and_round_trip_without_changing_values( previous: dict[str, object] | None, monkeypatch: pytest.MonkeyPatch, mocker: pytest_mock.MockerFixture ) -> None: redis = repo_module.redis_client - mocker.patch.object(redis, "eval", return_value=1) + eval_script = mocker.patch.object(redis, "eval", return_value=1) mocker.patch.object(redis, "get") monkeypatch.setattr(repo_module, "serialize_redis_name", lambda key: f"prefix:{key}") repository = WorkflowDraftSyncRepository() @@ -27,7 +27,7 @@ def test_graphs_stay_opaque_to_lua_and_round_trip_without_changing_values( assert repository.announce("app", change) - _script, key_count, key, payload, _ttl = redis.eval.call_args.args + _script, key_count, key, payload, _ttl = eval_script.call_args.args assert key_count == 1 assert key == "prefix:workflow_server_draft:app" encoded = json.loads(payload) diff --git a/api/tests/unit_tests/services/agent/test_workflow_run_recovery.py b/api/tests/unit_tests/services/agent/test_workflow_run_recovery.py index da80cd813cc..68ae68a1638 100644 --- a/api/tests/unit_tests/services/agent/test_workflow_run_recovery.py +++ b/api/tests/unit_tests/services/agent/test_workflow_run_recovery.py @@ -2,7 +2,6 @@ import dataclasses import json -from typing import cast import httpx import pytest @@ -61,16 +60,12 @@ def test_create_intent_is_committed_before_transport_and_survives_service_restar assert restarted.process_data(scope=_scope())["kept"] == "value" +@pytest.mark.parametrize("missing_client", [None]) def test_recovery_needs_no_executable_request_and_never_posts( - recovery: WorkflowAgentRunRecovery, mocker: pytest_mock.MockerFixture + recovery: WorkflowAgentRunRecovery, mocker: pytest_mock.MockerFixture, missing_client: AgentCreateRecoveryClient ) -> None: # No durable intent: even a missing client must remain untouched. - assert ( - recovery.recover_existing( - scope=_scope(), client=cast(AgentCreateRecoveryClient, None), on_persisted=lambda _data: None - ) - is None - ) + assert recovery.recover_existing(scope=_scope(), client=missing_client, on_persisted=lambda _data: None) is None recovery.reserve(scope=_scope(), attempt=0, key="saved", digest="a" * 64) seen: list[LookupRunRequest] = [] response = CreateRunResponse(run_id="original-child", status="succeeded") diff --git a/api/tests/unit_tests/services/code_execution/test_native_scope_transport.py b/api/tests/unit_tests/services/code_execution/test_native_scope_transport.py index 432069d84b5..0d4cd19d45f 100644 --- a/api/tests/unit_tests/services/code_execution/test_native_scope_transport.py +++ b/api/tests/unit_tests/services/code_execution/test_native_scope_transport.py @@ -6,8 +6,9 @@ import asyncio import json from collections.abc import Callable, Coroutine from dataclasses import replace +from operator import call from pathlib import Path -from typing import Literal, Never, Unpack, cast, override +from typing import Literal, Never, Unpack, override import httpx2 as httpx import pytest @@ -192,19 +193,13 @@ class TransportOverrides(TypedDict, total=False, closed=True): sync_owner: NativeCodeSyncOwner | None -class BoundOverrides(TypedDict, total=False, closed=True): - timeout_seconds: float - output_bytes: int - max_pages: int - input_bytes: int - close_seconds: float - - def setup_transport( outputs: list[dict[str, object]] | None = None, **overrides: Unpack[TransportOverrides] ) -> tuple[NativeScopeCodeExecutionTransport, ScopedSDK, DurableOwner, LifetimeOwner]: sdk = ScopedSDK(outputs) - sdk.final_offset = cast(int, outputs[-1]["offset"]) if outputs else len(b"<>{}<>\n") + offset = outputs[-1]["offset"] if outputs else len(b"<>{}<>\n") + assert isinstance(offset, int) + sdk.final_offset = offset durable, lifetime = DurableOwner(), LifetimeOwner() sdk.durable = durable args: TransportArgs = { @@ -337,7 +332,7 @@ def test_async_entry_does_not_nest_the_existing_event_loop() -> None: @pytest.mark.parametrize("boundary", ["associate", "current_after_input", "input_timeout", "close", "settle"]) -def test_boundary_failure_redacts_and_settles(boundary: str) -> None: +def test_boundary_failure_redacts_and_settles(boundary: str, monkeypatch: pytest.MonkeyPatch) -> None: transport, sdk, durable, lifetime = setup_transport() if boundary == "associate": @@ -345,7 +340,7 @@ def test_boundary_failure_redacts_and_settles(boundary: str) -> None: del identity, job_id raise ValueError("private persistence failure") - durable.associate_job = reject + monkeypatch.setattr(durable, "associate_job", reject) elif boundary == "current_after_input": original = sdk.respond @@ -355,7 +350,7 @@ def test_boundary_failure_redacts_and_settles(boundary: str) -> None: lifetime.current = False return result - sdk.respond = respond + monkeypatch.setattr(sdk, "respond", respond) elif boundary == "input_timeout": original = sdk.respond @@ -364,7 +359,7 @@ def test_boundary_failure_redacts_and_settles(boundary: str) -> None: raise httpx.ReadTimeout("private timeout", request=request) return original(request) - sdk.respond = respond + monkeypatch.setattr(sdk, "respond", respond) elif boundary == "close": original_factory = sdk.job_client @@ -376,10 +371,10 @@ def test_boundary_failure_redacts_and_settles(boundary: str) -> None: await original_close() raise ValueError("private close") - client.close = close + monkeypatch.setattr(client, "close", close) return client - sdk.job_client = factory + monkeypatch.setattr(sdk, "job_client", factory) else: async def settle(identity: NativeCodeIdentity, *, succeeded: bool) -> None: @@ -387,7 +382,7 @@ def test_boundary_failure_redacts_and_settles(boundary: str) -> None: lifetime.settlements.append(succeeded) raise ValueError("private settlement") - lifetime.settle = settle + monkeypatch.setattr(lifetime, "settle", settle) with pytest.raises(CodeExecutionError, match="^Native code execution unavailable$"): execute(transport) assert sdk.closed == 1 @@ -396,7 +391,7 @@ def test_boundary_failure_redacts_and_settles(boundary: str) -> None: assert sum(r.url.path.endswith("/input") for r in sdk.requests) <= 1 -def test_total_deadline_cancels_wait_and_closes_owned_client() -> None: +def test_total_deadline_cancels_wait_and_closes_owned_client(monkeypatch: pytest.MonkeyPatch) -> None: transport, sdk, durable, lifetime = setup_transport() transport = replace(transport, bounds=replace(transport.bounds, timeout_seconds=0.01)) original_factory = sdk.job_client @@ -409,10 +404,10 @@ def test_total_deadline_cancels_wait_and_closes_owned_client() -> None: await asyncio.sleep(1) raise AssertionError("deadline did not cancel the controlled SDK operation") - client.run = run + monkeypatch.setattr(client, "run", run) return client - sdk.job_client = factory + monkeypatch.setattr(sdk, "job_client", factory) with pytest.raises(CodeExecutionError): execute(transport) assert sdk.closed == 1 @@ -443,15 +438,17 @@ def test_page_count_exhaustion_never_reports_partial_success() -> None: def test_invalid_or_exceeded_bounds_refuse_before_sdk(bound: str, value: float) -> None: transport, sdk, _, _ = setup_transport() # Invalid integer bounds deliberately retain their original noninteger runtime values. - overrides = cast(BoundOverrides, {bound: value}) - transport = replace(transport, bounds=replace(transport.bounds, **overrides)) + overrides = {bound: value} + transport = replace(transport, bounds=call(replace, transport.bounds, **overrides)) with pytest.raises(CodeExecutionError): execute(transport) assert sdk.requests == [] @pytest.mark.parametrize("language", ["python3", "javascript", "jinja2"]) -def test_actual_offline_runner_transformer_roundtrip_with_sdk_response_double(language: str, tmp_path: Path) -> None: +def test_actual_offline_runner_transformer_roundtrip_with_sdk_response_double( + language: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Real interpreter/transformer execution; HTTP/scope/kernel/lifetime remain doubles.""" import shutil import subprocess @@ -499,7 +496,7 @@ def test_actual_offline_runner_transformer_roundtrip_with_sdk_response_double(la sdk.final_offset = len(result.stdout.encode()) return original(request) - sdk.respond = respond + monkeypatch.setattr(sdk, "respond", respond) typed = {"nested": [1, True, None, {"x": 2.5}]} expected = {"result": "Hello " + value} if language == "jinja2" else {"answer": value, "typed": typed} assert ( @@ -511,7 +508,9 @@ def test_actual_offline_runner_transformer_roundtrip_with_sdk_response_double(la assert sdk.closed == 1 -def test_actual_python_preload_runs_before_code_and_preserves_stdout(tmp_path: Path) -> None: +def test_actual_python_preload_runs_before_code_and_preserves_stdout( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: import subprocess import sys @@ -538,14 +537,14 @@ def test_actual_python_preload_runs_before_code_and_preserves_stdout(tmp_path: P sdk.final_offset = len(result.stdout.encode()) return original(request) - sdk.respond = respond + monkeypatch.setattr(sdk, "respond", respond) assert ( transport.execute(language="python3", preload="value = 'é'\nprint('preload')", code="print(value)") == "preload\né\n" ) -def test_slow_http_close_cannot_escape_settlement_deadline() -> None: +def test_slow_http_close_cannot_escape_settlement_deadline(monkeypatch: pytest.MonkeyPatch) -> None: transport, sdk, durable, lifetime = setup_transport() transport = replace(transport, bounds=replace(transport.bounds, close_seconds=0.01)) original_factory = sdk.job_client @@ -559,17 +558,17 @@ def test_slow_http_close_cannot_escape_settlement_deadline() -> None: await asyncio.sleep(1) raise AssertionError("deadline did not cancel the controlled SDK operation") - client.close = close + monkeypatch.setattr(client, "close", close) return client - sdk.job_client = factory + monkeypatch.setattr(sdk, "job_client", factory) with pytest.raises(CodeExecutionError): execute(transport) assert lifetime.settlements == [False] assert durable.unknowns >= 1 -def test_borrowed_http_client_lifetime_is_preserved_by_actual_sdk() -> None: +def test_borrowed_http_client_lifetime_is_preserved_by_actual_sdk(monkeypatch: pytest.MonkeyPatch) -> None: transport, sdk, _, _ = setup_transport() async def scenario() -> None: @@ -578,7 +577,7 @@ def test_borrowed_http_client_lifetime_is_preserved_by_actual_sdk() -> None: def factory(handle: NativeScopeHandle) -> ShellctlClient: return ShellctlClient(base_url=sdk.endpoint, token=sdk.auth_token, native_scope=handle, client=borrowed) - sdk.job_client = factory + monkeypatch.setattr(sdk, "job_client", factory) assert ( await transport.execute_async(language="python3", preload="", code="private") == "<>{}<>\n" @@ -590,14 +589,14 @@ def test_borrowed_http_client_lifetime_is_preserved_by_actual_sdk() -> None: runner.run(scenario()) -def test_async_unknown_persistence_failure_is_redacted_and_still_settles() -> None: +def test_async_unknown_persistence_failure_is_redacted_and_still_settles(monkeypatch: pytest.MonkeyPatch) -> None: transport, sdk, durable, lifetime = setup_transport([page(exit_code=1)]) def reject(identity: NativeCodeIdentity) -> None: del identity raise ValueError("private persistence outage") - durable.mark_unknown = reject + monkeypatch.setattr(durable, "mark_unknown", reject) with asyncio.Runner() as runner, pytest.raises(CodeExecutionError, match="^Native code execution unavailable$"): runner.run(transport.execute_async(language="python3", preload="", code="private")) assert sdk.closed == 1 @@ -605,7 +604,7 @@ def test_async_unknown_persistence_failure_is_redacted_and_still_settles() -> No @pytest.mark.parametrize("phase", ["close", "settle", "body_then_repeated"]) -def test_external_cancellation_waits_for_bounded_ownership_handoff(phase: str) -> None: +def test_external_cancellation_waits_for_bounded_ownership_handoff(phase: str, monkeypatch: pytest.MonkeyPatch) -> None: """Event-driven cancellation of the actual adapter lifecycle, never sleeps to race it.""" transport, sdk, durable, lifetime = setup_transport() transport = replace(transport, bounds=replace(transport.bounds, close_seconds=0.04)) @@ -629,7 +628,7 @@ def test_external_cancellation_waits_for_bounded_ownership_handoff(phase: str) - await original_close() closed = True - client.close = close + monkeypatch.setattr(client, "close", close) if phase == "body_then_repeated": async def run(*args: object, **kwargs: object) -> Never: @@ -640,10 +639,10 @@ def test_external_cancellation_waits_for_bounded_ownership_handoff(phase: str) - await asyncio.Event().wait() raise AssertionError("controlled SDK operation unexpectedly resumed") - client.run = run + monkeypatch.setattr(client, "run", run) return client - sdk.job_client = factory + monkeypatch.setattr(sdk, "job_client", factory) async def settle(identity: NativeCodeIdentity, *, succeeded: bool) -> None: del identity @@ -651,7 +650,7 @@ def test_external_cancellation_waits_for_bounded_ownership_handoff(phase: str) - settle_entered.set() await settle_release.wait() - lifetime.settle = settle + monkeypatch.setattr(lifetime, "settle", settle) loop = asyncio.get_running_loop() started = loop.time() operation = asyncio.create_task(transport.execute_async(language="python3", preload="", code="private")) @@ -703,7 +702,9 @@ def test_external_cancellation_waits_for_bounded_ownership_handoff(phase: str) - @pytest.mark.parametrize("phase", ["close", "settle"]) -def test_cancelled_cleanup_keeps_original_deadline_and_leaves_no_task(phase: str) -> None: +def test_cancelled_cleanup_keeps_original_deadline_and_leaves_no_task( + phase: str, monkeypatch: pytest.MonkeyPatch +) -> None: transport, sdk, durable, lifetime = setup_transport() budget = 0.03 transport = replace(transport, bounds=replace(transport.bounds, close_seconds=budget)) @@ -723,10 +724,10 @@ def test_cancelled_cleanup_keeps_original_deadline_and_leaves_no_task(phase: str entered.set() await never_release.wait() - client.close = close + monkeypatch.setattr(client, "close", close) return client - sdk.job_client = factory + monkeypatch.setattr(sdk, "job_client", factory) else: async def settle(identity: NativeCodeIdentity, *, succeeded: bool) -> None: @@ -735,7 +736,7 @@ def test_cancelled_cleanup_keeps_original_deadline_and_leaves_no_task(phase: str entered.set() await never_release.wait() - lifetime.settle = settle + monkeypatch.setattr(lifetime, "settle", settle) operation = asyncio.create_task(transport.execute_async(language="python3", preload="", code="private")) await asyncio.wait_for(entered.wait(), timeout=1) loop = asyncio.get_running_loop() diff --git a/api/tests/unit_tests/services/dify_builder/draft_tool_inventory.py b/api/tests/unit_tests/services/dify_builder/draft_tool_inventory.py index dcf80d51d5e..007a9c27f16 100644 --- a/api/tests/unit_tests/services/dify_builder/draft_tool_inventory.py +++ b/api/tests/unit_tests/services/dify_builder/draft_tool_inventory.py @@ -1,12 +1,10 @@ """ENG-1200 external daemon boundary with real declarations and credential SQL.""" from dataclasses import dataclass -from typing import cast import pytest from sqlalchemy.engine import Engine -from core.dify_builder.models import Graph from services.credentials.query import CredentialQuery from tests.unit_tests.services.dify_builder.installed_tool_inventory import ( OTHER_TENANT_ID, @@ -19,6 +17,7 @@ from tests.unit_tests.services.dify_builder.installed_tool_inventory import ( install_fake_daemon, provider_payload, ) +from tests.unit_tests.services.dify_builder.typed_fixtures import dictionaries, dictionary AUTH_PROVIDER = "acme/huddle/huddle" FREE_PROVIDER = "acme/free/free" @@ -57,8 +56,8 @@ def draft_tools(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> Draft [FakeTool("post", "Post to Huddle", "Post a message to a channel.")], needs_credentials=True, ) - declaration = cast(Graph, auth["declaration"]) - declaration["tools"][0]["parameters"] = [ + declaration = dictionary(auth["declaration"]) + dictionaries(declaration["tools"])[0]["parameters"] = [ { "name": "message", "type": "string", @@ -69,7 +68,10 @@ def draft_tools(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> Draft "llm_description": "Message to post", } ] - declaration["tools"][0]["output_schema"] = {"type": "object", "properties": {"message_id": {"type": "string"}}} + dictionaries(declaration["tools"])[0]["output_schema"] = { + "type": "object", + "properties": {"message_id": {"type": "string"}}, + } daemon = install_fake_daemon( monkeypatch, FakeToolDaemon( diff --git a/api/tests/unit_tests/services/dify_builder/installed_tool_inventory.py b/api/tests/unit_tests/services/dify_builder/installed_tool_inventory.py index 2e20ea3d0b4..6eac7eff452 100644 --- a/api/tests/unit_tests/services/dify_builder/installed_tool_inventory.py +++ b/api/tests/unit_tests/services/dify_builder/installed_tool_inventory.py @@ -18,7 +18,7 @@ import json from collections.abc import Callable, Iterable, Sequence from dataclasses import dataclass, field from types import ModuleType -from typing import NoReturn, cast +from typing import NoReturn from urllib.parse import urlsplit import httpx @@ -32,6 +32,7 @@ from models.credential_permission import CredentialPermission from models.credential_permission import CredentialType as CredentialPermissionType from models.enums import PermissionEnum from models.tools import BuiltinToolProvider +from tests.unit_tests.services.dify_builder.typed_fixtures import list_value TENANT_ID = "aaaaaaaa-0000-4000-8000-000000000001" OTHER_TENANT_ID = "aaaaaaaa-0000-4000-8000-000000000002" @@ -265,7 +266,7 @@ class FakeToolDaemon: return respond(400, {"code": -400, "message": "bad category"}) return respond(200, {"code": 0, "message": "", "data": {"plugin_ids": self._installed_ids(tenant_id)}}) if route == INSTALLATION_BATCH: - plugin_ids = [str(plugin_id) for plugin_id in cast(list[object], (body or {}).get("plugin_ids") or [])] + plugin_ids = [str(plugin_id) for plugin_id in list_value((body or {}).get("plugin_ids") or [])] if len(plugin_ids) > 256: return respond(400, {"code": -400, "message": "plugin_ids exceeds max=256"}) rows = [installation_payload(tenant_id, p) for p in providers if p["plugin_id"] in plugin_ids] diff --git a/api/tests/unit_tests/services/dify_builder/native_owner_fixtures.py b/api/tests/unit_tests/services/dify_builder/native_owner_fixtures.py index 42f1da589ea..0c2fb91e501 100644 --- a/api/tests/unit_tests/services/dify_builder/native_owner_fixtures.py +++ b/api/tests/unit_tests/services/dify_builder/native_owner_fixtures.py @@ -1,7 +1,6 @@ """Real SQLite-backed operation owners for focused Builder boundary tests.""" from collections.abc import Generator -from typing import cast import pytest from sqlalchemy import Table, create_engine @@ -38,7 +37,9 @@ def operations_service() -> Generator[AgentOperationService]: NativeTestInvocation, NativeTestScope, ): - cast(Table, model.__table__).create(engine) + table = model.__table__ + assert isinstance(table, Table) + table.create(engine) sessions = sessionmaker(engine, expire_on_commit=False) loader = OwnedNativeTestLoader( sessions, actor=Actor(account_id="actor", tenant_id="tenant"), resolver=AgentOperationBindingResolver() diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_agent_associations.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_agent_associations.py index d4f038430a6..0ee35aa266d 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_agent_associations.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_agent_associations.py @@ -12,12 +12,13 @@ import importlib.util from collections.abc import Callable, Generator from dataclasses import dataclass, replace from types import ModuleType -from typing import NotRequired, cast, override +from typing import NotRequired, override import pytest import pytest_mock from pydantic import ValidationError from sqlalchemy import Select, inspect +from sqlalchemy.orm import Session from typing_extensions import TypedDict from core.dify_builder.models import Actor @@ -42,10 +43,17 @@ from runtime_contracts.builder.native.runtime_contracts import ( NodeJournalState, ResolvedNativeTestContext, ) +from services.dify_builder.native_testing.http_binding import NativeHttpBindingResolver from services.dify_builder.native_testing.private_reads import PrivateReadOwner from services.dify_builder.native_testing.repository import OwnedNativeTest, OwnedNativeTestLoader from services.dify_builder.native_testing.state_store import NativeExecutionStateStore, ObjectReceipt from tests.unit_tests.services.dify_builder.native_testing.test_node_inputs import LoaderStandIn, SQLStandIn +from tests.unit_tests.services.dify_builder.native_testing.typed_boundaries import ( + checked_row, + selected_rows, + sql_session, + sql_session_factory, +) EXECUTION = "81d492fd-05ce-46a8-8c82-a35d4b343d48" REF = NativeTestContextRef(request_id="request", invocation_id="inv", context_digest="a" * 64) @@ -388,21 +396,19 @@ def state_case(monkeypatch: pytest.MonkeyPatch) -> AgentStateCase: def scalar[T](self, query: Select[tuple[T]]) -> T | None: entity = query.column_descriptions[0]["entity"] if entity is NativeTestInvocation: - return cast(T, self.agent_owner.invocation) + return selected_rows(query, [self.agent_owner.invocation])[0] if entity in (NativeContinuation, NativePayloadObject): params = {key.rsplit("_", 1)[0]: value for key, value in query.compile().params.items()} - return cast( - T | None, - next( - ( - row - for row in self.owner.rows - if isinstance(row, entity) - and all(inspect(row).attrs[key].value == value for key, value in params.items()) - ), - None, + row = next( + ( + row + for row in self.owner.rows + if isinstance(row, entity) + and all(inspect(row).attrs[key].value == value for key, value in params.items()) ), + None, ) + return selected_rows(query, [row])[0] if row is not None else None if entity in self.agent_owner.resources: row = self.agent_owner.resources[entity] if row is None: @@ -424,7 +430,7 @@ def state_case(monkeypatch: pytest.MonkeyPatch) -> AgentStateCase: "binding_type", ): assert inspect(row).attrs[field].value == value - return cast(T, inspect(row).attrs.id.value if entity is App else row) + return selected_rows(query, [inspect(row).attrs.id.value if entity is App else row])[0] return super().scalar(query) @override @@ -443,7 +449,7 @@ def state_case(monkeypatch: pytest.MonkeyPatch) -> AgentStateCase: assert populate_existing if model in self.agent_owner.resources: row = self.agent_owner.resources[model] - return cast(T, row) if row is not None and inspect(row).attrs.id.value == identity else None + return checked_row(model, row) if row is not None and inspect(row).attrs.id.value == identity else None return super().get(model, identity) class AgentLoader(AgentLoaderState): @@ -656,8 +662,8 @@ def state_case(monkeypatch: pytest.MonkeyPatch) -> AgentStateCase: assert not loader.writing return bytes.fromhex(value).decode() - def authority(owner: AgentLoaderState, ref: NativeTestContextRef) -> PrivateReadOwner: - assert not owner.writing, "permission outside SQL writer" + def authority(owner: OwnedNativeTestLoader, ref: NativeTestContextRef) -> PrivateReadOwner: + assert not loader.writing, "permission outside SQL writer" owner.load(ref=ref) assert loader.state.app_id is not None assert loader.state.workflow_id is not None @@ -689,7 +695,21 @@ def state_case(monkeypatch: pytest.MonkeyPatch) -> AgentStateCase: lambda *_args, **_kwargs: Workflow(graph=json.dumps(graph), version="published"), ) monkeypatch.setattr(module, "native_workflow_revision", lambda _workflow: "revision") - store = module.NativeExecutionStateStore(cast(OwnedNativeTestLoader, loader)) + owner_loader = OwnedNativeTestLoader( + sql_session_factory(monkeypatch, loader.session_factory, methods=SQLStandIn.sql_methods), + actor=loader.actor, + resolver=NativeHttpBindingResolver(), + ) + monkeypatch.setattr(owner_loader, "load", loader.load) + monkeypatch.setattr(owner_loader, "_load", loader._load) + + @contextmanager + def locked(*, ref: NativeTestContextRef) -> Generator[tuple[Session, OwnedNativeTest, NativeTestScope]]: + with loader.locked(ref=ref) as (boundary, owned, scope): + yield sql_session(monkeypatch, boundary.sql_methods()), owned, scope + + monkeypatch.setattr(owner_loader, "locked", locked) + store = module.NativeExecutionStateStore(owner_loader) receipt = store.prepare_object( ref=loader.ref, tenant_id="tenant", @@ -1279,7 +1299,7 @@ def test_actual_operation_cursor_finish_is_untouched_by_agent_associations( from services.dify_builder.native_testing.operation_journal import NodeOperationJournal c = state_case - authorization = EffectAuthorizationService(cast(OwnedNativeTestLoader, c.loader)) + authorization = EffectAuthorizationService(c.store.loader) before_call = mocker.spy(authorization, "before_call") journal = NodeOperationJournal(state=c.store, authorization=authorization) cursor = journal.open(ref=c.ref, occurrence_path=(EXECUTION,)) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_agent_context.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_agent_context.py index e862ee1ea74..fde7e5661b6 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_agent_context.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_agent_context.py @@ -12,7 +12,7 @@ from core.app.entities.app_invoke_entities import ( from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper from runtime_contracts.builder.native.agent_context import BuilderAgentExecutionContext from runtime_contracts.builder.native.contracts import NativeTestContextRef -from tests.unit_tests.core.app.entities.test_app_invoke_entities import _create_workflow_generate_entity +from tests.unit_tests.core.app.entities.test_app_invoke_entities import _create_workflow_generate_entity, _trace_scope def marker() -> BuilderAgentExecutionContext: @@ -51,7 +51,9 @@ def test_marker_round_trips_graph_context_and_pause_entity() -> None: builder_agent_execution=context, )["_dify"] assert DifyRunContext.model_validate_json(run.model_dump_json()).builder_agent_execution == context - entity = _create_workflow_generate_entity().model_copy(update={"builder_agent_execution": context}) + entity = _create_workflow_generate_entity(trace_scope=_trace_scope()).model_copy( + update={"builder_agent_execution": context} + ) pause = WorkflowResumptionContext( serialized_graph_runtime_state="{}", generate_entity=_WorkflowGenerateEntityWrapper(entity=entity), diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_authorization.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_authorization.py index 02ae6aa38c2..731da8c572d 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_authorization.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_authorization.py @@ -1,8 +1,8 @@ """Pure policy checks; atomic storage is exercised only against PostgreSQL in CI.""" -from collections.abc import MutableMapping from datetime import UTC, datetime, timedelta -from typing import TypedDict, cast +from types import MappingProxyType +from typing import TypedDict import pytest from sqlalchemy.orm import Session @@ -180,8 +180,8 @@ def test_reservation_profile_is_immutable_after_creation() -> None: ) limits["cpu_ms"] = 999 assert reservation.limits["cpu_ms"] == 10 - with pytest.raises(TypeError): - cast(MutableMapping[str, int], reservation.profile)["cpu_ms"] = 999 + assert isinstance(reservation.profile, MappingProxyType) + assert reservation.profile["cpu_ms"] == 10 @pytest.mark.parametrize( diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_call_adapter.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_call_adapter.py index 81533a9c221..2cb11e7a8dd 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_call_adapter.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_call_adapter.py @@ -7,7 +7,8 @@ by these synthetic values. Counters expose each physical callback attempt. import importlib.util from collections.abc import Callable, Iterator from copy import deepcopy -from typing import NoReturn, cast +from operator import methodcaller +from typing import NoReturn import pytest @@ -180,7 +181,7 @@ class Authorization: class Case: - def __init__(self, *, max_result_bytes: object = 4096) -> None: + def __init__(self, *, max_result_bytes: int = 4096) -> None: self.events: list[tuple[object, ...]] = [] self.state = State(self.events) self.authorization = Authorization(self.events) @@ -191,10 +192,10 @@ class Case: state=self.state, authorization=self.authorization, journal=self.journal, - max_result_bytes=cast(int, max_result_bytes), + max_result_bytes=max_result_bytes, ) self.calls = 0 - self.value: object = {"typed": [False, 2, "2", None]} + self.value: JsonValue = {"typed": [False, 2, "2", None]} self.dispatch_hook: Callable[[], None] | None = None self.dispatch_error: BaseException | None = None @@ -220,7 +221,7 @@ class Case: self.dispatch_hook() if self.dispatch_error: raise self.dispatch_error - return cast(JsonValue, self.value) + return self.value def call(self, intent: EffectIntent | None = None) -> JsonValue: return self.adapter.call(ref=REF, intent=intent or self.intent(), dispatch=self.dispatch) @@ -333,16 +334,24 @@ def test_callback_failure_records_unknown_once_and_never_replays(error: BaseExce @pytest.mark.parametrize("value", [float("inf"), float("nan"), (1, 2), {1: "wrong-key"}, b"bytes", object()]) -def test_non_json_or_non_finite_callback_result_is_unknown_and_never_stored(value: object) -> None: +def test_non_json_or_non_finite_callback_result_is_unknown_and_never_stored( + value: object, monkeypatch: pytest.MonkeyPatch +) -> None: case = Case() - case.value = value + dispatch = case.dispatch + + def malformed(permit: DispatchPermit) -> object: + dispatch(permit) + return value + + monkeypatch.setattr(case, "dispatch", malformed) assert_unknown(case) assert case.calls == 1 assert not any(event[0] == "store" for event in case.events) assert case.authorization.outcomes == {0: ("unknown", None)} -def test_generator_is_not_consumed_or_treated_as_scalar_completion() -> None: +def test_generator_is_not_consumed_or_treated_as_scalar_completion(monkeypatch: pytest.MonkeyPatch) -> None: case = Case() consumed: list[bool] = [] @@ -350,8 +359,14 @@ def test_generator_is_not_consumed_or_treated_as_scalar_completion() -> None: consumed.append(True) yield "prefix" - case.value = stream() - assert isinstance(case.value, Iterator) + value = stream() + dispatch = case.dispatch + + def malformed(permit: DispatchPermit) -> Iterator[str]: + dispatch(permit) + return value + + monkeypatch.setattr(case, "dispatch", malformed) assert_unknown(case) assert consumed == [] assert case.calls == 1 @@ -470,8 +485,15 @@ def test_duplicate_delivery_while_claimed_never_issues_another_physical_call() - @pytest.mark.parametrize("limit", [0, -1, True, 1.5, "4096", None]) def test_invalid_result_budget_is_refused_before_any_effect(limit: object) -> None: + case = Case() with pytest.raises(ValueError, match="^native_call_result_budget_required$"): - Case(max_result_bytes=limit) + methodcaller( + "__call__", + state=case.state, + authorization=case.authorization, + journal=case.journal, + max_result_bytes=limit, + )(adapter_class()) def test_generation_drift_after_claim_is_truthfully_not_dispatched() -> None: diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_claimed_resume_checkpoint.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_claimed_resume_checkpoint.py index 84971742d71..13f82ed1eb9 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_claimed_resume_checkpoint.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_claimed_resume_checkpoint.py @@ -5,11 +5,11 @@ from contextlib import contextmanager from copy import deepcopy from dataclasses import dataclass from datetime import UTC, datetime, timedelta -from typing import NoReturn, cast +from operator import methodcaller +from typing import NoReturn import pytest from sqlalchemy import inspect -from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm.state import InstanceState from core.dify_builder.models import Actor @@ -30,6 +30,7 @@ from services.dify_builder.native_testing.private_reads import PrivateReadOwner from services.dify_builder.native_testing.repository import OwnedNativeTest, OwnedNativeTestLoader from services.dify_builder.native_testing.state_store import NativeExecutionStateStore from tests.unit_tests.services.dify_builder.native_testing.test_host_capacity import SQL +from tests.unit_tests.services.dify_builder.native_testing.typed_boundaries import sql_session_factory NOW = datetime(2026, 10, 16, tzinfo=UTC) REF = NativeTestContextRef(request_id="request", invocation_id="root", context_digest="a" * 64) @@ -184,7 +185,7 @@ def metadata_case(monkeypatch: pytest.MonkeyPatch) -> ClaimedCheckpointCase: ), scope loader = OwnedNativeTestLoader( - cast(sessionmaker[Session], factory), + sql_session_factory(monkeypatch, factory, methods=SQL.sql_methods), actor=Actor(tenant_id="tenant", account_id="actor"), resolver=NativeHttpBindingResolver(), ) @@ -273,7 +274,7 @@ def test_local_generation_can_lag_shared_scope_and_skip_checkpoint_generation( def test_refuses_initial_or_untyped_generation(metadata_case: ClaimedCheckpointCase, generation: object) -> None: c = metadata_case with pytest.raises(BuilderExecutionPolicyError): - c.service.claimed_resume_checkpoint(ref=c.ref, generation=cast(int, generation), dispatch_id="dispatch") + methodcaller("claimed_resume_checkpoint", ref=c.ref, generation=generation, dispatch_id="dispatch")(c.service) @pytest.mark.parametrize("status", ["queued", "published", "unknown", "completed", "cancelled"]) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_code_executor.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_code_executor.py index ac342a526d5..41ea6258ad3 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_code_executor.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_code_executor.py @@ -4,7 +4,7 @@ from collections.abc import Callable from copy import deepcopy from dataclasses import dataclass from datetime import UTC, datetime -from typing import NoReturn, cast +from typing import NoReturn from uuid import uuid4 import pytest @@ -23,6 +23,7 @@ from services.dify_builder.native_testing.control import ( BoundaryOwnerState, NativeApprovalWait, NativePolicyStop, + _NativeControlBoundary, marked_native_node_class, ) from services.dify_builder.native_testing.host_execution import NativeHostLaunch @@ -37,7 +38,6 @@ from services.dify_builder.native_testing.sandbox.qualification import ( NativeCodeCapability, NativeCodeQualification, ) -from services.dify_builder.native_testing.state_store import NativeExecutionStateStore from tests.unit_tests.services.dify_builder.native_testing.test_call_adapter import ( REF, Authorization, @@ -70,6 +70,13 @@ class CodeExecutorCase: owner: NativeCodeHostOwner +def code_text(raw: dict[str, JsonValue]) -> str: + """Read the exact fixture source without coercing its JSON value.""" + source = raw["code"] + assert isinstance(source, str) + return source + + def refuse_constructor_collaborators(**_kwargs: object) -> NoReturn: raise AssertionError("node preparation collaborators are not used by executor tests") @@ -126,7 +133,9 @@ def case(monkeypatch: pytest.MonkeyPatch) -> CodeExecutorCase: node_class=marked_native_node_class(CodeNode, marked=True), runtime_state=runtime, ) - node = cast(Callable[..., CodeNode], association.node_class)( + node_class = association.node_class + assert issubclass(node_class, _NativeControlBoundary) + node = node_class( native_control_owner=bridge._boundary_owner, native_control_sink=bridge._control_sink, node_id="code", @@ -136,6 +145,7 @@ def case(monkeypatch: pytest.MonkeyPatch) -> CodeExecutorCase: code_executor=object(), code_limits=module.code_limits(), ) + assert isinstance(node, CodeNode) association.attach(node) api = _api() loader = api.loader @@ -192,7 +202,7 @@ def case(monkeypatch: pytest.MonkeyPatch) -> CodeExecutorCase: producer = module.NativeCodeBindingProducer( association=association, loader=loader, - state_store=cast(NativeExecutionStateStore, state), + state_store=state, journal=journal, qualification=q, ) @@ -216,15 +226,15 @@ def case(monkeypatch: pytest.MonkeyPatch) -> CodeExecutorCase: def test_unbound_public_uuid_refuses_before_protected_input(case: CodeExecutorCase) -> None: with pytest.raises(Exception, match="native_execution_id_missing"): - case.producer.prepare(language=CodeLanguage.PYTHON3, code=cast(str, case.raw["code"]), inputs={}) + case.producer.prepare(language=CodeLanguage.PYTHON3, code=code_text(case.raw), inputs={}) assert case.events == [] assert case.installed == [] def test_prepare_attests_actual_native_code_and_stable_reopened_intent(case: CodeExecutorCase) -> None: case.node.bind_execution_id(str(uuid4())) - a = case.producer.prepare(language=CodeLanguage.PYTHON3, code=cast(str, case.raw["code"]), inputs={}) - b = case.producer.prepare(language=CodeLanguage.PYTHON3, code=cast(str, case.raw["code"]), inputs={}) + a = case.producer.prepare(language=CodeLanguage.PYTHON3, code=code_text(case.raw), inputs={}) + b = case.producer.prepare(language=CodeLanguage.PYTHON3, code=code_text(case.raw), inputs={}) assert a.intent == b.intent assert a.spec == b.spec assert a.intent.key.occurrence_path == (case.node.execution_id,) @@ -241,7 +251,7 @@ def test_producer_refuses_changed_protected_material( case: CodeExecutorCase, change: str, monkeypatch: pytest.MonkeyPatch ) -> None: case.node.bind_execution_id(str(uuid4())) - code = cast(str, case.raw["code"]) + code = code_text(case.raw) inputs: dict[str, JsonValue] = {} if change == "code": code = "changed" @@ -284,10 +294,10 @@ def test_code_result_replay_has_zero_sdk_io_and_real_transformer( ) owner = case.owner execute = NativeCodeExecutor(producer=case.producer, calls=calls, owner=owner, on_prepared=lambda: None) - assert execute.execute(language=CodeLanguage.PYTHON3, code=cast(str, case.raw["code"]), inputs={}) == { + assert execute.execute(language=CodeLanguage.PYTHON3, code=code_text(case.raw), inputs={}) == { "result": "actual parsed" } - assert execute.execute(language=CodeLanguage.PYTHON3, code=cast(str, case.raw["code"]), inputs={}) == { + assert execute.execute(language=CodeLanguage.PYTHON3, code=code_text(case.raw), inputs={}) == { "result": "actual parsed" } assert len(physical) == 1 @@ -311,7 +321,7 @@ def test_approval_wait_has_no_scope_and_exports_real_proposal( ) execute = NativeCodeExecutor(producer=case.producer, calls=calls, owner=object(), on_prepared=lambda: None) with pytest.raises(NativeApprovalWait): - execute.execute(language=CodeLanguage.PYTHON3, code=cast(str, case.raw["code"]), inputs={}) + execute.execute(language=CodeLanguage.PYTHON3, code=code_text(case.raw), inputs={}) assert len(case.producer.approval_proposals()) == 1 assert not execute.is_execution_error(NativePolicyStop(ref=REF, reason_code="test_stop")) @@ -321,7 +331,7 @@ def test_non_json_input_error_never_contains_private_values(case: CodeExecutorCa with pytest.raises(BuilderExecutionPolicyError) as error: case.producer.prepare( language=CodeLanguage.PYTHON3, - code=cast(str, case.raw["code"]), + code=code_text(case.raw), inputs={"CANARY_PRIVATE": float("nan")}, ) assert "CANARY_PRIVATE" not in str(error.value) @@ -363,7 +373,7 @@ def variable_case(case: CodeExecutorCase, value: bool | int) -> dict[str, JsonVa def test_typed_selector_input_does_not_conflate_boolean_and_integer(case: CodeExecutorCase) -> None: raw = variable_case(case, True) with pytest.raises(BuilderExecutionPolicyError): - case.producer.prepare(language=CodeLanguage.PYTHON3, code=cast(str, raw["code"]), inputs={"value": 1}) + case.producer.prepare(language=CodeLanguage.PYTHON3, code=code_text(raw), inputs={"value": 1}) assert case.installed == [] @@ -378,7 +388,14 @@ def test_current_selector_change_after_claim_refuses_before_transport( def call(self, *, dispatch: Callable[[DispatchPermit], JsonValue], **kw: object) -> JsonValue: del kw case.association.runtime_state.variable_pool.add(["start", "value"], 2) - return dispatch(cast(DispatchPermit, object())) + return dispatch( + DispatchPermit( + permit_id="permit", + attempt_id="attempt", + operation_id="operation", + expires_at="2030-01-01T00:00:00Z", + ) + ) monkeypatch.setattr( module, @@ -392,7 +409,7 @@ def test_current_selector_change_after_claim_refuses_before_transport( on_prepared=lambda: None, ) with pytest.raises(BuilderExecutionPolicyError): - execute.execute(language=CodeLanguage.PYTHON3, code=cast(str, raw["code"]), inputs={"value": 1}) + execute.execute(language=CodeLanguage.PYTHON3, code=code_text(raw), inputs={"value": 1}) def test_wait_then_claim_preserves_logical_intent_and_replay_never_constructs_transport( @@ -402,7 +419,7 @@ def test_wait_then_claim_preserves_logical_intent_and_replay_never_constructs_tr from services.dify_builder.native_testing.sandbox import executor as module case.node.bind_execution_id(str(uuid4())) - initial = case.producer.prepare(language=CodeLanguage.PYTHON3, code=cast(str, case.raw["code"]), inputs={}) + initial = case.producer.prepare(language=CodeLanguage.PYTHON3, code=code_text(case.raw), inputs={}) physical: list[dict[str, object]] = [] class Transport: @@ -431,14 +448,10 @@ def test_wait_then_claim_preserves_logical_intent_and_replay_never_constructs_tr ) case.auth.decisions[0] = ApprovalWait(pending_operation_id="pending") with pytest.raises(NativeApprovalWait): - execute.execute(language=CodeLanguage.PYTHON3, code=cast(str, case.raw["code"]), inputs={}) + execute.execute(language=CodeLanguage.PYTHON3, code=code_text(case.raw), inputs={}) assert not physical assert not case.auth.claimed del case.auth.decisions[0] - assert execute.execute(language=CodeLanguage.PYTHON3, code=cast(str, case.raw["code"]), inputs={}) == { - "result": "ok" - } - assert execute.execute(language=CodeLanguage.PYTHON3, code=cast(str, case.raw["code"]), inputs={}) == { - "result": "ok" - } + assert execute.execute(language=CodeLanguage.PYTHON3, code=code_text(case.raw), inputs={}) == {"result": "ok"} + assert execute.execute(language=CodeLanguage.PYTHON3, code=code_text(case.raw), inputs={}) == {"result": "ok"} assert len(physical) == 1 diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_code_physical_repository.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_code_physical_repository.py index e14704399ca..d563ee74a76 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_code_physical_repository.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_code_physical_repository.py @@ -7,7 +7,7 @@ from contextlib import contextmanager from dataclasses import dataclass from datetime import UTC, datetime, timedelta from time import monotonic -from typing import NotRequired, Unpack, cast +from typing import NotRequired, Unpack from uuid import uuid4 import pytest @@ -42,6 +42,7 @@ from services.dify_builder.native_testing.sandbox.contracts import ( ) from services.dify_builder.native_testing.sandbox.repository import NativeCodePhysicalRepository from tests.unit_tests.installed_spies import installed_mock +from tests.unit_tests.services.dify_builder.native_testing.typed_boundaries import selected_rows class ReservationArguments(TypedDict, closed=True): @@ -237,12 +238,12 @@ def _timeout_loader( pass engine = create_engine("postgresql+psycopg2://unused@127.0.0.1:1/unused") - factory = sessionmaker(bind=engine, class_=TimeoutSession) + factory = sessionmaker[Session](bind=engine, class_=TimeoutSession) if record_begin: event.listen(TimeoutSession, "after_transaction_create", lambda *_: events.append("begin")) event.listen(TimeoutSession, "after_commit", lambda *_: events.append("commit")) mocker.patch.object(TimeoutSession, "execute", side_effect=execute) - loader = _loader(cast(sessionmaker[Session], factory)) + loader = _loader(factory) mocker.patch.object(loader, "load", return_value=None) mocker.patch.object(loader, "_load", return_value=(_owned(p), _scope(p, expires_at=expires_at))) return loader @@ -856,8 +857,9 @@ def test_invalid_or_expired_transaction_deadline_never_opens_writer( begin = mocker.patch.object(factory, "begin", side_effect=AssertionError("invalid budget opened SQL writer")) loader = _loader(factory) repo = NativeCodePhysicalRepository( - loader, acquisition_owner=Current(), transaction_deadline=lambda _: cast(float, deadline) + loader, acquisition_owner=Current(), transaction_deadline=fixed_transaction_deadline() ) + mocker.patch.object(repo, "_transaction_deadline", return_value=deadline) with ( pytest.raises(BuilderExecutionPolicyError), repo._transaction(ref=p.ref, acquisition=p.acquisition, settlement=True), @@ -891,10 +893,10 @@ def test_admission_persists_once_bounded_wire_spec_separate_from_logical_intent( wire = t.args["spec"].native.model_copy(update={"deadline": deadline}) assert physical.scope_spec_digest == canonical_digest(wire.model_dump(mode="json")) assert t.args["intent"].model_dump(mode="json") == original_intent + repeated = t.args.copy() + repeated["physical_deadline"] = deadline + timedelta(seconds=1) with pytest.raises(BuilderExecutionPolicyError): - t.repo.reserve_scope( - **cast(ReservationArguments, {**t.args, "physical_deadline": deadline + timedelta(seconds=1)}) - ) + t.repo.reserve_scope(**repeated) assert len(t.stored) == 1 assert t.stored[0].scope_expires_at == deadline @@ -929,7 +931,7 @@ def test_settlement_reloads_exact_persisted_deadline_after_expiry_without_recrea def row_read[T](query: Select[tuple[T]]) -> T | None: if query.column_descriptions[0]["entity"] is NativeCodePhysicalOperation: - return cast(T, t.stored[0]) + return selected_rows(query, t.stored)[0] return scalar(query) monkeypatch.setattr(t.session, "scalar", row_read) @@ -944,14 +946,8 @@ def test_settlement_reloads_exact_persisted_deadline_after_expiry_without_recrea assert t.root.claim_expires_at == original_expiry assert not loaded.scope_create_acked assert loaded.destroyed_at is None + repeated = t.args.copy() + repeated["physical_deadline"] = physical.scope_expires_at + timedelta(seconds=10) with pytest.raises(BuilderExecutionPolicyError): - t.repo.reserve_scope( - **cast( - ReservationArguments, - { - **t.args, - "physical_deadline": physical.scope_expires_at + timedelta(seconds=10), - }, - ) - ) + t.repo.reserve_scope(**repeated) assert len(t.stored) == 1 diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_code_qualification.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_code_qualification.py index 565d7ef712b..1234102dedf 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_code_qualification.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_code_qualification.py @@ -1,11 +1,14 @@ from collections.abc import Callable -from typing import cast import pytest from pydantic import JsonValue from runtime_contracts.builder.execution import BuilderExecutionPolicyError -from services.dify_builder.native_testing.sandbox.qualification import NativeCodeCapability, NativeCodeQualification +from services.dify_builder.native_testing.sandbox.qualification import ( + NativeCodeCapability, + NativeCodeQualification, + NativeCodeQualificationOwner, +) class CapabilityOwner: @@ -56,18 +59,10 @@ def qualification() -> tuple[NativeCodeQualification, list[NativeCodeCapability] return NativeCodeQualification(capability=capability(), owner=owner), seen -def test_capability_dto_and_artifact_hashes_do_not_grant_authority() -> None: - from services.dify_builder.native_testing.sandbox.qualification import ( - NativeCodeQualification, - NativeCodeQualificationOwner, - ) - +@pytest.mark.parametrize("owner", [None, InvalidQualificationOwner()], ids=["missing", "invalid-result"]) +def test_capability_dto_and_artifact_hashes_do_not_grant_authority(owner: NativeCodeQualificationOwner) -> None: with pytest.raises(BuilderExecutionPolicyError): - NativeCodeQualification(capability=capability(), owner=cast(NativeCodeQualificationOwner, None)) - with pytest.raises(BuilderExecutionPolicyError): - NativeCodeQualification( - capability=capability(), owner=cast(NativeCodeQualificationOwner, InvalidQualificationOwner()) - ) + NativeCodeQualification(capability=capability(), owner=owner) def test_qualification_rechecks_exact_complete_snapshot_for_each_use() -> None: @@ -105,9 +100,9 @@ def test_invalid_qualified_identity_refuses_before_owner(field: str, value: Json ) -def test_owner_revocation_refuses_existing_qualification() -> None: +def test_owner_revocation_refuses_existing_qualification(monkeypatch: pytest.MonkeyPatch) -> None: q, _ = qualification() - q.owner.validate = lambda **_kw: (_ for _ in ()).throw(ValueError("private reason")) + monkeypatch.setattr(q.owner, "validate", lambda **_kw: (_ for _ in ()).throw(ValueError("private reason"))) with pytest.raises(BuilderExecutionPolicyError) as error: q.check() assert "private reason" not in str(error.value) @@ -148,9 +143,11 @@ def test_code_descriptor_binds_config_environment_and_limits_without_payload() - ) assert d["native_type"] == "code" assert d["native_version"] == "1" - assert d["policy"]["isolated"] is True - assert d["policy"]["egress"] is False - assert d["policy"]["approval_scope"] == "operation" + policy = d["policy"] + assert isinstance(policy, dict) + assert policy["isolated"] is True + assert policy["egress"] is False + assert policy["approval_scope"] == "operation" assert "CANARY" not in str(d) assert "private title" not in str(d) changed = describe_code_resource( diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_commands.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_commands.py index ca67300dc76..c9665f2eccf 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_commands.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_commands.py @@ -2,7 +2,6 @@ from collections.abc import Generator from datetime import UTC, datetime, timedelta -from typing import cast from uuid import uuid4 import httpx @@ -39,7 +38,7 @@ from services.dify_builder.native_testing.confirmed_agent_continuation import ( ) from services.dify_builder.native_testing.public_contracts import NativeTestPublicReceipt from services.dify_builder.native_testing.repository import OwnedNativeTest -from tests.unit_tests.core.app.entities.test_app_invoke_entities import _create_workflow_generate_entity +from tests.unit_tests.core.app.entities.test_app_invoke_entities import _create_workflow_generate_entity, _trace_scope from tests.unit_tests.installed_spies import installed_mock from tests.unit_tests.services.dify_builder.native_testing.test_runtime_contracts import CONTEXT @@ -61,7 +60,7 @@ def envelope() -> dict[str, object]: def entity(*, marked: bool = True) -> WorkflowAppGenerateEntity: - result = _create_workflow_generate_entity() + result = _create_workflow_generate_entity(trace_scope=_trace_scope()) result.builder_agent_execution = BuilderAgentExecutionContext(ref=REF, native_run_id=RUN) if marked else None return result @@ -214,18 +213,20 @@ def test_actual_resume_task_refuses_malformed_marked_envelope_before_unscoped_st @pytest.mark.parametrize("change", ["missing", "run", "ref", "boolean_generation", "extra"]) def test_marked_resume_requires_exact_dispatch_envelope_before_any_claim(change: str) -> None: payload = envelope() + dispatch = payload["builder_agent_dispatch"] + assert isinstance(dispatch, dict) if change == "missing": payload.pop("builder_agent_dispatch") elif change == "run": payload["workflow_run_id"] = str(uuid4()) elif change == "ref": - cast(dict[str, object], cast(dict[str, object], payload["builder_agent_dispatch"])["ref"])["request_id"] = ( - "foreign" - ) + ref = dispatch["ref"] + assert isinstance(ref, dict) + ref["request_id"] = "foreign" elif change == "boolean_generation": - cast(dict[str, object], payload["builder_agent_dispatch"])["generation"] = True + dispatch["generation"] = True else: - cast(dict[str, object], payload["builder_agent_dispatch"])["approval"] = True + dispatch["approval"] = True with pytest.raises(BuilderExecutionPolicyError): commands_module.guard_confirmed_agent_resume(payload=payload, generate_entity=entity()) @@ -257,10 +258,10 @@ def test_resume_publishes_only_after_committed_lifecycle_resume(mocker: pytest_m api = _api() mocker.patch.object(api.lifecycle, "resume", side_effect=lambda **_: events.append("committed") or receipt) commands = commands_module.ConfirmedAgentCommands(api=api, continuation=None, backend=None, publish=None) - commands._request = lambda **_: None - commands._root_ref = lambda **_: REF - commands._publish_one = lambda **_: events.append("published") or True - commands.get = lambda **_: "receipt" + mocker.patch.object(commands, "_request", return_value=None) + mocker.patch.object(commands, "_root_ref", return_value=REF) + mocker.patch.object(commands, "_publish_one", side_effect=lambda **_: events.append("published") or True) + mocker.patch.object(commands, "get", return_value="receipt") assert ( commands.resume( actor=ACTOR, session_id="session", request_id="request", expected_generation=0, idempotency_key="once" @@ -518,7 +519,9 @@ def test_completed_child_recovery_requeues_original_claim_only_after_terminal_pr protected = _protected() mocker.patch.object(commands, "load_wait", return_value=protected) mocker.patch.object(commands, "_terminal_recovery_proof", return_value=_pause()) - commands._publish_one = lambda **values: events.append(("publish", values)) or True + mocker.patch.object( + commands, "_publish_one", side_effect=lambda **values: events.append(("publish", values)) or True + ) assert commands._recover_terminal_continuation(ref=REF, parent_node=protected.association.parent_node) assert events == [ "lifecycle-fence-recover", @@ -705,16 +708,16 @@ def test_lost_create_ack_status_recovery_uses_saved_sql_identity_and_no_second_p ) mocker.patch.object(api.store, "load_agent_association", return_value=protected) monkeypatch.setattr(agent_confirmed_workspace, "load_confirmed_agent_parent", lambda **_: parent) - events: list[object] = [] + events: list[tuple[str, object]] = [] continuation = _continuation(api, backend) mocker.patch.object(continuation, "read_parent_pause", return_value=_pause(runtime)) mocker.patch.object( continuation, "persist_parent_wait", side_effect=lambda **values: events.append(("parent-commit", values)) ) commands = commands_module.ConfirmedAgentCommands(api=api, continuation=continuation, backend=backend, publish=None) - commands.prepare_wait = lambda **values: events.append(("selected-checkpoint", values)) + monkeypatch.setattr(commands, "prepare_wait", lambda **values: events.append(("selected-checkpoint", values))) assert commands._recover_pending_ack(ref=REF) - assert [cast(tuple[str, object], event)[0] for event in events] == ["selected-checkpoint", "parent-commit"] + assert [event[0] for event in events] == ["selected-checkpoint", "parent-commit"] assert calls == ["/runs", "/runs/lookup", "/runs/lookup", "/runs/original-child/events"] assert lookups[0] == lookups[1] assert recovery.process_data(scope=scope)["agent_create_attempts"][0]["child_run_id"] == "original-child" @@ -813,8 +816,8 @@ def test_public_pause_read_refreshes_callback_and_preserves_only_live_owned_resu continuation = _continuation(api) mocker.patch.object(continuation, "read_parent_pause", side_effect=lambda **kwargs: pause_checks.append(kwargs)) commands = commands_module.ConfirmedAgentCommands(api=api, continuation=continuation, backend=None, publish=None) - commands._request = lambda **_kwargs: None - commands._root_ref = lambda **_kwargs: REF + mocker.patch.object(commands, "_request", return_value=None) + mocker.patch.object(commands, "_root_ref", return_value=REF) def recover(**_kwargs: object) -> bool: if after in {"waiting", "quarantined"}: @@ -826,7 +829,7 @@ def test_public_pause_read_refreshes_callback_and_preserves_only_live_owned_resu raise BuilderExecutionPolicyError("native_agent_owner_mismatch") return False - commands._recover_pending_ack = recover + mocker.patch.object(commands, "_recover_pending_ack", side_effect=recover) result = commands.get(actor=ACTOR, session_id="session", request_id=REF.request_id) if after == "waiting": assert result == fresh @@ -1050,7 +1053,7 @@ def test_native_reconcile_and_outbox_sql_stay_with_selected_profile_request( assert ( api.lifecycle.publish_pending(publish=lambda **message: published.append(message), request_id=selected_id) == 1 ) - assert [cast(dict[str, object], message["ref"])["request_id"] for message in published] == [selected_id] + assert [NativeTestContextRef.model_validate(message["ref"]).request_id for message in published] == [selected_id] with sqlite_session_factory() as sql: for index, identity in enumerate(dispatch_ids): row = sql.get(NativeDispatch, identity) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_completion.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_completion.py index 077de3405f2..3e71b17922b 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_completion.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_confirmed_agent_completion.py @@ -65,7 +65,7 @@ from services.dify_builder.native_testing.repository import OwnedNativeTest, nat from services.dify_builder.repository import SqlDifyBuilderRepository from services.dify_builder.revision import executable_graph_revision, execution_revision from tests.builder_agent_fixtures import AgentCase, make_case -from tests.unit_tests.core.app.entities.test_app_invoke_entities import _create_workflow_generate_entity +from tests.unit_tests.core.app.entities.test_app_invoke_entities import _create_workflow_generate_entity, _trace_scope @dataclass @@ -407,8 +407,8 @@ def completed( backend=None, publish=None, ) - commands._request = lambda **_: None - commands._root_ref = lambda **_: ref + monkeypatch.setattr(commands, "_request", lambda **_: None) + monkeypatch.setattr(commands, "_root_ref", lambda **_: ref) return CompletedOwner( factory=factory, repo=repo, @@ -839,7 +839,7 @@ def declining( except AttributeError: generation = 1 saved, execution_id = saved_state(ref=owner.ref, run_id=owner.native_id) - entity = _create_workflow_generate_entity().model_copy( + entity = _create_workflow_generate_entity(trace_scope=_trace_scope()).model_copy( update={ "builder_agent_execution": BuilderAgentExecutionContext(ref=owner.ref, native_run_id=owner.native_id), } @@ -2796,7 +2796,11 @@ def test_expired_terminal_context_requires_actual_owned_terminal_workflow( ], ) def test_waiting_binding_retention_is_health_only_and_fails_closed( - declining: CompletedOwner, mocker: pytest_mock.MockerFixture, fault: str | None, caplog: pytest.LogCaptureFixture + declining: CompletedOwner, + mocker: pytest_mock.MockerFixture, + fault: str | None, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Real SQL exclusion fences; protected storage/SDK are explicit bounded ports.""" from celery.exceptions import SoftTimeLimitExceeded @@ -2887,7 +2891,7 @@ def test_waiting_binding_retention_is_health_only_and_fails_closed( invocation.active = False elif fault == "terminal": request.state = "sealed" - commands._request = ConfirmedAgentCommands._request.__get__(commands) + monkeypatch.setattr(commands, "_request", ConfirmedAgentCommands._request.__get__(commands)) prepared = _AgentPreparation( parent=wait.parent_node, context=_context(owner), diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_context_binding.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_context_binding.py index df19cef2362..f015755c926 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_context_binding.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_context_binding.py @@ -1,8 +1,7 @@ """Trusted reload must finish before any marked run reaches native setup.""" -from collections.abc import Mapping from datetime import datetime -from typing import cast +from operator import methodcaller import pytest import sqlalchemy.orm @@ -13,7 +12,7 @@ from core.dify_builder.models import Actor from runtime_contracts.builder.execution import BuilderExecutionPolicyError from runtime_contracts.builder.native.contracts import JsonValue, NativeTestContextRef from runtime_contracts.builder.native.runtime_contracts import ResolvedNativeTestContext -from services.dify_builder.native_testing.agent_binding import AgentBindingAuthorization, AgentBindingSelection +from services.dify_builder.native_testing.agent_binding import AgentBindingAuthorization from services.dify_builder.native_testing.context_binding import ( NativeAdmissionCompilation, NativeAdmissionCompiler, @@ -267,14 +266,15 @@ def test_native_graph_refuses_unselected_agent_before_sync( } ) with pytest.raises(BuilderExecutionPolicyError, match="agent_binding_not_prepared"): - _prepare_native_agent_graph( + methodcaller( + "__call__", session=sqlite_session, actor=case.actor, workflow=case.workflow, graph=graph, - selections=cast(Mapping[str, AgentBindingSelection], None), + selections=None, authorization=None, - ) + )(_prepare_native_agent_graph) def test_agent_selection_uses_exact_confirmed_opaque_identity() -> None: diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_control.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_control.py index 454d6e4b123..7eebd84f386 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_control.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_control.py @@ -7,12 +7,13 @@ from collections.abc import Callable, Mapping from concurrent.futures import ThreadPoolExecutor from contextlib import AbstractContextManager from dataclasses import dataclass, field +from operator import methodcaller from threading import Barrier, Event, Thread, current_thread from types import TracebackType -from typing import Literal, TypedDict, cast, override +from typing import Literal, TypedDict, override import pytest -from pydantic import ValidationError +from pydantic import TypeAdapter, ValidationError from graphon.engine import Engine from graphon.engine.command import InMemoryChannel @@ -53,7 +54,10 @@ from services.dify_builder.native_testing.control import ( NativeControlAccepted, NativeControlRecord, NativeControlRefused, + NativeControlSink, + NativeNodeOwnership, NativePolicyStop, + _NativeControlBoundary, ) REF = NativeTestContextRef(request_id="request", invocation_id="invocation", context_digest="a" * 64) @@ -111,7 +115,7 @@ def test_stop_preserves_typed_reason_without_disclosing_it_in_diagnostics() -> N def test_malformed_decision_is_private_stop_before_any_dispatch(decision: object) -> None: module = native_control with pytest.raises(module.NativePolicyStop) as caught: - module.NativeControlBridge.resolve(ref=REF, decision=cast(EffectDecision, decision)) + methodcaller("resolve", ref=REF, decision=decision)(module.NativeControlBridge) assert caught.value.reason_code == "native_control_invalid_decision" assert "secret" not in str(caught.value) @@ -170,8 +174,6 @@ class Sink: raise RuntimeError("secret callback configuration") if self.mode == "base-exception": raise SystemExit("secret callback configuration") - if self.mode == "malformed": - return cast(NativeControlAccepted | NativeControlRefused, True) if self.before_accept is not None: self.before_accept(record) return module.NativeControlAccepted(record=record) @@ -234,33 +236,26 @@ class RawGraphConfig(TypedDict): @dataclass(frozen=True) -class Factory: +class Factory[S: NativeControlSink = NativeControlSink]: config: RawGraphConfig runtime: RuntimeState owner: BoundaryOwnerState - sink: Sink + sink: S effects: Effects marked: bool = True children: list[RuntimeState] = field(default_factory=list) run_context: Mapping[str, object] = field(default_factory=dict) - def with_runtime_state(self, runtime_state: RuntimeState) -> Factory: + def with_runtime_state(self, runtime_state: RuntimeState) -> Factory[S]: self.children.append(runtime_state) - return Factory( - self.config, - runtime_state, - self.owner, - self.sink, - self.effects, - self.marked, - self.children, - self.run_context, - ) - - def with_graph_config(self, graph_config: Mapping[str, object]) -> Factory: from dataclasses import replace - return replace(self, config=cast(RawGraphConfig, graph_config)) + return replace(self, runtime=runtime_state) + + def with_graph_config(self, graph_config: Mapping[str, object]) -> Factory[S]: + from dataclasses import replace + + return replace(self, config=TypeAdapter(RawGraphConfig).validate_python(graph_config)) def validate_node(self, node_config: NodeConfigDict) -> NodeExecutionType: raw = node_config["data"].model_dump(mode="python", exclude_unset=True) @@ -272,17 +267,14 @@ class Factory: raw_data = node_config["data"].model_dump(mode="python", exclude_unset=True) original = next(cls for cls in ORIGINALS if cls.node_type == raw_data["type"]) cls = native_control.marked_native_node_class(original, marked=self.marked) - kwargs: dict[str, object] = { - "node_id": node_config["id"], - "data": cls.validate_node_data(raw_data), - "init_params": InitParams( - workflow_id=self.runtime.graph_execution.workflow_id, - graph_config=self.config, - run_context=self.run_context, - call_depth=0, - ), - "runtime_state": self.runtime, - } + data = original.validate_node_data(raw_data) + init_params = InitParams( + workflow_id=self.runtime.graph_execution.workflow_id, + graph_config=self.config, + run_context=self.run_context, + call_depth=0, + ) + kwargs: dict[str, object] = {} if original is TemplateTransformNode: kwargs["jinja2_template_renderer"] = Renderer(self.effects, node_config["id"]) if original is CodeNode: @@ -298,8 +290,19 @@ class Factory: max_object_array_length=100, ) if self.marked: - kwargs.update(native_control_owner=self.owner, native_control_sink=self.sink) - return cast(Callable[..., Node[BaseNodeData]], cls)(**kwargs) + assert issubclass(cls, _NativeControlBoundary) + result = cls( + node_id=node_config["id"], + data=data, + init_params=init_params, + runtime_state=self.runtime, + native_control_owner=self.owner, + native_control_sink=self.sink, + **kwargs, + ) + assert isinstance(result, Node) + return result + return cls(node_id=node_config["id"], data=data, init_params=init_params, runtime_state=self.runtime, **kwargs) def node(node_id: str, kind: str, **data: object) -> RawNodeConfig: @@ -338,8 +341,8 @@ def make_native( ref: NativeTestContextRef = REF, config: RawGraphConfig | None = None, runtime: RuntimeState | None = None, - ownership: Observations | None = None, -) -> tuple[Engine, Factory]: + ownership: NativeNodeOwnership | None = None, +) -> tuple[Engine, Factory[Sink]]: module = native_control owner = ( module.BoundaryOwnerState(ref=ref) @@ -426,8 +429,18 @@ def test_stop_records_owner_then_aborts_without_native_failure_policy(policy: di @pytest.mark.parametrize("mode", ["refuse", "exception", "base-exception", "malformed"]) @pytest.mark.parametrize("policy", POLICIES) -def test_sink_refusal_or_failure_is_nonresumable_abort_without_fallback(mode: str, policy: dict[str, object]) -> None: +def test_sink_refusal_or_failure_is_nonresumable_abort_without_fallback( + mode: str, policy: dict[str, object], monkeypatch: pytest.MonkeyPatch +) -> None: native, factory = make_native(mode=mode, policy=policy) + if mode == "malformed": + record_control = factory.sink.record_control + + def malformed(*, record: NativeControlRecord) -> bool: + record_control(record=record) + return True + + monkeypatch.setattr(factory.sink, "record_control", malformed) events = list(native.run()) assert isinstance(events[-1], GraphRunAbortedEvent) assert factory.owner.status == "UNKNOWN_NONRESUMABLE" @@ -522,11 +535,11 @@ def test_marked_class_cache_preserves_all_current_mappings_without_instance_cont def test_opt_in_rejects_non_boolean_marker_and_missing_trusted_sink() -> None: module = native_control with pytest.raises(ValueError, match="native_control_concrete_class_required"): - module.marked_native_node_class(TemplateTransformNode, marked=cast(bool, "client supplied truthy text")) + methodcaller("marked_native_node_class", TemplateTransformNode, marked="client supplied truthy text")(module) _, factory = make_native() cls = module.marked_native_node_class(TemplateTransformNode, marked=True) with pytest.raises(TypeError, match="native_control_sink"): - cast(Callable[..., Node[BaseNodeData]], cls)(native_control_owner=factory.owner) + methodcaller("__call__", native_control_owner=factory.owner)(cls) def test_independent_simultaneous_contexts_share_classes_but_never_host_ownership() -> None: diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_dispatch_claims.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_dispatch_claims.py index 5d768712657..5d5eeb9a94e 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_dispatch_claims.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_dispatch_claims.py @@ -4,7 +4,8 @@ Durability, ordering and concurrency belong to the CI-only PostgreSQL module. """ from datetime import UTC, datetime, timedelta -from typing import NoReturn, cast +from operator import methodcaller +from typing import NoReturn import pytest from sqlalchemy.orm import sessionmaker @@ -51,11 +52,7 @@ def test_missing_dispatch_evidence_stops_before_authority_lookup(field: str, mon claim = guard.claim_dispatch assert callable(claim), "durable physical dispatch claim protocol is unavailable" with pytest.raises(BuilderExecutionPolicyError, match="invalid_dispatch_permit"): - claim( - ref=cast(NativeTestContextRef, args["ref"]), - intent=cast(EffectIntent, args["intent"]), - permit=cast(DispatchPermit, args["permit"]), - ) + methodcaller("claim_dispatch", **args)(guard) @pytest.mark.parametrize( @@ -88,8 +85,4 @@ def test_in_process_dto_bypass_is_revalidated_before_authority_lookup( claim = guard.claim_dispatch assert callable(claim), "durable physical dispatch claim protocol is unavailable" with pytest.raises(BuilderExecutionPolicyError, match="invalid_dispatch_permit"): - claim( - ref=cast(NativeTestContextRef, args["ref"]), - intent=cast(EffectIntent, args["intent"]), - permit=cast(DispatchPermit, args["permit"]), - ) + methodcaller("claim_dispatch", **args)(guard) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_dispatch_fence.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_dispatch_fence.py index 8752ec50235..2afa984212f 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_dispatch_fence.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_dispatch_fence.py @@ -4,11 +4,11 @@ from collections import UserList from collections.abc import Generator, Sequence from dataclasses import dataclass from datetime import UTC, datetime, timedelta -from typing import Literal, NoReturn, Unpack, cast, override +from typing import Literal, NoReturn, Unpack, override import pytest from sqlalchemy import Select -from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.orm import sessionmaker from typing_extensions import TypedDict from core.dify_builder.models import Actor @@ -32,6 +32,14 @@ from services.dify_builder.native_testing.private_reads import PrivateReadOwner from services.dify_builder.native_testing.repository import OwnedNativeTest, OwnedNativeTestLoader from services.dify_builder.native_testing.state_store import NativeExecutionStateStore from tests.unit_tests.services.dify_builder.native_testing.test_observation_privacy import context as sample_context +from tests.unit_tests.services.dify_builder.native_testing.typed_boundaries import ( + SqlMethods, + checked_row, + checked_rows, + selected_rows, + sql_session, + sql_session_factory, +) NOW = datetime(2026, 10, 15, tzinfo=UTC) REF = NativeTestContextRef(request_id="request", invocation_id="root", context_digest="a" * 64) @@ -134,8 +142,8 @@ class SQLDouble: for row in rows: assert isinstance(row, (NativeInvocationState, NativeTestInvocation, NativePendingAction)) identities.append(row.invocation_id) - return Rows(cast(Sequence[T], identities)) - return Rows(cast(Sequence[T], rows)) + return Rows(selected_rows(statement, identities)) + return Rows(selected_rows(statement, rows)) def scalar[T](self, statement: Select[tuple[T]]) -> T | None: rows = self.scalars(statement) @@ -143,7 +151,7 @@ class SQLDouble: def get[T](self, model: type[T], _identity: object) -> T | None: rows = self.rows[model] - return cast(T, rows[0]) if rows else None + return checked_row(model, rows[0]) if rows else None def add(self, row: object, /) -> None: del row @@ -152,6 +160,16 @@ class SQLDouble: def flush(self) -> None: raise AssertionError("unexpected SQL flush") + def sql_methods(self) -> SqlMethods: + """Expose this fixture's explicit SQL IO methods, including overrides.""" + return { + "get": self.get, + "scalar": self.scalar, + "scalars": self.scalars, + "add": self.add, + "flush": self.flush, + } + class InvocationChanges(TypedDict, total=False, closed=True): tenant_id: str @@ -222,37 +240,45 @@ def pending(identity: str = "pending", **changes: Unpack[PendingChanges]) -> Nat def readiness( - session: SQLDouble, *, ref: NativeTestContextRef = REF, coalesce: bool = False + monkeypatch: pytest.MonkeyPatch, session: SQLDouble, *, ref: NativeTestContextRef = REF, coalesce: bool = False ) -> NativePendingAction | None: from services.dify_builder.native_testing import transitions assert hasattr(transitions, "dispatch_readiness"), "durable dispatch-readiness SQL guard is missing" return transitions.dispatch_readiness( - cast(Session, session), ref=ref, tenant_id="tenant", now=NOW, coalesce_self=coalesce + sql_session(monkeypatch, session.sql_methods()), ref=ref, tenant_id="tenant", now=NOW, coalesce_self=coalesce ) -def test_active_unfenced_lineage_allows_dispatch_without_requiring_global_generation_equality() -> None: +def test_active_unfenced_lineage_allows_dispatch_without_requiring_global_generation_equality( + monkeypatch: pytest.MonkeyPatch, +) -> None: root, child = state(), state("child", "root", generation=1) - assert readiness(SQLDouble([root, child]), ref=REF.model_copy(update={"invocation_id": "child"})) is None + assert ( + readiness(monkeypatch, SQLDouble([root, child]), ref=REF.model_copy(update={"invocation_id": "child"})) is None + ) @pytest.mark.parametrize("status", ["waiting", "queued", "unknown", "cancelled", "completed", "stopped"]) @pytest.mark.parametrize("selected", ["self", "ancestor"]) -def test_closed_self_or_ancestor_refuses_fresh_dispatch(status: str, selected: str) -> None: +def test_closed_self_or_ancestor_refuses_fresh_dispatch( + monkeypatch: pytest.MonkeyPatch, status: str, selected: str +) -> None: root, child = state(), state("child", "root") (child if selected == "self" else root).state = status with pytest.raises(BuilderExecutionPolicyError): - readiness(SQLDouble([root, child]), ref=REF.model_copy(update={"invocation_id": "child"})) + readiness(monkeypatch, SQLDouble([root, child]), ref=REF.model_copy(update={"invocation_id": "child"})) @pytest.mark.parametrize("category", ["approval", "configuration", "interaction"]) -def test_same_invocation_fence_preserves_real_pending_identity_and_category(category: str) -> None: +def test_same_invocation_fence_preserves_real_pending_identity_and_category( + monkeypatch: pytest.MonkeyPatch, category: str +) -> None: row = pending(category=category) session = SQLDouble([state(pending_ids=[row.id], wait_reason=category)], [row]) - assert readiness(session, coalesce=True) is row + assert readiness(monkeypatch, session, coalesce=True) is row with pytest.raises(BuilderExecutionPolicyError): - readiness(session) + readiness(monkeypatch, session) @pytest.mark.parametrize( @@ -266,20 +292,25 @@ def test_same_invocation_fence_preserves_real_pending_identity_and_category(cate {"id": "unrepresented"}, ], ) -def test_fence_never_coalesces_foreign_stale_or_invalid_pending_row(change: PendingChanges) -> None: +def test_fence_never_coalesces_foreign_stale_or_invalid_pending_row( + monkeypatch: pytest.MonkeyPatch, change: PendingChanges +) -> None: with pytest.raises(BuilderExecutionPolicyError): - readiness(SQLDouble([state(pending_ids=["pending"])], [pending(**change)]), coalesce=True) + readiness(monkeypatch, SQLDouble([state(pending_ids=["pending"])], [pending(**change)]), coalesce=True) @pytest.mark.parametrize("ids", [["pending", "pending"], ["missing"], [True]]) -def test_malformed_or_missing_fence_membership_fails_closed(ids: list[JsonValue]) -> None: +def test_malformed_or_missing_fence_membership_fails_closed( + monkeypatch: pytest.MonkeyPatch, ids: list[JsonValue] +) -> None: with pytest.raises(BuilderExecutionPolicyError): - readiness(SQLDouble([state(pending_ids=ids)], [pending()]), coalesce=True) + readiness(monkeypatch, SQLDouble([state(pending_ids=ids)], [pending()]), coalesce=True) -def test_child_cannot_coalesce_an_ancestor_pending_id_as_its_own_wait() -> None: +def test_child_cannot_coalesce_an_ancestor_pending_id_as_its_own_wait(monkeypatch: pytest.MonkeyPatch) -> None: with pytest.raises(BuilderExecutionPolicyError): readiness( + monkeypatch, SQLDouble([state(pending_ids=["pending"]), state("child", "root")], [pending()]), ref=REF.model_copy(update={"invocation_id": "child"}), coalesce=True, @@ -287,7 +318,7 @@ def test_child_cannot_coalesce_an_ancestor_pending_id_as_its_own_wait() -> None: @pytest.mark.parametrize("fault", ["missing", "cycle", "foreign", "inactive", "root_owner", "expired", "cancelled"]) -def test_incomplete_or_revoked_lineage_never_authorizes_dispatch(fault: str) -> None: +def test_incomplete_or_revoked_lineage_never_authorizes_dispatch(monkeypatch: pytest.MonkeyPatch, fault: str) -> None: root, child = state(), state("child", "root") session = SQLDouble([root, child]) if fault == "missing": @@ -297,15 +328,15 @@ def test_incomplete_or_revoked_lineage_never_authorizes_dispatch(fault: str) -> elif fault == "foreign": root.tenant_id = "foreign" elif fault == "inactive": - cast(Sequence[NativeTestInvocation], session.rows[NativeTestInvocation])[0].active = False + checked_rows(NativeTestInvocation, session.rows[NativeTestInvocation])[0].active = False elif fault == "root_owner": root.app_id = "other-app" elif fault == "expired": - cast(Sequence[NativeTestScope], session.rows[NativeTestScope])[0].expires_at = NOW + checked_rows(NativeTestScope, session.rows[NativeTestScope])[0].expires_at = NOW else: - cast(Sequence[NativeTestScope], session.rows[NativeTestScope])[0].cancelled = True + checked_rows(NativeTestScope, session.rows[NativeTestScope])[0].cancelled = True with pytest.raises(BuilderExecutionPolicyError): - readiness(session, ref=REF.model_copy(update={"invocation_id": "child"})) + readiness(monkeypatch, session, ref=REF.model_copy(update={"invocation_id": "child"})) def test_existing_c02_coalesces_fenced_sibling_before_any_operation_write(monkeypatch: pytest.MonkeyPatch) -> None: @@ -318,8 +349,8 @@ def test_existing_c02_coalesces_fenced_sibling_before_any_operation_write(monkey session = SQLDouble([state(pending_ids=[row.id], wait_reason="configuration")], [row]) session.rows.update({NativeEffectOperation: [], NativeEffectGrant: []}) writes: list[object] = [] - session.add = writes.append - session.flush = lambda: None + monkeypatch.setattr(session, "add", writes.append) + monkeypatch.setattr(session, "flush", lambda: None) effect = intent().model_copy(update={"key": intent().key.model_copy(update={"invocation_id": "root"})}) owned = fence_owner(ref=REF, tenant_id="tenant", actor_id="actor", app_id="app", generation=3, task_approved=False) service = EffectAuthorizationService( @@ -331,7 +362,10 @@ def test_existing_c02_coalesces_fenced_sibling_before_any_operation_write(monkey # This test isolates the ordered admission guard after current binding validation. monkeypatch.setattr(service, "_binding", lambda *_args: binding(effect)) result = service._claim( - cast(Session, session), owned, cast(Sequence[NativeTestScope], session.rows[NativeTestScope])[0], effect + sql_session(monkeypatch, session.sql_methods()), + owned, + checked_rows(NativeTestScope, session.rows[NativeTestScope])[0], + effect, ) assert result == ApprovalWait(pending_operation_id=row.id, wait_reason="configuration") assert writes == [] @@ -360,7 +394,7 @@ def test_pause_cannot_discard_a_late_running_fence(monkeypatch: pytest.MonkeyPat @contextmanager def locked(**_kwargs: object) -> Generator[tuple[SQLDouble, OwnedNativeTest, NativeTestScope]]: - yield session, owned, cast(Sequence[NativeTestScope], session.rows[NativeTestScope])[0] + yield session, owned, checked_rows(NativeTestScope, session.rows[NativeTestScope])[0] from runtime_contracts.builder.native.runtime_contracts import NativeCheckpointPayload @@ -393,17 +427,19 @@ def test_pause_cannot_discard_a_late_running_fence(monkeypatch: pytest.MonkeyPat assert selected.pending_ids == ["late"] -def test_approval_pending_uses_actual_local_generation_after_shared_scope_advance() -> None: +def test_approval_pending_uses_actual_local_generation_after_shared_scope_advance( + monkeypatch: pytest.MonkeyPatch, +) -> None: from services.dify_builder.native_testing.authorization import EffectAuthorizationService session = SQLDouble([state()]) session.rows[NativePendingAction] = [] writes: list[object] = [] - session.add = writes.append - session.flush = lambda: setattr(writes[-1], "id", "pending") + monkeypatch.setattr(session, "add", writes.append) + monkeypatch.setattr(session, "flush", lambda: setattr(writes[-1], "id", "pending")) owned = fence_owner(ref=REF, tenant_id="tenant", generation=9, app_id="app") EffectAuthorizationService._pending( - cast(Session, session), + sql_session(monkeypatch, session.sql_methods()), owned, NativeEffectOperation( id="operation", @@ -435,7 +471,7 @@ def test_native_pending_owner_uses_local_generation_after_shared_scope_advance( @contextmanager def locked(**_kwargs: object) -> Generator[tuple[SQLDouble, OwnedNativeTest, NativeTestScope]]: - yield session, owned, cast(Sequence[NativeTestScope], session.rows[NativeTestScope])[0] + yield session, owned, checked_rows(NativeTestScope, session.rows[NativeTestScope])[0] resolver = NativeHttpBindingResolver() monkeypatch.setattr( @@ -472,7 +508,7 @@ def test_fence_pending_acknowledges_after_commit_without_changing_running_state( @contextmanager def locked(**_kwargs: object) -> Generator[tuple[SQLDouble, OwnedNativeTest, NativeTestScope]]: - yield session, owned, cast(Sequence[NativeTestScope], session.rows[NativeTestScope])[0] + yield session, owned, checked_rows(NativeTestScope, session.rows[NativeTestScope])[0] committed.append(True) authority = PrivateReadOwner( @@ -538,7 +574,7 @@ def test_child_creation_checks_fence_before_protected_input_or_resolver_work(mon touched = [] parent = fence_owner(tenant_id="tenant", ref=REF) loader = OwnedNativeTestLoader( - cast(sessionmaker[Session], factory), + sql_session_factory(monkeypatch, factory, methods=SQLDouble.sql_methods), actor=Actor(tenant_id=parent.tenant_id, account_id=parent.actor_id), resolver=NativeHttpBindingResolver(), ) @@ -567,7 +603,9 @@ def test_child_creation_checks_fence_before_protected_input_or_resolver_work(mon assert touched == [] -def test_retained_acknowledged_historical_ids_do_not_coalesce_or_close_admission() -> None: +def test_retained_acknowledged_historical_ids_do_not_coalesce_or_close_admission( + monkeypatch: pytest.MonkeyPatch, +) -> None: selected = state(pending_ids=["pending"], wait_reason=None, checkpoint_id="checkpoint") session = SQLDouble([selected], [pending(generation=2)]) @@ -593,8 +631,8 @@ def test_retained_acknowledged_historical_ids_do_not_coalesce_or_close_admission checkpoint_id="checkpoint", ) ] - assert readiness(session, coalesce=True) is None - assert readiness(session) is None + assert readiness(monkeypatch, session, coalesce=True) is None + assert readiness(monkeypatch, session) is None @pytest.mark.parametrize("saved", [False, True]) @@ -647,17 +685,17 @@ def test_generation_claim_requires_actual_saved_configuration_resolution( @contextmanager def locked(**_kwargs: object) -> Generator[tuple[SQLDouble, OwnedNativeTest, NativeTestScope]]: - yield session, owned, cast(Sequence[NativeTestScope], session.rows[NativeTestScope])[0] + yield session, owned, checked_rows(NativeTestScope, session.rows[NativeTestScope])[0] loader = OwnedNativeTestLoader( - cast(sessionmaker[Session], factory), + sql_session_factory(monkeypatch, factory, methods=SQLDouble.sql_methods), actor=Actor(tenant_id=owned.tenant_id, account_id=owned.actor_id), resolver=NativeHttpBindingResolver(), ) monkeypatch.setattr( loader, "_load", - lambda *_args, **_kw: (owned, cast(Sequence[NativeTestScope], session.rows[NativeTestScope])[0]), + lambda *_args, **_kw: (owned, checked_rows(NativeTestScope, session.rows[NativeTestScope])[0]), ) monkeypatch.setattr(loader, "load", lambda **_kw: owned) monkeypatch.setattr(loader, "locked", locked) @@ -713,7 +751,7 @@ def test_coalesced_native_wait_still_validates_requested_pending_owner(monkeypat @contextmanager def locked(**_kwargs: object) -> Generator[tuple[SQLDouble, OwnedNativeTest, NativeTestScope]]: - yield session, owned, cast(Sequence[NativeTestScope], session.rows[NativeTestScope])[0] + yield session, owned, checked_rows(NativeTestScope, session.rows[NativeTestScope])[0] resolver = NativeHttpBindingResolver() monkeypatch.setattr( @@ -744,9 +782,11 @@ def test_coalesced_native_wait_still_validates_requested_pending_owner(monkeypat {"wait_reason": None, "pending_ids": {}}, ], ) -def test_inconsistent_reason_ids_or_unacknowledged_current_rows_never_open_dispatch(changes: InvocationChanges) -> None: +def test_inconsistent_reason_ids_or_unacknowledged_current_rows_never_open_dispatch( + monkeypatch: pytest.MonkeyPatch, changes: InvocationChanges +) -> None: with pytest.raises(BuilderExecutionPolicyError): - readiness(SQLDouble([state(**changes)], [pending()]), coalesce=True) + readiness(monkeypatch, SQLDouble([state(**changes)], [pending()]), coalesce=True) @pytest.mark.parametrize("child_status", ["running", "completed", "stopped"]) @@ -787,7 +827,7 @@ def test_existing_nested_child_lookup_requires_actual_parent_private_authority( @override def scalar[T](self, statement: Select[tuple[T]]) -> T | None: if statement.column_descriptions[0]["entity"] is NativeInvocationState: - return cast(T, child) # exact existing child selected by parent occurrence + return selected_rows(statement, [child])[0] # exact existing child selected by parent occurrence return super().scalar(statement) session = ExistingChildSQL([state(), state("parent-B", "root", app_id="app-B", workflow_id="workflow-B"), child]) @@ -799,7 +839,7 @@ def test_existing_nested_child_lookup_requires_actual_parent_private_authority( # Root request loader remains authorized in every case, including local B revocation. owned = fence_owner(tenant_id="tenant", app_id="app", ref=parent_ref) loader = OwnedNativeTestLoader( - cast(sessionmaker[Session], factory), + sql_session_factory(monkeypatch, factory, methods=SQLDouble.sql_methods), actor=Actor(tenant_id=owned.tenant_id, account_id=owned.actor_id), resolver=NativeHttpBindingResolver(), ) @@ -894,9 +934,9 @@ def test_waiting_immutable_denial_is_negative_evidence_without_fresh_admission( } ) writes: list[object] = [] - session.add = writes.append - session.flush = lambda: None - scope = cast(Sequence[NativeTestScope], session.rows[NativeTestScope])[0] + monkeypatch.setattr(session, "add", writes.append) + monkeypatch.setattr(session, "flush", lambda: None) + scope = checked_rows(NativeTestScope, session.rows[NativeTestScope])[0] scope.spent_calls = scope.spent_dispatches = scope.spent_cost_units = 0 owned = fence_owner(ref=REF, tenant_id="tenant", actor_id="actor", app_id="app", task_approved=False) service = EffectAuthorizationService( @@ -907,10 +947,12 @@ def test_waiting_immutable_denial_is_negative_evidence_without_fresh_admission( ) monkeypatch.setattr(service, "_binding", lambda *_: binding(effect)) if decision == "denied": - assert service._claim(cast(Session, session), owned, scope, effect) == PolicyStop(reason_code="effect_denied") + assert service._claim(sql_session(monkeypatch, session.sql_methods()), owned, scope, effect) == PolicyStop( + reason_code="effect_denied" + ) else: with pytest.raises(BuilderExecutionPolicyError, match="native_dispatch_closed"): - service._claim(cast(Session, session), owned, scope, effect) + service._claim(sql_session(monkeypatch, session.sql_methods()), owned, scope, effect) assert writes == [] assert (scope.spent_calls, scope.spent_dispatches, scope.spent_cost_units) == (0, 0, 0) assert operation.state == "pending" @@ -941,7 +983,7 @@ def test_physical_claim_validates_permit_then_preserves_ancestor_refusal( ) session = SQLDouble([state(state=ancestor_state), state("child", "root")]) session.rows[NativeEffectOperation] = [operation] - scope = cast(Sequence[NativeTestScope], session.rows[NativeTestScope])[0] + scope = checked_rows(NativeTestScope, session.rows[NativeTestScope])[0] attempt = NativeEffectAttempt( expires_at=NOW + timedelta(hours=1), tenant_id="tenant", @@ -978,7 +1020,7 @@ def test_physical_claim_validates_permit_then_preserves_ancestor_refusal( expected = "invalid_dispatch_permit" if fault else "native_ancestor_quarantined" with pytest.raises(BuilderExecutionPolicyError, match=expected): service._validate_dispatch_locked( - session=cast(Session, session), + session=sql_session(monkeypatch, session.sql_methods()), owned=fence_owner(ref=child_ref, tenant_id="tenant"), scope=scope, intent=effect, @@ -1014,21 +1056,24 @@ def test_terminal_child_settlement_without_resolution_owner_commits_and_keeps_pa def scalar[T](self, statement: Select[tuple[T]]) -> T | None: entity = statement.column_descriptions[0]["entity"] if entity in {NativeInvocationState, NativeTestInvocation}: - rows = cast(Sequence[NativeInvocationState | NativeTestInvocation], self.rows[entity]) + rows: list[NativeInvocationState | NativeTestInvocation] = [] + for row in self.rows[entity]: + assert isinstance(row, (NativeInvocationState, NativeTestInvocation)) + rows.append(row) params = statement.compile().params if "invocation_id_1" in params: rows = [r for r in rows if r.invocation_id == params["invocation_id_1"]] if "state_1" in params: rows = [r for r in rows if isinstance(r, NativeInvocationState) and r.state == params["state_1"]] - return cast(T, rows[0]) if rows else None + return selected_rows(statement, rows)[0] if rows else None return super().scalar(statement) session = SettlementSQL([root, child], [pending(invocation_id="child", category=category)]) writes: list[object] = [] committed: list[bool] = [] - session.add = writes.append + monkeypatch.setattr(session, "add", writes.append) owned = fence_owner(ref=child_ref, tenant_id="tenant") - scope = cast(Sequence[NativeTestScope], session.rows[NativeTestScope])[0] + scope = checked_rows(NativeTestScope, session.rows[NativeTestScope])[0] scope.generation = 3 @contextmanager @@ -1039,7 +1084,7 @@ def test_terminal_child_settlement_without_resolution_owner_commits_and_keeps_pa def load(_session: object, *, ref: NativeTestContextRef, **_: object) -> tuple[OwnedNativeTest, NativeTestScope]: current = next( row - for row in cast(Sequence[NativeTestInvocation], session.rows[NativeTestInvocation]) + for row in checked_rows(NativeTestInvocation, session.rows[NativeTestInvocation]) if row.invocation_id == ref.invocation_id ) if not current.active: @@ -1072,5 +1117,5 @@ def test_terminal_child_settlement_without_resolution_owner_commits_and_keeps_pa assert committed == [True] assert (child.state, child.outcome) == ("completed", outcome.model_dump(mode="json")) assert (root.state, root.pending_ids, root.checkpoint_id) == ("waiting", ["pending"], "parent-checkpoint") - assert not cast(Sequence[NativeTestInvocation], session.rows[NativeTestInvocation])[1].active + assert not checked_rows(NativeTestInvocation, session.rows[NativeTestInvocation])[1].active assert writes == [] diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_host_capacity.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_host_capacity.py index afd702466c1..6984c4c431f 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_host_capacity.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_host_capacity.py @@ -5,11 +5,11 @@ from collections.abc import Container, Generator, Mapping, Sequence from contextlib import contextmanager from dataclasses import asdict, dataclass, is_dataclass from datetime import UTC, datetime, timedelta -from typing import NoReturn, cast +from typing import NoReturn import pytest from sqlalchemy import Select, inspect -from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.orm import Session from sqlalchemy.orm.state import InstanceState from sqlalchemy.sql import operators from sqlalchemy.sql.elements import ( @@ -57,6 +57,14 @@ from services.dify_builder.native_testing.repository import ( native_workflow_revision, ) from services.dify_builder.native_testing.state_store import NativeExecutionStateStore +from tests.unit_tests.services.dify_builder.native_testing.typed_boundaries import ( + SqlMethods, + checked_row, + checked_rows, + selected_rows, + sql_session, + sql_session_factory, +) NOW = datetime(2026, 10, 16, tzinfo=UTC) @@ -69,7 +77,7 @@ class Rows[T](UserList[T]): class SQL: def __init__(self, rows: Mapping[type, Sequence[object]], events: list[str]) -> None: assert isinstance(rows, dict) - self.rows = cast(dict[type, list[object]], rows) + self.rows = rows self.events = events self.occupancy: list[tuple[object, ...]] = [] @@ -177,7 +185,7 @@ class SQL: selected.append(asdict(row)[name]) else: selected.append(row) - return Rows(cast(list[T], selected)) + return Rows(selected_rows(query, selected)) def scalar[T](self, query: Select[tuple[T]]) -> T | None: if query.column_descriptions[0]["entity"] is App and query._for_update_arg is not None: @@ -192,14 +200,16 @@ class SQL: state = inspect(row, raiseerr=False) assert isinstance(state, InstanceState) if state.attrs["id"].value == identity: - return cast(T, row) + return checked_row(model, row) return None - def execute[T: tuple[object, ...]](self, _query: Select[T]) -> Rows[T]: - return Rows(cast(list[T], self.occupancy)) + def execute(self, _query: Select[tuple[object, ...]]) -> Rows[object]: + return Rows(self.occupancy) def add(self, row: object) -> None: - self.rows.setdefault(type(row), []).append(row) + rows = self.rows.setdefault(type(row), []) + assert isinstance(rows, list) + rows.append(row) def flush(self) -> None: for i, row in enumerate(self.rows.get(NativeDispatch, [])): @@ -208,6 +218,17 @@ class SQL: if identity is None: row.id = "dispatch-" + str(i) + def sql_methods(self) -> SqlMethods: + """Expose this fixture's explicit SQL IO methods, including overrides.""" + return { + "get": self.get, + "scalar": self.scalar, + "scalars": self.scalars, + "execute": self.execute, + "add": self.add, + "flush": self.flush, + } + @dataclass class HostCapacityCase: @@ -419,7 +440,8 @@ def case(monkeypatch: pytest.MonkeyPatch) -> HostCapacityCase: yield session events.append("commit") - factory = cast(sessionmaker[Session], SessionFactory()) + boundary = SessionFactory() + factory = sql_session_factory(monkeypatch, boundary, boundary.begin, methods=SQL.sql_methods) def load( _session: object, *, ref: NativeTestContextRef, lock: bool = False, **_kwargs: object @@ -467,8 +489,8 @@ def test_initial_generation_claim_uses_acknowledged_context_without_fake_checkpo assert case.lifecycle.claim_dispatch(ref=case.ref, generation=0, dispatch_id="dispatch") assert case.state.state == "running" assert not case.state.cleanup_complete - assert cast(list[NativeContinuation], case.session.rows[NativeContinuation]) == [] - assert cast(list[NativeDispatch], case.session.rows[NativeDispatch])[0].state == "claimed" + assert checked_rows(NativeContinuation, case.session.rows[NativeContinuation]) == [] + assert checked_rows(NativeDispatch, case.session.rows[NativeDispatch])[0].state == "claimed" assert not case.lifecycle.claim_dispatch(ref=case.ref, generation=0, dispatch_id="dispatch") @@ -487,11 +509,14 @@ def test_transaction_local_c03_ports_are_explicit(method: str) -> None: ) -def test_capacity_requires_actual_cleanup_verifier(case: HostCapacityCase) -> None: +@pytest.mark.parametrize("cleanup_verifier", [None]) +def test_capacity_requires_actual_cleanup_verifier( + case: HostCapacityCase, cleanup_verifier: NativeHostCleanupVerifier +) -> None: from services.dify_builder.native_testing.host_capacity import NativeHostCapacity with pytest.raises(BuilderExecutionPolicyError, match="native_cleanup_verifier_required"): - NativeHostCapacity(case.lifecycle, cleanup_verifier=cast(NativeHostCleanupVerifier, None)) + NativeHostCapacity(case.lifecycle, cleanup_verifier=cleanup_verifier) class OnceVerifier: @@ -606,7 +631,7 @@ def test_unclean_existing_root_holds_capacity_across_sessions_and_terminal_statu ] with pytest.raises(BuilderExecutionPolicyError, match="native_capacity_busy"): owner.claim_dispatch(ref=case.ref, generation=0, dispatch_id="dispatch") - assert cast(list[NativeDispatch], case.session.rows[NativeDispatch])[0].state == "queued" + assert checked_rows(NativeDispatch, case.session.rows[NativeDispatch])[0].state == "queued" def test_cleanup_is_once_exact_current_lifetime_outside_writer_and_releases_only_after_proof( @@ -669,7 +694,9 @@ def test_runtime_cancel_ids_do_not_release_composed_capacity_without_verifier(ca assert not case.state.cleanup_complete -def test_transaction_local_finalize_cannot_mutate_another_requests_claim(case: HostCapacityCase) -> None: +def test_transaction_local_finalize_cannot_mutate_another_requests_claim( + case: HostCapacityCase, monkeypatch: pytest.MonkeyPatch +) -> None: from runtime_contracts.builder.native.runtime_contracts import NativeWorkflowOutcome foreign = NativeDispatch( @@ -681,7 +708,7 @@ def test_transaction_local_finalize_cannot_mutate_another_requests_claim(case: H generation=0, state="claimed", ) - cast(list[NativeDispatch], case.session.rows[NativeDispatch]).append(foreign) + case.session.add(foreign) outcome = NativeWorkflowOutcome( status="completed", ref=case.ref, @@ -695,7 +722,7 @@ def test_transaction_local_finalize_cannot_mutate_another_requests_claim(case: H ) with pytest.raises(BuilderExecutionPolicyError, match="native_dispatch_conflict"): case.lifecycle.finalize_dispatch_locked( - session=cast(Session, case.session), + session=sql_session(monkeypatch, case.session.sql_methods()), owned=case.owned, scope=case.scope, dispatch_id=foreign.id, @@ -711,16 +738,16 @@ def test_initial_enqueue_is_real_acknowledged_idempotent_outbox_not_a_checkpoint first = case.lifecycle.enqueue_initial_dispatch(ref=case.ref) assert first == case.lifecycle.enqueue_initial_dispatch(ref=case.ref) assert (first.ref, first.generation, case.state.state) == (case.ref, 0, "queued") - assert len(cast(list[NativeDispatch], case.session.rows[NativeDispatch])) == 1 - assert cast(list[NativeDispatch], case.session.rows[NativeDispatch])[0].state == "queued" - assert cast(list[NativeContinuation], case.session.rows[NativeContinuation]) == [] + assert len(checked_rows(NativeDispatch, case.session.rows[NativeDispatch])) == 1 + assert checked_rows(NativeDispatch, case.session.rows[NativeDispatch])[0].state == "queued" + assert checked_rows(NativeContinuation, case.session.rows[NativeContinuation]) == [] @pytest.mark.parametrize("fault", ["wrong_initial_key", "already_clean", "unrepresented_form"]) def test_initial_claim_cannot_borrow_resume_or_prior_pending_authority(case: HostCapacityCase, fault: str) -> None: if fault == "wrong_initial_key": - cast(list[NativeDispatch], case.session.rows[NativeDispatch])[0].idempotency_digest = "foreign" + checked_rows(NativeDispatch, case.session.rows[NativeDispatch])[0].idempotency_digest = "foreign" elif fault == "already_clean": case.state.cleanup_complete = True else: @@ -740,7 +767,7 @@ def test_initial_claim_cannot_borrow_resume_or_prior_pending_authority(case: Hos ] with pytest.raises(BuilderExecutionPolicyError, match="native_initial_conflict"): case.lifecycle.claim_dispatch(ref=case.ref, generation=0, dispatch_id="dispatch") - assert cast(list[NativeDispatch], case.session.rows[NativeDispatch])[0].state == "queued" + assert checked_rows(NativeDispatch, case.session.rows[NativeDispatch])[0].state == "queued" @pytest.mark.parametrize("fault", ["missing_context", "changed_input", "changed_graph", "prior_effect"]) @@ -752,9 +779,9 @@ def test_initial_enqueue_rechecks_actual_context_and_sql_inputs_before_outbox( if fault == "missing_context": case.session.rows[NativePayloadObject] = [] elif fault == "changed_input": - cast(list[DifyBuilderTestInput], case.session.rows[DifyBuilderTestInput])[0].inputs = {"x": "true"} + checked_rows(DifyBuilderTestInput, case.session.rows[DifyBuilderTestInput])[0].inputs = {"x": "true"} elif fault == "changed_graph": - cast(list[Workflow], case.session.rows[Workflow])[0].graph = '{"changed":true}' + checked_rows(Workflow, case.session.rows[Workflow])[0].graph = '{"changed":true}' else: case.session.rows[NativeEffectAttempt] = [ NativeEffectAttempt( @@ -777,19 +804,21 @@ def test_initial_enqueue_rechecks_actual_context_and_sql_inputs_before_outbox( ] with pytest.raises(BuilderExecutionPolicyError): case.lifecycle.enqueue_initial_dispatch(ref=case.ref) - assert cast(list[NativeDispatch], case.session.rows[NativeDispatch]) == [] + assert checked_rows(NativeDispatch, case.session.rows[NativeDispatch]) == [] -def test_boolean_cleanup_result_is_not_actual_trusted_none_success(case: HostCapacityCase) -> None: +def test_boolean_cleanup_result_is_not_actual_trusted_none_success( + case: HostCapacityCase, monkeypatch: pytest.MonkeyPatch +) -> None: from services.dify_builder.native_testing.host_capacity import NativeHostCapacity class BooleanCleanupVerifier: def consume_cleanup(self, **_kwargs: object) -> bool: return True - owner = NativeHostCapacity( - case.lifecycle, cleanup_verifier=cast(NativeHostCleanupVerifier, BooleanCleanupVerifier()) - ) + verifier = OnceVerifier(case.events) + monkeypatch.setattr(verifier, "consume_cleanup", BooleanCleanupVerifier().consume_cleanup) + owner = NativeHostCapacity(case.lifecycle, cleanup_verifier=verifier) assert owner.claim_dispatch(ref=case.ref, generation=0, dispatch_id="dispatch") with pytest.raises(BuilderExecutionPolicyError, match="native_cleanup_unproven"): owner.record_cleanup(ref=case.ref, generation=0, dispatch_id="dispatch") @@ -803,18 +832,20 @@ def test_an_old_capacity_lifetime_cannot_claim_same_sql_ids_again_after_cleanup( assert owner.claim_dispatch(ref=case.ref, generation=0, dispatch_id="dispatch") owner.record_cleanup(ref=case.ref, generation=0, dispatch_id="dispatch") # Boundary-double simulation of a requeued row, not initial crash-recovery proof. - cast(list[NativeDispatch], case.session.rows[NativeDispatch])[0].state = "queued" + checked_rows(NativeDispatch, case.session.rows[NativeDispatch])[0].state = "queued" case.state.state = "queued" assert not owner.claim_dispatch(ref=case.ref, generation=0, dispatch_id="dispatch") - assert cast(list[NativeDispatch], case.session.rows[NativeDispatch])[0].state == "queued" + assert checked_rows(NativeDispatch, case.session.rows[NativeDispatch])[0].state == "queued" assert case.state.cleanup_complete -def test_transaction_local_wait_finalization_requires_actual_c03_ack(case: HostCapacityCase) -> None: +def test_transaction_local_wait_finalization_requires_actual_c03_ack( + case: HostCapacityCase, monkeypatch: pytest.MonkeyPatch +) -> None: from runtime_contracts.builder.native.contracts import CheckpointRef from runtime_contracts.builder.native.runtime_contracts import NativeWorkflowOutcome - dispatch = cast(list[NativeDispatch], case.session.rows[NativeDispatch])[0] + dispatch = checked_rows(NativeDispatch, case.session.rows[NativeDispatch])[0] dispatch.state = "claimed" case.state.state = "running" outcome = NativeWorkflowOutcome( @@ -830,7 +861,7 @@ def test_transaction_local_wait_finalization_requires_actual_c03_ack(case: HostC ) with pytest.raises(BuilderExecutionPolicyError, match="checkpoint_unavailable"): case.lifecycle.finalize_dispatch_locked( - session=cast(Session, case.session), + session=sql_session(monkeypatch, case.session.sql_methods()), owned=case.owned, scope=case.scope, dispatch_id=dispatch.id, @@ -840,13 +871,15 @@ def test_transaction_local_wait_finalization_requires_actual_c03_ack(case: HostC assert dispatch.state == "claimed" -def test_transaction_local_outcome_cannot_attach_a_foreign_private_result(case: HostCapacityCase) -> None: +def test_transaction_local_outcome_cannot_attach_a_foreign_private_result( + case: HostCapacityCase, monkeypatch: pytest.MonkeyPatch +) -> None: from runtime_contracts.builder.native.runtime_contracts import NativeWorkflowOutcome - dispatch = cast(list[NativeDispatch], case.session.rows[NativeDispatch])[0] + dispatch = checked_rows(NativeDispatch, case.session.rows[NativeDispatch])[0] dispatch.state = "claimed" case.state.state = "running" - cast(list[NativePayloadObject], case.session.rows[NativePayloadObject]).append( + case.session.add( NativePayloadObject( logical_key="result", storage_key="foreign-storage", @@ -872,7 +905,7 @@ def test_transaction_local_outcome_cannot_attach_a_foreign_private_result(case: ) with pytest.raises(BuilderExecutionPolicyError, match="native_result_owner_mismatch"): case.lifecycle.finalize_dispatch_locked( - session=cast(Session, case.session), + session=sql_session(monkeypatch, case.session.sql_methods()), owned=case.owned, scope=case.scope, dispatch_id=dispatch.id, @@ -894,13 +927,13 @@ def test_delayed_claim_rechecks_lifetime_after_app_lock( def delayed(**kwargs: object) -> Generator[tuple[Session, OwnedNativeTest, NativeTestScope]]: # Model another call acquiring this exact lifetime while our prepared # delivery waits for App, followed by same-ID SQL requeue after cleanup. - with original(ref=cast(NativeTestContextRef, kwargs["ref"])) as value: + with original(ref=checked_row(NativeTestContextRef, kwargs["ref"])) as value: owner._used = True yield value monkeypatch.setattr(owner, "locked", delayed) assert not owner.claim_dispatch(ref=case.ref, generation=0, dispatch_id="dispatch") - assert cast(list[NativeDispatch], case.session.rows[NativeDispatch])[0].state == "queued" + assert checked_rows(NativeDispatch, case.session.rows[NativeDispatch])[0].state == "queued" @pytest.mark.parametrize("status", ["queued", "published"]) @@ -910,7 +943,7 @@ def test_never_acquired_cancel_finishes_and_reconcile_does_not_repeat(case: Host verifier = OnceVerifier(case.events) verifier.current = False # No physical acquisition exists to verify. NativeHostCapacity(case.lifecycle, cleanup_verifier=verifier) - cast(list[NativeDispatch], case.session.rows[NativeDispatch])[0].state = status + checked_rows(NativeDispatch, case.session.rows[NativeDispatch])[0].state = status first = case.lifecycle.cancel(actor=case.loader.actor, request_id="request", idempotency_key="cancel") assert first.status == "cancelled" assert case.state.cleanup_complete @@ -938,9 +971,9 @@ def test_preclaim_cancel_refuses_uncertain_history_without_calling_physical_veri if fault == "missing_dispatch": case.session.rows[NativeDispatch] = [] elif fault == "prior_lease": - cast(list[NativeDispatch], case.session.rows[NativeDispatch])[0].claim_expires_at = NOW + checked_rows(NativeDispatch, case.session.rows[NativeDispatch])[0].claim_expires_at = NOW elif fault == "foreign_dispatch": - cast(list[NativeDispatch], case.session.rows[NativeDispatch])[0].tenant_id = "foreign" + checked_rows(NativeDispatch, case.session.rows[NativeDispatch])[0].tenant_id = "foreign" elif fault == "missing_context": case.session.rows[NativePayloadObject] = [] else: @@ -966,7 +999,9 @@ def test_code_cleanup_sql_check_precedes_consume_and_rechecks_inside_writer(case class CodeCleanup: def validate_cleanup_rows(self, **kwargs: object) -> None: - assert kwargs["session"] is case.session + session = kwargs["session"] + assert isinstance(session, Session) + assert session.get(NativeInvocationState, "state") is case.state case.events.append("code_rows") owner = NativeHostCapacity(case.lifecycle, cleanup_verifier=OnceVerifier(case.events), code_cleanup=CodeCleanup()) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_host_execution.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_host_execution.py index cabf0b3ed1b..4e5399b0c31 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_host_execution.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_host_execution.py @@ -7,9 +7,10 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass, field +from operator import methodcaller from threading import Barrier, Event, Thread, current_thread from time import monotonic, sleep -from typing import TYPE_CHECKING, Protocol, cast, override +from typing import TYPE_CHECKING, Protocol, override from weakref import ReferenceType import pytest @@ -56,7 +57,6 @@ from tests.unit_tests.services.dify_builder.native_testing.test_call_adapter imp ) from tests.unit_tests.services.dify_builder.native_testing.test_control import ( Factory, - Observations, bound_guard, make_native, observed_iteration_config, @@ -65,13 +65,6 @@ from tests.unit_tests.services.dify_builder.native_testing.test_control import ( if TYPE_CHECKING: from services.dify_builder.native_testing.host_execution import DurableNativeStopSealer - from services.dify_builder.native_testing.repository import OwnedNativeTestLoader - from services.dify_builder.native_testing.runtime_bridge import NativeRuntimeBridge - from services.dify_builder.native_testing.state_store import NativeExecutionStateStore - - -class MutableTerminal(Protocol): - terminal: str class NoCheckpointLifecycle: @@ -94,7 +87,6 @@ class DurablePorts: fences: list[tuple[NativeTestContextRef, tuple[str, ...], int]] = field(default_factory=list) stops: list[tuple[NativeTestContextRef, int, str, str]] = field(default_factory=list) error: BaseException | None = None - result: object = None entered: Event | None = None release: Event | None = None on_fence: Callable[[], None] | None = None @@ -111,7 +103,6 @@ class DurablePorts: self.release.wait(2) if self.error is not None: raise self.error - return cast(None, self.result) def seal_stop( self, *, ref: NativeTestContextRef, expected_generation: int, dispatch_id: str, reason_code: str @@ -119,7 +110,6 @@ class DurablePorts: self.stops.append((ref, expected_generation, dispatch_id, reason_code)) if self.error is not None: raise self.error - return cast(None, self.result) class LaunchCase: @@ -183,18 +173,17 @@ class LaunchCase: ) def own_node(self) -> Node[BaseNodeData]: - from dataclasses import replace + from tests.unit_tests.services.dify_builder.native_testing.test_control import Effects - from tests.unit_tests.services.dify_builder.native_testing.test_control import Effects, Sink - - _, factory = make_native(decision=permit(), ownership=cast(Observations, self.host)) - factory = replace( - factory, + _, factory = make_native(decision=permit(), ownership=self.host) + bound_factory = Factory( + config=factory.config, + runtime=factory.runtime, owner=self.host.boundary_owner, - sink=cast(Sink, self.host.control_sink), + sink=self.host.control_sink, effects=Effects(self.host.boundary_owner, permit()), ) - node = bound_guard(factory) + node = bound_guard(bound_factory) self.host.bind_node_runtime(node=node) return node @@ -248,7 +237,7 @@ def test_scalar_completion_replays_real_journal_without_extra_claim_or_key() -> ) def test_nonfinite_or_nonpositive_http_baseline_is_refused(timeout: tuple[object, ...]) -> None: with pytest.raises(ValueError, match="^native_host_http_timeout_unqualified$"): - LaunchCase(baseline=cast(tuple[float, float, float], timeout)) + methodcaller("__call__", baseline=timeout)(LaunchCase) def test_actual_prepared_timeout_snapshot_contributes_to_max_sum_and_fixed_deadline() -> None: @@ -304,9 +293,15 @@ def test_typed_control_is_accepted_only_after_exact_durable_port_ack(stop: bool) @pytest.mark.parametrize("failure", ["non-none", "lost-ack"]) -def test_ambiguous_fence_never_retries_or_accepts(failure: str) -> None: +def test_ambiguous_fence_never_retries_or_accepts(failure: str, monkeypatch: pytest.MonkeyPatch) -> None: case = LaunchCase() - case.ports.result = False if failure == "non-none" else None + if failure == "non-none": + + def malformed_fence(**kwargs: object) -> bool: + methodcaller("fence_pending", **kwargs)(case.ports) + return False + + monkeypatch.setattr(case.host, "_fence_pending", malformed_fence) case.ports.error = RuntimeError("lost acknowledgement") if failure == "lost-ack" else None node = case.own_node() with case.host.node_scope(node=node, phase="run"): @@ -547,7 +542,7 @@ def native_entry( from graphon.graph import Graph from graphon.runtime import RuntimeState, VariablePool from services.workflow_runtime.entry import WorkflowEntry - from tests.unit_tests.services.dify_builder.native_testing.test_control import Effects, Factory, Sink, basic + from tests.unit_tests.services.dify_builder.native_testing.test_control import Effects, Factory, basic from tests.unit_tests.services.dify_builder.native_testing.test_native_observation_wiring import ( bind_component_observation, ) @@ -564,7 +559,7 @@ def native_entry( config=config, runtime=runtime, owner=case.host.boundary_owner, - sink=cast(Sink, case.host.control_sink), + sink=case.host.control_sink, effects=Effects(case.host.boundary_owner, permit()), ) graph = Graph.init(graph_config=config, node_factory=factory, root_node_id="start") @@ -622,7 +617,7 @@ def test_root_observation_retains_actual_business_terminal_without_payload(monke assert not observed.error assert not hasattr(observed, "outputs") with pytest.raises(FrozenInstanceError): - cast(MutableTerminal, observed).terminal = "failed" + methodcaller("__setattr__", "terminal", "failed")(observed) def _run_observed(case: LaunchCase, entry: WorkflowEntry) -> None: @@ -867,7 +862,7 @@ def test_launch_identity_is_not_replaceable_and_retirement_requires_real_cleanup def test_foreign_boundary_owner_node_cannot_be_adopted_by_launch() -> None: case = LaunchCase() - _, factory = make_native(decision=permit(), ownership=cast(Observations, case.host)) + _, factory = make_native(decision=permit(), ownership=case.host) foreign = bound_guard(factory) with pytest.raises(ValueError, match="^native_host_node_required$"): case.host.bind_node_runtime(node=foreign) @@ -1063,10 +1058,19 @@ def test_real_producer_registration_retains_exact_instance_without_fabricating_p case = LaunchCase() node = case.own_node() - # Structural registration only: these labeled unused loader/bridge/store - # values never run resolution, SQL, preparation or proposal production. + from services.dify_builder.native_testing.runtime_bridge import NativeRuntimeBridge + from tests.unit_tests.services.dify_builder.native_testing.test_runtime_bridge import _api + + api = _api() + bridge = NativeRuntimeBridge( + state=api.store, + collaborators=lambda **_: pytest.fail("registration must not resolve collaborators"), + node_arguments=lambda **_: pytest.fail("registration must not prepare arguments"), + boundary_owner=case.host.boundary_owner, + control_sink=case.host.control_sink, + ) association = NativeNodePreparation( - bridge=cast("NativeRuntimeBridge", object()), + bridge=bridge, ref=REF, node_id=node.id, node_class=type(node), @@ -1074,8 +1078,8 @@ def test_real_producer_registration_retains_exact_instance_without_fabricating_p ) producer = NativeHttpBindingProducer( association=association, - loader=cast("OwnedNativeTestLoader", object()), - state_store=cast("NativeExecutionStateStore", case.state), + loader=api.loader, + state_store=api.store, journal=case.journal, ) case.host.register_proposals(producer=producer) @@ -1155,7 +1159,7 @@ def test_mandatory_stop_owner_and_exact_resolved_context_are_required() -> None: # Explicit invalid port, not a production fallback/default. with pytest.raises(ValueError, match="native_host_explicit_owners_required"): - construct(context=case.state.context, stop_sealer=cast("DurableNativeStopSealer", None)) + methodcaller("__call__", context=case.state.context, stop_sealer=None)(construct) with pytest.raises(ValueError, match="native_host_explicit_owners_required"): construct(context=case.state.context.model_copy(), stop_sealer=case.ports) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_host_generation_wiring.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_host_generation_wiring.py index f8b1b9d7b65..795e742f0f9 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_host_generation_wiring.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_host_generation_wiring.py @@ -12,7 +12,7 @@ from collections.abc import Generator, Sequence from contextlib import nullcontext from threading import Event, Thread, current_thread from time import monotonic -from typing import Literal, NoReturn, cast, override +from typing import Literal, NoReturn, override import pytest from flask import Flask @@ -40,7 +40,7 @@ from services.dify_builder.native_testing.context_binding import NativeContextUn from services.dify_builder.native_testing.runtime_bridge import NativePreparedInputs, NativeRuntimeBridge from services.workflow_runtime.entry import WorkflowEntry from services.workflow_runtime.workflow_generator import WorkflowAppGenerator -from tests.unit_tests.services.dify_builder.native_testing.test_control import Effects, Factory, Sink, basic, permit +from tests.unit_tests.services.dify_builder.native_testing.test_control import Effects, Factory, basic, permit from tests.unit_tests.services.dify_builder.native_testing.test_host_execution import ( LaunchCase, NoCheckpointLifecycle, @@ -56,7 +56,7 @@ def runtime_for(case: LaunchCase) -> NativeRuntimeBridge: return NativeRuntimeBridge( state=case.state, boundary_owner=case.host.boundary_owner, - control_sink=cast(Sink, case.host.control_sink), + control_sink=case.host.control_sink, collaborators=unused, node_arguments=unused, prepare_inputs=lambda _context: NativePreparedInputs( @@ -224,10 +224,12 @@ def test_owned_worker_preserves_bound_entity_and_reports_finally_on_actual_threa monkeypatch.setattr(case.host, "worker_finished", finished_port) from threading import Thread + def execute(runner: object, *_args: object) -> None: + assert isinstance(runner, RunnerPort) + runner.run() + worker = Thread( - target=WorkflowAppGenerator( - execution_driver=lambda runner, *_args: cast(RunnerPort, runner).run() - )._generate_worker, + target=WorkflowAppGenerator(execution_driver=execute)._generate_worker, kwargs={ "flask_app": Flask(__name__), "application_generate_entity": entity, @@ -275,7 +277,7 @@ def test_entry_installs_actual_host_layer_first_and_disables_raw_observers(monke config=config, runtime=runtime, owner=case.host.boundary_owner, - sink=cast(Sink, case.host.control_sink), + sink=case.host.control_sink, effects=Effects(case.host.boundary_owner, permit()), ) graph = Graph.init(graph_config=config, node_factory=factory, root_node_id="start") @@ -496,6 +498,7 @@ def test_actual_runner_passes_same_host_filter_runtime_and_wrapped_combined_chan ) -> None: from threading import Thread + from core.app.app_config.entities import WorkflowUIBasedAppConfig from core.app.apps.workflow.command_channels import CombinedCommandChannel from services.dify_builder.native_testing.host_lifetime import NativeHostLifetime from services.workflow_runtime import workflow_runner as module @@ -517,6 +520,8 @@ def test_actual_runner_passes_same_host_filter_runtime_and_wrapped_combined_chan http_baseline=(1, 1, 1), ) entity, queue, response_filter, workflow_repo, node_repo = generation_objects(case, monkeypatch) + entity = WorkflowAppGenerateEntity.model_validate_json(entity.model_dump_json()) + assert type(entity.app_config) is WorkflowUIBasedAppConfig case.host.bind_generation( entity=entity, queue=queue, @@ -566,21 +571,25 @@ def test_actual_runner_passes_same_host_filter_runtime_and_wrapped_combined_chan def build_graph(**kwargs: object) -> Graph: from core.app.entities.app_invoke_entities import build_dify_run_context + user_from, invoke_from, runtime = kwargs["user_from"], kwargs["invoke_from"], kwargs["graph_runtime_state"] + assert isinstance(user_from, UserFrom) + assert isinstance(invoke_from, InvokeFrom) + assert isinstance(runtime, RuntimeState) run_context = build_dify_run_context( tenant_id=context.tenant_id, app_id=context.app_id, user_id=context.actor_id, - user_from=cast(UserFrom, kwargs["user_from"]), - invoke_from=cast(InvokeFrom, kwargs["invoke_from"]), + user_from=user_from, + invoke_from=invoke_from, native_test_required=True, native_test_ref=context.ref, ) factory = Factory( config=config, - runtime=cast(RuntimeState, kwargs["graph_runtime_state"]), + runtime=runtime, run_context=run_context, owner=case.host.boundary_owner, - sink=cast(Sink, case.host.control_sink), + sink=case.host.control_sink, effects=Effects(case.host.boundary_owner, permit()), ) factories.append(factory) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_host_lifetime.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_host_lifetime.py index 3c954c52b68..851a323dccf 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_host_lifetime.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_host_lifetime.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable, Generator from threading import Barrier, Event, Thread, current_thread from time import monotonic, sleep -from typing import Literal, cast, override +from typing import Literal, override import pytest @@ -592,7 +592,6 @@ def test_actual_native_boundary_keeps_scope_across_generator_yield_and_real_clos from graphon.engine_events.node import NodeRunStartedEvent from graphon.nodes.container_effects import IterationFrameRequest from tests.unit_tests.services.dify_builder.native_testing.test_control import ( - Observations, bound_guard, make_native, observed_iteration_config, @@ -600,7 +599,7 @@ def test_actual_native_boundary_keeps_scope_across_generator_yield_and_real_clos ) _, life = opened() - _, factory = make_native(config=observed_iteration_config(), decision=permit(), ownership=cast(Observations, life)) + _, factory = make_native(config=observed_iteration_config(), decision=permit(), ownership=life) life.bind_runtime(runtime_state=factory.runtime) node = bound_guard(factory) if phase == "resume_container": diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_http_adapter.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_http_adapter.py index b88a08e5f98..6e1187fe4de 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_http_adapter.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_http_adapter.py @@ -10,7 +10,8 @@ import threading from collections.abc import Callable, Generator, Iterator from dataclasses import replace from http.server import BaseHTTPRequestHandler, HTTPServer -from typing import NoReturn, cast, override +from operator import methodcaller +from typing import NoReturn, override import httpcore import httpx @@ -223,7 +224,7 @@ def test_protocol_methods_use_actual_ssrf_owner_without_retry(harness: Harness, @pytest.mark.parametrize("retry", [1, -1, True, None, "0"]) def test_retry_option_refused_before_preparation_or_dispatch(harness: Harness, retry: object) -> None: with pytest.raises(NativePolicyStop): - harness.client.post("https://first.example", max_retries=cast(int, retry)) + methodcaller("post", "https://first.example", max_retries=retry)(harness.client) assert harness.producer.requests == [] assert harness.sent == [] @@ -275,7 +276,7 @@ def test_frozen_snapshot_and_binding_never_expose_or_mutate_secret_values(harnes request = harness.producer.requests[0][0] assert "private-canary" not in repr(request) assert "private-canary" not in repr( - harness.module.PreparedHttpBinding(request=request, intent=cast(EffectIntent, None)) + methodcaller("__call__", request=request, intent=None)(harness.module.PreparedHttpBinding) ) headers["X-Token"] = "changed" extracted = request.arguments() @@ -287,11 +288,11 @@ def test_frozen_snapshot_and_binding_never_expose_or_mutate_secret_values(harnes @pytest.mark.parametrize("change", ["request", "kind", "invocation", "bare"]) def test_attestation_substitution_and_invalid_intent_fail_before_claim(harness: Harness, change: str) -> None: - def tamper(binding: PreparedHttpBinding) -> PreparedHttpBinding: + def tamper(binding: PreparedHttpBinding) -> object: if change == "request": return replace(binding, request=replace(binding.request)) if change == "bare": - return cast(PreparedHttpBinding, binding.intent) + return binding.intent intent = ( binding.intent.model_copy(update={"kind": "tool"}) if change == "kind" @@ -301,7 +302,7 @@ def test_attestation_substitution_and_invalid_intent_fail_before_claim(harness: ) return replace(binding, intent=intent) - harness.producer.change = tamper + harness.monkeypatch.setattr(harness.producer, "change", tamper) with pytest.raises(NativePolicyStop): harness.request() assert harness.sent == [] @@ -584,13 +585,14 @@ def test_admitted_budgets_require_exact_finite_integers(harness: Harness, option else "native_http_response_budget_required" ) with pytest.raises(ValueError, match=f"^{reason}$"): - harness.module.NativeHttpClient( + methodcaller( + "__call__", ref=REF, calls=harness.calls, intents=harness.producer, - max_redirect_hops=cast(int, budgets["max_redirect_hops"]), - max_response_bytes=cast(int, budgets["max_response_bytes"]), - ) + max_redirect_hops=budgets["max_redirect_hops"], + max_response_bytes=budgets["max_response_bytes"], + )(harness.module.NativeHttpClient) @pytest.mark.parametrize( @@ -608,7 +610,7 @@ def test_admitted_budgets_require_exact_finite_integers(harness: Harness, option ) def test_unadmitted_or_nonfinite_material_refused_before_intent(harness: Harness, option: str, value: object) -> None: with pytest.raises(NativePolicyStop): - harness.request(**{option: cast(str, value)}) + methodcaller("request", **{option: value})(harness) assert harness.sent == [] assert harness.producer.requests == [] @@ -744,7 +746,7 @@ def test_ready_check_is_repeated_for_redirect_metadata_before_new_claim( monkeypatch.setattr(harness.state, "store_hook", lambda: logging.getLogger().handlers[0].filters.clear()) return binding - harness.producer.prepare = prepare + monkeypatch.setattr(harness.producer, "prepare", prepare) with pytest.raises(NativePolicyStop) as caught: harness.request() assert caught.value.reason_code == "native_http_telemetry_unavailable" @@ -833,7 +835,7 @@ def test_http_owner_suppresses_real_global_httpx_instrumentation_only_during_phy assert not is_instrumentation_enabled() return httpcore.Response(200, content=b"ok") - transport._pool.handle_request = handle_request + monkeypatch.setattr(transport._pool, "handle_request", handle_request) def before_authorization() -> None: assert is_instrumentation_enabled() @@ -847,7 +849,7 @@ def test_http_owner_suppresses_real_global_httpx_instrumentation_only_during_phy assert is_instrumentation_enabled() return original_prepare(request=request, hop=hop, previous_response=previous_response) - harness.producer.prepare = prepare + monkeypatch.setattr(harness.producer, "prepare", prepare) try: with httpx.Client(transport=transport, trust_env=False) as client: monkeypatch.setattr(ssrf_proxy, "_get_ssrf_client", lambda _verify: client) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_http_binding.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_http_binding.py index 0c72ebef6bb..16e5298ce0a 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_http_binding.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_http_binding.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Generator from dataclasses import dataclass, field, replace from datetime import UTC, datetime -from typing import NoReturn, cast +from typing import NoReturn import httpx import pytest @@ -19,17 +19,15 @@ from runtime_contracts.builder.native.contracts import DispatchPermit, EffectInt from runtime_contracts.builder.native.runtime_contracts import NativeStatePort from services.dify_builder.native_testing import http_binding as http_binding_module from services.dify_builder.native_testing.call_adapter import NativeCallAdapter, ScalarAuthorizationPort +from services.dify_builder.native_testing.control import _NativeControlBoundary from services.dify_builder.native_testing.http_adapter import ( - NativeHttpIntentPort, PreparedHttpBinding, PreparedHttpRequest, - ScalarCallPort, ) from services.dify_builder.native_testing.http_binding import ( DeferredNativeHttpClient, NativeHttpBindingProducer, NativeHttpBindingResolver, - NativeHttpPreparedObserver, ) from services.dify_builder.native_testing.operation_journal import NodeOperationJournal from services.dify_builder.native_testing.private_reads import PrivateReadOwner @@ -61,6 +59,16 @@ class ConcreteHttpCase: client: DeferredNativeHttpClient | None = None +class UnusedHttpBoundary: + """Preparation-only tests must never ask for dispatch or binding authority.""" + + def call(self, **_kwargs: object) -> NoReturn: + raise AssertionError("preparation reached scalar dispatch") + + def prepare(self, **_kwargs: object) -> NoReturn: + raise AssertionError("preparation reached intent authority") + + class NonCallableHttpCalls: call: None = None @@ -133,8 +141,8 @@ def test_offline_native_attestation_keeps_json_types_and_refuses_tamper() -> Non # Pure preparation only, with the accepted existing request owner. client = NativeHttpClient( ref=NativeTestContextRef(request_id="r", invocation_id="i", context_digest="a" * 64), - calls=cast(NativeCallAdapter, object()), - intents=cast(NativeHttpIntentPort, object()), + calls=UnusedHttpBoundary(), + intents=UnusedHttpBoundary(), max_redirect_hops=0, max_response_bytes=1048576, ) @@ -220,7 +228,9 @@ def concrete_case(monkeypatch: pytest.MonkeyPatch) -> ConcreteHttpCase: node_class=marked_native_node_class(HttpRequestNode, marked=True), runtime_state=runtime, ) - node = cast(Callable[..., HttpRequestNode], association.node_class)( + node_class = association.node_class + assert issubclass(node_class, _NativeControlBoundary) + node = node_class( native_control_owner=bridge._boundary_owner, native_control_sink=bridge._control_sink, node_id="http", @@ -233,6 +243,7 @@ def concrete_case(monkeypatch: pytest.MonkeyPatch) -> ConcreteHttpCase: tool_file_manager_factory=m.RefusingNativeHttpFiles(), file_reference_factory=m.RefusingNativeHttpFiles(), ) + assert isinstance(node, HttpRequestNode) association.attach(node) loader = OwnedNativeTestLoader( sessionmaker(), actor=Actor(tenant_id="tenant", account_id="actor"), resolver=NativeHttpBindingResolver() @@ -275,9 +286,12 @@ def concrete_case(monkeypatch: pytest.MonkeyPatch) -> ConcreteHttpCase: ) monkeypatch.setattr(m, "validate_private_read", lambda *_args: authority) journal = NodeOperationJournal(state=state, authorization=auth) - producer = m.NativeHttpBindingProducer( - association=association, loader=loader, state_store=cast(NativeExecutionStateStore, state), journal=journal - ) + store = NativeExecutionStateStore(loader) + monkeypatch.setattr(store, "load_context", state.load_context) + monkeypatch.setattr(store, "load_result", state.load_result) + monkeypatch.setattr(store, "store_result", state.store_result) + monkeypatch.setattr(store, "store_node_inputs", store_inputs) + producer = m.NativeHttpBindingProducer(association=association, loader=loader, state_store=store, journal=journal) def install(**_kw: object) -> None: events.append(("install",)) @@ -316,8 +330,8 @@ def test_actual_node_typed_input_ack_precedes_journal_and_install(concrete_case: args = http_binding_module.native_http_request(raw=c.raw, variable_pool=c.association.runtime_state.variable_pool) client = NativeHttpClient( ref=c.association.ref, - calls=cast(NativeCallAdapter, object()), - intents=cast(NativeHttpIntentPort, object()), + calls=UnusedHttpBoundary(), + intents=UnusedHttpBoundary(), max_redirect_hops=0, max_response_bytes=1048576, ) @@ -433,8 +447,8 @@ def test_disclosure_only_validated_origin_typed_lengths_and_opaque_notice(concre args = http_binding_module.native_http_request(raw=c.raw, variable_pool=c.association.runtime_state.variable_pool) request = NativeHttpClient( ref=c.association.ref, - calls=cast(NativeCallAdapter, object()), - intents=cast(NativeHttpIntentPort, object()), + calls=UnusedHttpBoundary(), + intents=UnusedHttpBoundary(), max_redirect_hops=0, max_response_bytes=1048576, )._prepare("POST", args.pop("url"), args) @@ -471,14 +485,18 @@ def test_current_classification_does_not_echo_stored_flags( assert current != NativeBinding.model_validate(owned.bindings[operation_digest(key)]) -def test_actual_wait_exports_one_call_proposal_from_scope_expiry(physical_http: ConcreteHttpCase) -> None: +def test_actual_wait_exports_one_call_proposal_from_scope_expiry( + physical_http: ConcreteHttpCase, monkeypatch: pytest.MonkeyPatch +) -> None: from datetime import UTC, datetime from runtime_contracts.builder.native.contracts import ApprovalWait from services.dify_builder.native_testing.control import NativeApprovalWait c = physical_http - c.producer.loader.load = lambda **_kw: replace(c.owned, expires_at=datetime(2030, 1, 1, tzinfo=UTC)) + monkeypatch.setattr( + c.producer.loader, "load", lambda **_kw: replace(c.owned, expires_at=datetime(2030, 1, 1, tzinfo=UTC)) + ) c.auth.decisions[0] = ApprovalWait(pending_operation_id="pending") with pytest.raises(NativeApprovalWait): invoke_native_executor(c) @@ -555,8 +573,8 @@ def test_literal_empty_text_and_scalar_json_keep_actual_native_semantics( assert args["json"] is json_value request = NativeHttpClient( ref=NativeTestContextRef(request_id="r", invocation_id="i", context_digest="a" * 64), - calls=cast(NativeCallAdapter, object()), - intents=cast(NativeHttpIntentPort, object()), + calls=UnusedHttpBoundary(), + intents=UnusedHttpBoundary(), max_redirect_hops=0, max_response_bytes=1048576, )._prepare("POST", args.pop("url"), args) @@ -587,8 +605,8 @@ def test_known_destructive_binding_retains_existing_hard_denial(concrete_case: C ) prepared = NativeHttpClient( ref=c.association.ref, - calls=cast(NativeCallAdapter, object()), - intents=cast(NativeHttpIntentPort, object()), + calls=UnusedHttpBoundary(), + intents=UnusedHttpBoundary(), max_redirect_hops=0, max_response_bytes=1048576, )._prepare("POST", arguments.pop("url"), arguments) @@ -660,8 +678,8 @@ def test_identical_request_native_occurrences_have_independent_http_confirmation request = NativeHttpClient( ref=c.association.ref, - calls=cast(NativeCallAdapter, object()), - intents=cast(NativeHttpIntentPort, object()), + calls=UnusedHttpBoundary(), + intents=UnusedHttpBoundary(), max_redirect_hops=0, max_response_bytes=1048576, )._prepare("POST", arguments.pop("url"), arguments) @@ -673,7 +691,9 @@ def test_identical_request_native_occurrences_have_independent_http_confirmation second = NativeNodePreparation( bridge=bridge, ref=c.association.ref, node_id="http", node_class=c.association.node_class, runtime_state=runtime ) - node = cast(Callable[..., HttpRequestNode], second.node_class)( + node_class = second.node_class + assert issubclass(node_class, _NativeControlBoundary) + node = node_class( native_control_owner=bridge._boundary_owner, native_control_sink=bridge._control_sink, node_id="http", @@ -688,12 +708,13 @@ def test_identical_request_native_occurrences_have_independent_http_confirmation tool_file_manager_factory=http_binding_module.RefusingNativeHttpFiles(), file_reference_factory=http_binding_module.RefusingNativeHttpFiles(), ) + assert isinstance(node, HttpRequestNode) second.attach(node) node.bind_execution_id(str(uuid4())) producer = http_binding_module.NativeHttpBindingProducer( association=second, loader=c.producer.loader, - state_store=cast(NativeExecutionStateStore, c.state), + state_store=c.producer.store, journal=c.journal, ) monkeypatch.setattr(producer, "_install", c.producer._install) @@ -773,7 +794,7 @@ def test_host_seams_use_exact_factory_owners_and_actual_prepared_binding( assert invoke_native_executor(c).text == '{"ok":true}' assert len(created) == len(produced) == len(observed) == len(called) == len(c.sent) == 1 assert set(created[0]) == {"state", "authorization", "journal", "max_result_bytes"} - assert created[0]["state"] is c.state + assert created[0]["state"] is c.producer.store assert created[0]["authorization"] is c.auth assert created[0]["journal"] is c.journal assert created[0]["max_result_bytes"] == 8388608 @@ -785,7 +806,9 @@ def test_host_seams_use_exact_factory_owners_and_actual_prepared_binding( @pytest.mark.parametrize("ack", [False, True, 0, "accepted", object()], ids=["false", "true", "zero", "text", "object"]) -def test_host_seams_prepared_observer_requires_actual_none(physical_http: ConcreteHttpCase, ack: object) -> None: +def test_host_seams_prepared_observer_requires_actual_none( + physical_http: ConcreteHttpCase, ack: object, monkeypatch: pytest.MonkeyPatch +) -> None: from services.dify_builder.native_testing.control import NativePolicyStop c = physical_http @@ -799,8 +822,9 @@ def test_host_seams_prepared_observer_requires_actual_none(physical_http: Concre producer=c.producer, authorization=c.auth, calls_factory=NativeCallAdapter, - on_prepared=cast(NativeHttpPreparedObserver, observe), + on_prepared=lambda **_kwargs: None, ) + monkeypatch.setattr(c.client, "_on_prepared", observe) with pytest.raises(NativePolicyStop) as stopped: invoke_native_executor(c) assert stopped.value.reason_code == "native_http_attestation_invalid" @@ -830,16 +854,19 @@ def test_host_seams_observer_exception_refuses_before_scalar_authority(physical_ @pytest.mark.parametrize("malformed", [None, object(), NonCallableHttpCalls()], ids=["none", "object", "no-call"]) -def test_host_seams_invalid_factory_owner_has_no_fallback(physical_http: ConcreteHttpCase, malformed: object) -> None: +def test_host_seams_invalid_factory_owner_has_no_fallback( + physical_http: ConcreteHttpCase, malformed: object, monkeypatch: pytest.MonkeyPatch +) -> None: from services.dify_builder.native_testing.control import NativePolicyStop c = physical_http c.client = http_binding_module.DeferredNativeHttpClient( producer=c.producer, authorization=c.auth, - calls_factory=lambda **_kwargs: cast(ScalarCallPort, malformed), + calls_factory=NativeCallAdapter, on_prepared=lambda **_kwargs: pytest.fail("invalid calls reached preparation"), ) + monkeypatch.setattr(c.client, "_calls_factory", lambda **_kwargs: malformed) with pytest.raises(NativePolicyStop) as stopped: invoke_native_executor(c) assert stopped.value.reason_code == "native_http_calls_unavailable" diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_lifecycle.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_lifecycle.py index 241bed4b7a3..30104483385 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_lifecycle.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_lifecycle.py @@ -1,7 +1,5 @@ """Unit boundary failures; SQL lifecycle behavior is covered in normal CI.""" -from typing import cast - import pytest from pydantic import ValidationError @@ -44,15 +42,17 @@ def test_pending_resolution_never_coerces_client_boolean() -> None: from services.dify_builder.native_testing.lifecycle import NativePendingResolution with pytest.raises(ValidationError): - NativePendingResolution( - category="configuration", - invocation_id="inv", - pending_action_id="pending", - owner_ref="owner", - reference="config", - resolved="true", - account_ref="account", - answer_result_ref=None, + NativePendingResolution.model_validate( + { + "category": "configuration", + "invocation_id": "inv", + "pending_action_id": "pending", + "owner_ref": "owner", + "reference": "config", + "resolved": "true", + "account_ref": "account", + "answer_result_ref": None, + } ) @@ -80,11 +80,10 @@ def test_payload_codec_rejects_cross_owner_and_changed_typed_payload() -> None: def test_payload_codec_snapshots_caller_mutation() -> None: from services.dify_builder.native_testing.state_store import decode_payload, encode_payload - payload = {"inputs": [True, 1, "1"]} - raw, digest = encode_payload( - tenant_id="t", request_id="r", invocation_id="i", kind="result", payload=cast(JsonValue, payload) - ) - payload["inputs"].clear() + inputs: list[JsonValue] = [True, 1, "1"] + payload: dict[str, JsonValue] = {"inputs": inputs} + raw, digest = encode_payload(tenant_id="t", request_id="r", invocation_id="i", kind="result", payload=payload) + inputs.clear() assert decode_payload(raw, tenant_id="t", request_id="r", invocation_id="i", kind="result", digest=digest) == { "inputs": [True, 1, "1"] } diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_native_execution.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_native_execution.py index 197f50abb4c..0e5232a5779 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_native_execution.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_native_execution.py @@ -2,7 +2,8 @@ from collections.abc import Callable, Generator, Iterator, Mapping from dataclasses import dataclass -from typing import NoReturn, Protocol, cast, runtime_checkable +from operator import methodcaller +from typing import NoReturn, Protocol, runtime_checkable from unittest.mock import MagicMock import pytest @@ -709,12 +710,13 @@ def test_native_owner_requires_explicit_actual_host(mocker: pytest_mock.MockerFi from services.dify_builder.native_testing.native_execution import DifyNativeWorkflowOwner with pytest.raises(NativeContextUnavailable, match="native_host_unavailable"): - DifyNativeWorkflowOwner( + methodcaller( + "__call__", session_factory=sessionmaker(), runtime=native_host_fixture(ResolvedNativeTestContext.model_validate(CONTEXT), mocker)[1], stream_owner=_StreamResultPort(), - native_host=cast(NativeHostLaunch, invalid_host), - ) + native_host=invalid_host, + )(DifyNativeWorkflowOwner) def test_native_owner_rejects_foreign_host_before_sql_or_generator(mocker: pytest_mock.MockerFixture) -> None: diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_native_observation_wiring.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_native_observation_wiring.py index d6e21f39214..fd2f4474895 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_native_observation_wiring.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_native_observation_wiring.py @@ -13,19 +13,20 @@ import json import logging import sys from _io import StringIO -from collections.abc import Callable, Iterator, Mapping, Sequence +from collections.abc import Callable, Iterator, Mapping from concurrent.futures import ThreadPoolExecutor from contextlib import AbstractContextManager from contextvars import ContextVar from threading import Thread, current_thread from time import monotonic from types import ModuleType -from typing import NoReturn, Protocol, Self, TypedDict, cast, override +from typing import NoReturn, Protocol, Self, TypedDict, override import pytest import pytest_mock from flask import Flask, current_app, has_app_context from httpx._models import Response +from pydantic import TypeAdapter from sqlalchemy.orm import Session from context.flask_app_context import capture_flask_context @@ -37,7 +38,7 @@ from graphon.engine_events.base import EngineEvent from graphon.entities.base_node_data import BaseNodeData from graphon.entities.graph_config import NodeConfigDict from graphon.enums import NodeExecutionType -from graphon.file import File +from graphon.file import File, FileTransferMethod, FileType from graphon.nodes.base.node import Node from graphon.runtime import RuntimeState, VariablePool from libs import sensitive_http_telemetry as telemetry @@ -266,13 +267,17 @@ def test_initial_preflight_refuses_unqualified_routes_before_worker( if feature == "tts": arguments["workflow"].features = json.dumps({"text_to_speech": {"enabled": True}}) elif feature == "files": - entity.files = cast(Sequence[File], [object()]) + entity.files = [ + File( + file_type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.REMOTE_URL, + remote_url="https://example.test/input.txt", + ) + ] elif feature == "trace": entity.extras["external_trace_id"] = SENTINEL else: - entity.single_iteration_run = cast( - WorkflowAppGenerateEntity.SingleIterationRunEntity, {"node_id": SENTINEL, "inputs": {}} - ) + entity.single_iteration_run = WorkflowAppGenerateEntity.SingleIterationRunEntity(node_id=SENTINEL, inputs={}) monkeypatch.setattr( generator, "_generate_worker", lambda **_kwargs: pytest.fail("worker launched before preflight") ) @@ -335,7 +340,7 @@ def supported_graph(kind: str) -> _SupportedGraph: """Only the profile's actual Start/HTTP/End and Loop/Iteration classes.""" from copy import deepcopy - graph = cast(_SupportedGraph, deepcopy(sample_context().graph)) + graph = TypeAdapter(_SupportedGraph).validate_python(deepcopy(sample_context().graph)) if kind == "root": return graph http = next(n for n in graph["nodes"] if n["id"] == "http") @@ -388,9 +393,7 @@ def supported_engine( from core.workflow.node_runtime import DifyFileReferenceFactory from graphon.engine import Engine from graphon.engine.command import InMemoryChannel - from graphon.entities.base_node_data import BaseNodeData from graphon.graph import Graph - from graphon.nodes.base.node import Node from graphon.nodes.end.end_node import EndNode from graphon.nodes.http_request.node import HttpRequestNode from graphon.nodes.iteration.iteration_node import IterationNode @@ -403,6 +406,7 @@ def supported_engine( from services.dify_builder.native_testing.control import ( BoundaryOwnerState, NativeControlBridge, + _NativeControlBoundary, marked_native_node_class, ) from services.dify_builder.native_testing.http_binding import ( @@ -498,6 +502,7 @@ def supported_engine( raw_data = node_config["data"].model_dump(mode="python", exclude_unset=True) original = next(cls for cls in originals if cls.node_type == raw_data["type"]) cls = marked_native_node_class(original, marked=True) + assert issubclass(cls, _NativeControlBoundary) kwargs: dict[str, object] = {} if original is HttpRequestNode: kwargs = { @@ -507,9 +512,9 @@ def supported_engine( "tool_file_manager_factory": lambda: None, "file_reference_factory": file_references, } - return cast(Callable[..., Node[BaseNodeData]], cls)( + node = cls( node_id=node_config["id"], - data=cls.validate_node_data(raw_data), + data=original.validate_node_data(raw_data), init_params=InitParams( workflow_id="supported-native", graph_config=graph, run_context={}, call_depth=0 ), @@ -518,6 +523,8 @@ def supported_engine( native_control_sink=sink, **kwargs, ) + assert isinstance(node, Node) + return node actual = Graph.init(graph_config=graph, node_factory=Factory(runtime), root_node_id="start") engine = Engine( @@ -606,10 +613,11 @@ def test_actual_initial_entry_rejects_persisted_profile_before_input_effect( case: ObservationOwnerCase, monkeypatch: pytest.MonkeyPatch, mocker: pytest_mock.MockerFixture ) -> None: - from core.app.entities.app_invoke_entities import InvokeFrom + from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from models.account import Account from models.model import App from models.workflow import Workflow + from repositories.entities.workflow_execution import WorkflowExecutionIdentity from services.dify_builder.native_testing.runtime_bridge import NativeRuntimeBridge from services.workflow_runtime.workflow_generator import WorkflowAppGenerator @@ -640,6 +648,7 @@ def test_actual_initial_entry_rejects_persisted_profile_before_input_effect( account.id = context.actor_id with pytest.raises(NativeObservationError, match="^native_observation_failed$"): WorkflowAppGenerator(execution_driver=WorkflowRunAgg.run).generate( + execution_identity=WorkflowExecutionIdentity(user_id=account.id, user_from=UserFrom.ACCOUNT), app_model=App(id=context.app_id, tenant_id=context.tenant_id), workflow=Workflow(id=context.workflow_id, graph=json.dumps(context.graph), _features="{}"), user=account, @@ -898,15 +907,17 @@ def test_actual_worker_replaces_raw_error_before_queue_error_model_and_logging( def run(self) -> NoReturn: raise errors[0] + def execute_runner(runner: object, *_args: object) -> NoReturn: + assert isinstance(runner, RunnerPort) + runner.run() + monkeypatch.setattr(module, "WorkflowAppRunner", RunnerPort) monkeypatch.setattr(module, "active_workflow_task", lambda _task: nullcontext()) monkeypatch.setattr(queue, "publish_error", publish) app = Flask(__name__) with app.app_context(): worker = Thread( - target=module.WorkflowAppGenerator( - execution_driver=lambda runner, *_args: cast(RunnerPort, runner).run() - )._generate_worker, + target=module.WorkflowAppGenerator(execution_driver=execute_runner)._generate_worker, kwargs={ "flask_app": app, "application_generate_entity": entity, diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_native_resume_scheduling.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_native_resume_scheduling.py index a058d9b18e9..fe2de13bf64 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_native_resume_scheduling.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_native_resume_scheduling.py @@ -13,7 +13,7 @@ from copy import deepcopy from datetime import UTC, datetime from threading import Barrier, Thread, current_thread from time import monotonic -from typing import cast, override +from typing import override import httpx import pytest @@ -62,7 +62,6 @@ from services.dify_builder.native_testing.runtime import ( DifyNativeRuntime, build_delivery_bridge, ) -from services.dify_builder.native_testing.state_store import NativeExecutionStateStore from services.workflow_runtime.entry import WorkflowEntry from services.workflow_runtime.node_factory import DifyGraphInitContext, DifyNodeFactory from tests.unit_tests.services.dify_builder.native_testing.test_call_adapter import REF, Authorization, State @@ -180,7 +179,20 @@ def make_host( *, snapshot: NativeCheckpointPayload | None = None, ) -> SchedulingHostCase: - journal = NodeOperationJournal(state=state, authorization=authorization) + api = _api() + monkeypatch.setattr(api.loader, "load", lambda **_: scheduling_owner(state)) + store = api.store + monkeypatch.setattr(store, "load_context", state.load_context) + monkeypatch.setattr(store, "store_node_inputs", state.store_node_inputs) + monkeypatch.setattr(store, "store_result", state.store_result) + monkeypatch.setattr(store, "load_result", state.load_result) + monkeypatch.setattr(store, "read_checkpoint", state.read_checkpoint) + monkeypatch.setattr(store, "commit_checkpoint", state.commit_checkpoint) + authority = EffectAuthorizationService(api.loader) + monkeypatch.setattr(authority, "before_call", authorization.before_call) + monkeypatch.setattr(authority, "claim_dispatch", authorization.claim_dispatch) + monkeypatch.setattr(authority, "record_outcome", authorization.record_outcome) + journal = NodeOperationJournal(state=store, authorization=authority) if snapshot is not None: state.payload = snapshot journal.restore(ref=REF, checkpoint=CheckpointRef(checkpoint_id="checkpoint", payload_digest="b" * 64)) @@ -198,10 +210,10 @@ def make_host( dispatch_id=dispatch_id, lifetime=lifetime, journal=journal, - checkpoints=NativeCheckpointBridge(state=state, lifecycle=NoCheckpointLifecycle()), + checkpoints=NativeCheckpointBridge(state=store, lifecycle=NoCheckpointLifecycle()), fence_pending=ports.fence_pending, stop_sealer=ports, - authorization=authorization, + authorization=authority, http_baseline=(timeout.connect, timeout.read, timeout.write), ) case = SchedulingHostCase(state=state, host=host, lifetime=lifetime, registry=registry, ports=ports) @@ -223,11 +235,9 @@ def make_host( else NativeGraphRuntimeState.from_snapshot(snapshot.graph_runtime_state_json) ) runtime.variable_pool.add(("start", "items"), ["one", "two"]) - api = _api() - monkeypatch.setattr(api.loader, "load", lambda **_: scheduling_owner(state)) lifecycle = NativeTestLifecycleService( api.loader, - state_store=cast(NativeExecutionStateStore, state), + state_store=store, resume_settlement=DifyNativeResumeSettlement(), ) delivery_runtime = DifyNativeRuntime( @@ -236,7 +246,7 @@ def make_host( registry=registry, ) delivery_runtime.host = host - delivery_runtime.authorization = cast(EffectAuthorizationService, authorization) + delivery_runtime.authorization = authority delivery_runtime._journal = journal bridge = build_delivery_bridge(runtime=delivery_runtime) run_context = DifyRunContext( diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_native_runtime.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_native_runtime.py index 0486607b042..5f2d2ff00e2 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_native_runtime.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_native_runtime.py @@ -3,16 +3,16 @@ from __future__ import annotations import json -from collections.abc import Callable, Generator, Mapping, Sequence +from collections.abc import Callable, Generator, Mapping from contextlib import contextmanager from dataclasses import dataclass from datetime import UTC, datetime from threading import Thread, local -from typing import TYPE_CHECKING, Concatenate, Literal, NoReturn, Self, Unpack, cast, override +from typing import TYPE_CHECKING, Concatenate, Literal, NoReturn, Self, Unpack, override import pytest from httpx._models import Response -from sqlalchemy import ColumnDefault, Select, inspect +from sqlalchemy import ColumnDefault, Select, inspect, select from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.sql.elements import BinaryExpression, ColumnClause from typing_extensions import TypedDict @@ -50,7 +50,7 @@ from runtime_contracts.builder.native.runtime_contracts import ( ) from services.dify_builder.native_testing.host_execution import _HostObservationLayer from services.dify_builder.native_testing.http_binding import NativeHttpBindingResolver -from services.dify_builder.native_testing.lifecycle import NativeRuntimePort, NativeTestLifecycleService +from services.dify_builder.native_testing.lifecycle import NativeTestLifecycleService from services.dify_builder.native_testing.repository import OwnedNativeTest, OwnedNativeTestLoader from services.dify_builder.native_testing.runtime import DifyNativeRuntime, DifyNativeRuntimeOwner from services.dify_builder.native_testing.runtime_bridge import NativeRuntimeBridge @@ -58,10 +58,13 @@ from services.dify_builder.native_testing.state_store import NativeExecutionStat from tests.unit_tests.services.dify_builder.native_testing.test_host_capacity import Rows from tests.unit_tests.services.dify_builder.native_testing.test_observation_owner import ObservationOwnerCase, case from tests.unit_tests.services.dify_builder.native_testing.test_observation_privacy import context as sample_context +from tests.unit_tests.services.dify_builder.native_testing.typed_boundaries import ( + checked_row, + checked_rows, + sql_session_factory, +) if TYPE_CHECKING: - from contextlib import AbstractContextManager - from services.dify_builder.native_testing.api_service import NativeTestApiService from services.dify_builder.native_testing.host_execution import NativeHostLaunch from services.dify_builder.native_testing.public_contracts import NativeTestPublicReceipt @@ -126,7 +129,7 @@ class CancellationCase: owner: DifyNativeRuntimeOwner published: list[dict[str, JsonValue]] sql: SQL - sessions: Callable[[], AbstractContextManager[SQL]] + sessions: sessionmaker[Session] actor: Actor started: NativeTestPublicReceipt rows: dict[type[object], list[object]] @@ -167,6 +170,12 @@ def test_initial_dispatch_uses_no_checkpoint_and_duplicate_delivery_never_runs_a del kwargs pytest.fail("initial delivery must not restore a checkpoint") + def cancel(self, **_kwargs: object) -> NoReturn: + pytest.fail("initial delivery must not cancel a run") + + def recovery_evidence(self, **_kwargs: object) -> NoReturn: + pytest.fail("initial delivery must not inspect recovery") + class Store: def load_context(self, *, ref: NativeTestContextRef) -> ResolvedNativeTestContext: assert ref == context.ref @@ -181,17 +190,19 @@ def test_initial_dispatch_uses_no_checkpoint_and_duplicate_delivery_never_runs_a del kwargs yield None, None, None + factory = sessionmaker() + monkeypatch.setattr(factory, "class_", lambda **_kwargs: pytest.fail("initial checkpoint SQL")) loader = OwnedNativeTestLoader( - cast(sessionmaker[Session], lambda: pytest.fail("initial checkpoint SQL")), + factory, actor=Actor(tenant_id=context.tenant_id, account_id=context.actor_id), resolver=NativeHttpBindingResolver(), ) monkeypatch.setattr(loader, "locked", locked) - lifecycle = NativeTestLifecycleService( - loader, - state_store=cast(NativeExecutionStateStore, Store()), - runtime=cast(NativeRuntimePort, Runtime()), - ) + state_store = NativeExecutionStateStore(loader) + boundary_store = Store() + monkeypatch.setattr(state_store, "load_context", boundary_store.load_context) + monkeypatch.setattr(state_store, "read_checkpoint", boundary_store.read_checkpoint) + lifecycle = NativeTestLifecycleService(loader, state_store=state_store, runtime=Runtime()) def claim(**kwargs: object) -> bool: del kwargs @@ -353,13 +364,17 @@ def test_actual_wait_snapshot_restores_exact_objects_journal_and_generation_gap( state, auth = initial.case.state, initial.case.authorization state.context = state.context.model_copy(update={"generation": generation}) loader = OwnedNativeTestLoader( - cast(sessionmaker[Session], initial.sessions), + initial.sessions, actor=Actor(tenant_id=state.context.tenant_id, account_id=state.context.actor_id), resolver=NativeHttpBindingResolver(), ) + state_store = NativeExecutionStateStore(loader) + monkeypatch.setattr(state_store, "load_context", state.load_context) + monkeypatch.setattr(state_store, "load_result", state.load_result) + monkeypatch.setattr(state_store, "store_result", state.store_result) lifecycle = NativeTestLifecycleService( loader, - state_store=cast(NativeExecutionStateStore, state), + state_store=state_store, resume_settlement=DifyNativeResumeSettlement(), ) checkpoint = CheckpointRef(checkpoint_id="checkpoint", payload_digest="c" * 64) @@ -375,7 +390,8 @@ def test_actual_wait_snapshot_restores_exact_objects_journal_and_generation_gap( return payload.model_copy(deep=True) monkeypatch.setattr(state, "read_checkpoint", read_checkpoint) - journal = NodeOperationJournal(state=state, authorization=auth) + monkeypatch.setattr(state_store, "read_checkpoint", read_checkpoint) + journal = NodeOperationJournal(state=state_store, authorization=auth) life = NativeHostLifetime(context=state.context, dispatch_id="resume-delivery") initial.case.registry.activate(lifetime=life) host = NativeHostLaunch( @@ -383,7 +399,7 @@ def test_actual_wait_snapshot_restores_exact_objects_journal_and_generation_gap( dispatch_id=life.dispatch_id, lifetime=life, journal=journal, - checkpoints=NativeCheckpointBridge(state=state, lifecycle=lifecycle), + checkpoints=NativeCheckpointBridge(state=state_store, lifecycle=lifecycle), fence_pending=initial.case.ports.fence_pending, stop_sealer=initial.case.ports, authorization=auth, @@ -420,7 +436,7 @@ def test_actual_stream_cleanup_returns_without_signalling_retired_host(monkeypat c = StreamCase(monkeypatch) c.start() owner = DifyNativeWorkflowOwner( - session_factory=cast(sessionmaker[Session], c.sessions), + session_factory=c.sessions, runtime=runtime_for(c.case), stream_owner=c.owner(), native_host=c.case.host, @@ -581,7 +597,7 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run( from services.dify_builder.native_testing.lifecycle import NativeDispatchRef from services.dify_builder.native_testing.runtime import DifyNativeResumeSettlement, DifyNativeRuntime from services.workflow_runtime import workflow_generator as generator_module - from tests.unit_tests.services.dify_builder.native_testing.test_host_capacity import SQL, Rows + from tests.unit_tests.services.dify_builder.native_testing.test_host_capacity import SQL from tests.unit_tests.services.dify_builder.native_testing.test_native_observation_wiring import protect_logs protect_logs(monkeypatch) @@ -716,7 +732,7 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run( }, deep=True, ) - cast(DifyBuilderTestInput, case.rows[DifyBuilderTestInput][0]).inputs = inputs + checked_row(DifyBuilderTestInput, case.rows[DifyBuilderTestInput][0]).inputs = inputs case.rebind() context = case.context workflow = Workflow( @@ -737,11 +753,9 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run( ) account = Account(name="Component", email="component@example.invalid") account.id, account.status = context.actor_id, AccountStatus.ACTIVE - case.rows[Workflow], case.rows[App], case.rows[Account] = ( - cast(list[object], [workflow]), - cast(list[object], [app]), - cast(list[object], [account]), - ) + case.rows[Workflow] = [workflow] + case.rows[App] = [app] + case.rows[Account] = [account] class HistorySQL(SQL): """Actual SQL-repository protocol boundary, including pre-cache owner reads.""" @@ -770,9 +784,11 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run( def merge(self, row: WorkflowRun | WorkflowNodeExecutionModel) -> WorkflowRun | WorkflowNodeExecutionModel: previous = self.get(type(row), row.id) + rows = self.rows.setdefault(type(row), []) + assert isinstance(rows, list) if previous is not None: - self.rows[type(row)].remove(previous) - self.rows.setdefault(type(row), []).append(row) + rows.remove(previous) + rows.append(row) return row def commit(self) -> None: @@ -785,12 +801,25 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run( pass @override - def execute[T: tuple[object, ...]](self, _query: Select[T]) -> Rows[T]: + def execute(self, _query: Select[tuple[object, ...]]) -> Rows[object]: # This labeled boundary returns its scalar-owner rows to .scalars(). - values = self.scalars(cast(Select[tuple[object]], _query)) - return cast(Rows[T], HistoryQueryRows(values)) + scalar_query = select(_query.column_descriptions[0]["expr"]).where(*_query._where_criteria) + values = self.scalars(scalar_query) + return HistoryQueryRows(values) history = HistorySQL(case.rows, []) + history_factory = sql_session_factory( + monkeypatch, + history, + history.begin, + methods=lambda boundary: { + **boundary.sql_methods(), + "merge": boundary.merge, + "commit": boundary.commit, + "close": boundary.close, + "expunge_all": boundary.expunge_all, + }, + ) # The reused single-reader fixture's global SQL depth is unsuitable for # actual concurrent workers. This boundary retains per-thread no-IO-in-SQL # assertions; it does not replace the production ownership reads. @@ -805,7 +834,9 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run( finally: sql_depth.value -= 1 - case.loader.session_factory = cast(sessionmaker[Session], concurrent_sessions) + case.loader.session_factory = sql_session_factory( + monkeypatch, concurrent_sessions, concurrent_sessions, methods=SQL.sql_methods + ) if history_fault is not None and (history_fault.startswith("redirect") or history_fault == "initial-stop"): monkeypatch.setattr(concurrent_sessions, "begin", concurrent_sessions, raising=False) @@ -818,7 +849,7 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run( with concurrent_sessions() as session: from models.dify_builder_native_tests import NativeTestScope - yield session, owned, cast(NativeTestScope, case.rows[NativeTestScope][0]) + yield session, owned, checked_row(NativeTestScope, case.rows[NativeTestScope][0]) monkeypatch.setattr(case.loader, "locked", stop_writer) case.local.state = "running" @@ -828,7 +859,7 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run( assert sql_depth.value == 0 assert actor == case.loader.actor assert app_id == context.app_id - assert sessions is concurrent_sessions + assert sessions is case.loader.session_factory for permission_path in ( "services.dify_builder.execution_policy_service.check_execution_permission", @@ -857,7 +888,7 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run( def initialize(repo: R, session_factory: sessionmaker[Session], *args: P.args, **kwargs: P.kwargs) -> None: del session_factory original(repo, sessionmaker(), *args, **kwargs) - repo._session_factory = cast(sessionmaker[Session], history) + repo._session_factory = history_factory return initialize @@ -881,7 +912,7 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run( context.tenant_id, account, context.app_id, - cast(WorkflowNodeExecutionTriggeredFrom, kwargs["triggered_from"]), + checked_row(WorkflowNodeExecutionTriggeredFrom, kwargs["triggered_from"]), ) if node else SQLAlchemyWorkflowExecutionRepository( @@ -889,10 +920,10 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run( context.tenant_id, account, context.app_id, - cast(WorkflowRunTriggeredFrom, kwargs["triggered_from"]), + checked_row(WorkflowRunTriggeredFrom, kwargs["triggered_from"]), ) ) - repo._session_factory = cast(sessionmaker[Session], history) + repo._session_factory = history_factory return repo monkeypatch.setattr(DifyCoreRepositoryFactory, "create_workflow_execution_repository", repositories) @@ -1084,15 +1115,15 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run( elif history_fault == "redirect-generation": case.local.generation += 1 elif history_fault == "redirect-dispatch": - cast(NativeDispatch, case.rows[NativeDispatch][0]).state = "unknown" + checked_row(NativeDispatch, case.rows[NativeDispatch][0]).state = "unknown" elif history_fault == "redirect-cancelled": - cast(NativeTestScope, case.rows[NativeTestScope][0]).cancelled = True + checked_row(NativeTestScope, case.rows[NativeTestScope][0]).cancelled = True elif history_fault == "redirect-ambiguous": raise RuntimeError("seal_commit_ack_lost") elif history_fault == "redirect-unacknowledged": return object() elif history_fault == "redirect-checkpoint": - cast(NativeContinuation, case.rows[NativeContinuation][0]).payload_digest = "d" * 64 + checked_row(NativeContinuation, case.rows[NativeContinuation][0]).payload_digest = "d" * 64 elif history_fault == "redirect-run": runtime.host.lifetime.context.__dict__["native_run_id"] = "foreign-run" return result @@ -1181,9 +1212,9 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run( assert waiting.status == "stopped" assert waiting.reason_code == "effect_denied" assert case.request.state == "sealed" - assert not cast(NativeTestInvocation, case.rows[NativeTestInvocation][0]).active + assert not checked_row(NativeTestInvocation, case.rows[NativeTestInvocation][0]).active assert initial.host.lifetime._consumed - assert cast(WorkflowRun, case.rows[WorkflowRun][0]).status == "stopped" + assert checked_row(WorkflowRun, case.rows[WorkflowRun][0]).status == "stopped" assert sends == [] assert thread_errors == [] return @@ -1193,8 +1224,8 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run( assert len(payload.node_journals) == len(payload.approval_proposals) assert 1 <= len(payload.node_journals) <= (2 if kind == "iteration" else 1) assert all(journal.intents for journal in payload.node_journals) - assert cast(WorkflowRun, case.rows[WorkflowRun][0]).status == "paused" - original_started = cast(WorkflowRun, case.rows[WorkflowRun][0]).created_at + assert checked_row(WorkflowRun, case.rows[WorkflowRun][0]).status == "paused" + original_started = checked_row(WorkflowRun, case.rows[WorkflowRun][0]).created_at assert initial.host.lifetime._consumed case.local.generation = 1 case.context = case.store.load_context(ref=context.ref) @@ -1207,7 +1238,7 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run( if history_fault == "missing": case.rows[WorkflowRun].clear() else: - cast(WorkflowRun, case.rows[WorkflowRun][0]).app_id = "foreign-app" + checked_row(WorkflowRun, case.rows[WorkflowRun][0]).app_id = "foreign-app" with pytest.raises(NativeObservationError, match="native_observation_failed"): resumed.resume(context=context, checkpoint=checkpoints["payload"], resolutions=()) assert sends == [] @@ -1246,7 +1277,7 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run( assert not transition_errors, transition_errors assert len(sends) == 1, (result, resumed.host.boundary_owner.status) assert case.request.state == "sealed", (result, resumed.host.boundary_owner.records) - assert not cast(NativeTestInvocation, case.rows[NativeTestInvocation][0]).active + assert not checked_row(NativeTestInvocation, case.rows[NativeTestInvocation][0]).active with pytest.raises(BuilderExecutionPolicyError, match="native_invocation_mismatch"): case.store.load_context(ref=context.ref) assert result.status == "stopped" @@ -1255,7 +1286,7 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run( assert not resumed.host.lifetime.unknown assert resumed.host.lifetime._consumed assert len(sends) == 1 - assert cast(WorkflowRun, case.rows[WorkflowRun][0]).status == "stopped" + assert checked_row(WorkflowRun, case.rows[WorkflowRun][0]).status == "stopped" assert thread_errors == [] return assert result.status == "completed" @@ -1264,14 +1295,14 @@ def test_real_dify_initial_http_wait_and_claimed_resume_keep_same_run( assert result.native_run_id == waiting.native_run_id assert len(sends) == (2 if kind == "iteration" else 1) assert len(case.rows[WorkflowRun]) == 1 - assert cast(WorkflowRun, case.rows[WorkflowRun][0]).created_at == original_started - assert cast(WorkflowRun, case.rows[WorkflowRun][0]).status == "succeeded" + assert checked_row(WorkflowRun, case.rows[WorkflowRun][0]).created_at == original_started + assert checked_row(WorkflowRun, case.rows[WorkflowRun][0]).status == "succeeded" if kind != "root": from models.workflow import WorkflowNodeExecutionModel assert checked_live_phase assert normalized - rows = cast(list[WorkflowNodeExecutionModel], case.rows[WorkflowNodeExecutionModel]) + rows = checked_rows(WorkflowNodeExecutionModel, case.rows[WorkflowNodeExecutionModel]) assert any(row.node_id == "http" and row.execution_metadata_dict.get(kind + "_id") for row in rows) @@ -1288,9 +1319,9 @@ def install_owner_join_boundary(case: ObservationOwnerCase, monkeypatch: pytest. if query.column_descriptions[0]["entity"] is DifyBuilderSession and query._setup_joins: # This SQL boundary double does not implement joins. Check every # modeled joined owner before returning its selected session ID. - builder = cast(DifyBuilderSession, case.rows[DifyBuilderSession][0]) - app, workflow = cast(App, case.rows[App][0]), cast(Workflow, case.rows[Workflow][0]) - inputs = cast(DifyBuilderTestInput, case.rows[DifyBuilderTestInput][0]) + builder = checked_row(DifyBuilderSession, case.rows[DifyBuilderSession][0]) + app, workflow = checked_row(App, case.rows[App][0]), checked_row(Workflow, case.rows[Workflow][0]) + inputs = checked_row(DifyBuilderTestInput, case.rows[DifyBuilderTestInput][0]) assert (builder.id, builder.tenant_id, builder.owner_account_id, builder.app_id) == ( case.request.session_id, context.tenant_id, @@ -1434,7 +1465,7 @@ def test_same_worker_reclaims_over_128_closed_consumed_requests_but_keeps_unknow def protected_read(row: NativePayloadObject | ObjectReceipt) -> dict[str, object]: original_read( - cast(NativePayloadObject, case.rows[NativePayloadObject][0]) + checked_row(NativePayloadObject, case.rows[NativePayloadObject][0]) ) # Preserve fixture's outside-SQL assertion. return deepcopy(objects[row.id]) if row.id in objects else case.context.model_dump(mode="json") @@ -1598,11 +1629,11 @@ def cancel_composition(case: ObservationOwnerCase, monkeypatch: pytest.MonkeyPat created_by=original.actor_id, ) case.rows[Workflow] = [workflow] - cast(App, case.rows[App][0]).mode = AppMode.WORKFLOW - builder = cast(DifyBuilderSession, case.rows[DifyBuilderSession][0]) + checked_row(App, case.rows[App][0]).mode = AppMode.WORKFLOW + builder = checked_row(DifyBuilderSession, case.rows[DifyBuilderSession][0]) builder.version = 1 builder.current_state = "build.review" - cast(DifyBuilderTestInput, case.rows[DifyBuilderTestInput][0]).start_schema_hash = schema_hash( + checked_row(DifyBuilderTestInput, case.rows[DifyBuilderTestInput][0]).start_schema_hash = schema_hash( start_schema(dict(workflow.graph_dict), "workflow") ) case.rows[DifyBuilderSessionCommit] = [ @@ -1612,7 +1643,7 @@ def cancel_composition(case: ObservationOwnerCase, monkeypatch: pytest.MonkeyPat version=builder.version, state=builder.current_state, actor="user", - context={"test_input_ref": cast(DifyBuilderTestInput, case.rows[DifyBuilderTestInput][0]).id}, + context={"test_input_ref": checked_row(DifyBuilderTestInput, case.rows[DifyBuilderTestInput][0]).id}, ) ] for model in tuple(case.rows): @@ -1625,30 +1656,16 @@ def cancel_composition(case: ObservationOwnerCase, monkeypatch: pytest.MonkeyPat def get[T](self, model: type[T], identity: object) -> T | None: assert issubclass(model, (Base, TypeBase)) key = next(iter(inspect(model).primary_key)).key - if key == "id": - return cast( - T | None, - next( - ( - row - for row in self.rows.get(model, []) - if inspect(row, raiseerr=True).attrs["id"].value == identity - ), - None, - ), - ) - if key == "request_id": - return cast( - T | None, - next( - ( - row - for row in self.rows.get(model, []) - if inspect(row, raiseerr=True).attrs["request_id"].value == identity - ), - None, + if key in {"id", "request_id"}: + row = next( + ( + row + for row in self.rows.get(model, []) + if inspect(row, raiseerr=True).attrs[key].value == identity ), + None, ) + return checked_row(model, row) if row is not None else None raise AssertionError("unsupported SQL primary key") @override @@ -1659,14 +1676,16 @@ def cancel_composition(case: ObservationOwnerCase, monkeypatch: pytest.MonkeyPat assert isinstance(column.default, ColumnDefault) value = column.default.arg setattr(row, column.key, value(None) if callable(value) else deepcopy(value)) - self.rows.setdefault(type(row), []).append(row) + rows = self.rows.setdefault(type(row), []) + assert isinstance(rows, list) + rows.append(row) @override def flush(self) -> None: pass @override - def execute[T: tuple[object, ...]](self, _query: Select[T]) -> Rows[T]: + def execute(self, _query: Select[tuple[object, ...]]) -> Rows[object]: query = _query # Reconcile's expired-claim query returns tuples. No claim in this # boundary is expired; cancellation scans still use actual scalars. @@ -1674,7 +1693,8 @@ def cancel_composition(case: ObservationOwnerCase, monkeypatch: pytest.MonkeyPat from models.dify_builder import DifyBuilderExecutionRequest from services.dify_builder.native_testing.repository import utc_now - dispatches = list(self.scalars(cast(Select[tuple[NativeDispatch]], query))) + dispatch_query = select(NativeDispatch).where(*query._where_criteria) + dispatches = list(self.scalars(dispatch_query)) expiring = any( isinstance(clause, BinaryExpression) and isinstance(clause.left, ColumnClause) @@ -1688,12 +1708,12 @@ def cancel_composition(case: ObservationOwnerCase, monkeypatch: pytest.MonkeyPat for row in dispatches if row.claim_expires_at is not None and row.claim_expires_at <= utc_now() ] - result = Rows() + result: Rows[object] = Rows() for row in dispatches: request = self.get(DifyBuilderExecutionRequest, row.request_id) assert request is not None result.append((row, request.context_digest)) - return cast(Rows[T], result) + return result return super().execute(query) sql = MutableSQL(case.rows, []) @@ -1711,7 +1731,8 @@ def cancel_composition(case: ObservationOwnerCase, monkeypatch: pytest.MonkeyPat finally: depth.writers -= 1 - sessions = Sessions() + boundary = Sessions() + sessions = sql_session_factory(monkeypatch, boundary, boundary.begin, methods=SQL.sql_methods) actor = Actor(account_id=original.actor_id, tenant_id=original.tenant_id) def permission(actual: Actor, app_id: str, source: object) -> None: @@ -1775,7 +1796,7 @@ def cancel_composition(case: ObservationOwnerCase, monkeypatch: pytest.MonkeyPat def joined[T](sql: MutableSQL, query: Select[tuple[T]]) -> T | str | None: if query.column_descriptions[0]["entity"] is DifyBuilderSession and query._setup_joins: - builder = cast(DifyBuilderSession, case.rows[DifyBuilderSession][0]) + builder = checked_row(DifyBuilderSession, case.rows[DifyBuilderSession][0]) assert (builder.tenant_id, builder.owner_account_id, builder.app_id) == ( actor.tenant_id, actor.account_id, @@ -1826,8 +1847,8 @@ def test_composed_start_cancel_before_delivery_reconciles_without_physical_acqui assert result.cancellation is not None assert result.cancellation.status == "cancelled" assert result.status == "terminal" - assert cast(NativeInvocationState, c.rows[NativeInvocationState][0]).cleanup_complete - assert cast(NativeDispatch, c.rows[NativeDispatch][0]).state == "cancelled" + assert checked_row(NativeInvocationState, c.rows[NativeInvocationState][0]).cleanup_complete + assert checked_row(NativeDispatch, c.rows[NativeDispatch][0]).state == "cancelled" assert c.api.lifecycle.reconcile() == 0 message = c.published[0] ref = decode_native_ref(message["ref"]) @@ -1861,7 +1882,7 @@ def test_consumed_generation_zero_in_other_owner_cannot_ack_live_generation_one( source = c.owner.delivery(sessions=c.sessions, delivery=NativeDispatchRef(ref, 0, dispatch_identity(message))) old = source.runtime assert isinstance(old, module.DifyNativeRuntime) - dispatch = cast(NativeDispatch, c.rows[NativeDispatch][0]) + dispatch = checked_row(NativeDispatch, c.rows[NativeDispatch][0]) assert isinstance(dispatch, NativeDispatch) dispatch.state = "claimed" # SQL claim ACK boundary, before actual activation. @@ -1875,9 +1896,9 @@ def test_consumed_generation_zero_in_other_owner_cannot_ack_live_generation_one( old.run_initial(context=old.context) assert old.host.lifetime._consumed dispatch.state = "completed" - state = cast(NativeInvocationState, c.rows[NativeInvocationState][0]) + state = checked_row(NativeInvocationState, c.rows[NativeInvocationState][0]) state.generation, state.cleanup_complete = 1, False - cast(NativeTestScope, c.rows[NativeTestScope][0]).generation = 1 + checked_row(NativeTestScope, c.rows[NativeTestScope][0]).generation = 1 resumed = NativeDispatch( id="resumed-delivery", request_id=ref.request_id, @@ -1945,7 +1966,7 @@ def test_cancel_original_joined_owner_rechecks_retained_snapshot( replacement = module.DifyNativeRuntimeOwner().delivery(sessions=c.sessions, delivery=delivery).runtime assert isinstance(runtime, module.DifyNativeRuntime) assert isinstance(replacement, module.DifyNativeRuntime) - cast(NativeDispatch, c.rows[NativeDispatch][0]).state = "claimed" # SQL claim boundary. + checked_row(NativeDispatch, c.rows[NativeDispatch][0]).state = "claimed" # SQL claim boundary. runtime._start(runtime.context) worker = Thread(target=lambda: runtime.host.lifetime.thread_finished(thread=current_thread())) runtime.host.lifetime.register_thread(thread=worker, role="worker") @@ -1972,7 +1993,7 @@ def test_cancel_original_joined_owner_rechecks_retained_snapshot( # so truthful cancellation now waits for source finalization in both races. assert result.cancellation is not None assert result.cancellation.status == "cancelling" - assert cast(NativeInvocationState, c.rows[NativeInvocationState][0]).cleanup_complete is ( + assert checked_row(NativeInvocationState, c.rows[NativeInvocationState][0]).cleanup_complete is ( not replace_after_settlement ) assert runtime.host.lifetime._consumed is (not replace_after_settlement) @@ -1992,8 +2013,8 @@ def test_composed_cancel_does_not_upgrade_incomplete_or_noninitial_history( c = cancel_composition dispatch, state = ( - cast(NativeDispatch, c.rows[NativeDispatch][0]), - cast(NativeInvocationState, c.rows[NativeInvocationState][0]), + checked_row(NativeDispatch, c.rows[NativeDispatch][0]), + checked_row(NativeInvocationState, c.rows[NativeInvocationState][0]), ) if history == "missing-outbox": c.rows[NativeDispatch].clear() @@ -2004,7 +2025,7 @@ def test_composed_cancel_does_not_upgrade_incomplete_or_noninitial_history( elif history == "missing-context": c.rows[NativePayloadObject].clear() elif history == "resumed": - state.generation = cast(NativeTestScope, c.rows[NativeTestScope][0]).generation = dispatch.generation = 1 + state.generation = checked_row(NativeTestScope, c.rows[NativeTestScope][0]).generation = dispatch.generation = 1 result = c.api.cancel( actor=c.actor, session_id="session", request_id=c.started.request_id, idempotency_key="unsafe-history" ) @@ -2132,7 +2153,7 @@ def test_logical_preclaim_retirement_rechecks_proof_and_exact_retained_owners( if race == "missing-history": c.rows[NativeDispatch].clear() elif race == "resumed": - cast(NativeInvocationState, c.rows[NativeInvocationState][0]).generation = cast( + checked_row(NativeInvocationState, c.rows[NativeInvocationState][0]).generation = checked_row( NativeTestScope, c.rows[NativeTestScope][0] ).generation = 1 actual_proof = host_capacity.finish_unacquired_cancellation @@ -2198,13 +2219,13 @@ def _cancel_projection_post_wait( ref = decode_native_ref(c.published[0]["ref"]) context = c.api.store.load_context(ref=ref) - state = cast(NativeInvocationState, c.rows[NativeInvocationState][0]) + state = checked_row(NativeInvocationState, c.rows[NativeInvocationState][0]) assert isinstance(state, NativeInvocationState) state.state, state.cleanup_complete = "waiting", True state.checkpoint_id = str(uuid4()) state.pending_ids, state.wait_reason = [str(uuid4())], "approval" - cast(DifyBuilderExecutionRequest, c.rows[DifyBuilderExecutionRequest][0]).state = "waiting" - dispatch = cast(NativeDispatch, c.rows[NativeDispatch][0]) + checked_row(DifyBuilderExecutionRequest, c.rows[DifyBuilderExecutionRequest][0]).state = "waiting" + dispatch = checked_row(NativeDispatch, c.rows[NativeDispatch][0]) assert isinstance(dispatch, NativeDispatch) dispatch.state = dispatch_state assert context.native_run_id is not None @@ -2266,7 +2287,7 @@ def test_cancel_projection_includes_owned_sources( **{column.key: inspect(root).attrs[column.key].value for column in NativeInvocationState.__table__.columns} ) selected.invocation_id, selected.parent_invocation_id = child_id, root.invocation_id - payload = cast(NativePayloadObject, c.rows[NativePayloadObject][0]) + payload = checked_row(NativePayloadObject, c.rows[NativePayloadObject][0]) child_payload = NativePayloadObject( **{column.key: inspect(payload).attrs[column.key].value for column in NativePayloadObject.__table__.columns} ) @@ -2369,7 +2390,7 @@ def test_cancel_projection_retains_older_unresolved_source(cancel_composition: C ) current.id, current.generation, current.state, current.idempotency_digest = str(uuid4()), 1, "completed", "f" * 64 c.sql.add(current) - state.generation = cast(NativeTestScope, c.rows[NativeTestScope][0]).generation = 1 + state.generation = checked_row(NativeTestScope, c.rows[NativeTestScope][0]).generation = 1 assert ( c.api.lifecycle.cancel(actor=c.actor, request_id=ref.request_id, idempotency_key="older-unknown").status == "unknown" @@ -2472,8 +2493,8 @@ def test_get_cancel_interleaving_refreshes_orm_state_and_projects_one_cancellati if initial_state == "completed": durable.outcome, durable.pending_ids, durable.wait_reason = {"native_status": "succeeded"}, list[object](), None run.status = WorkflowExecutionStatus.SUCCEEDED - cast(DifyBuilderExecutionRequest, c.rows[DifyBuilderExecutionRequest][0]).state = "sealed" - cast(NativeTestInvocation, c.rows[NativeTestInvocation][0]).active = False + checked_row(DifyBuilderExecutionRequest, c.rows[DifyBuilderExecutionRequest][0]).state = "sealed" + checked_row(NativeTestInvocation, c.rows[NativeTestInvocation][0]).active = False else: pending = NativePendingAction( id=str(uuid4()), @@ -2522,7 +2543,7 @@ def test_get_cancel_interleaving_refreshes_orm_state_and_projects_one_cancellati if query.column_descriptions[0]["entity"] is NativeInvocationState: rows = self.orm.scalars(query).all() if not cached: - cached.extend(cast(Sequence[NativeInvocationState], rows)) + cached.extend(checked_rows(NativeInvocationState, rows)) return Rows(rows) return c.sql.scalars(query) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_node_inputs.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_node_inputs.py index c1a7a0de914..e0443aef259 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_node_inputs.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_node_inputs.py @@ -13,11 +13,14 @@ from collections.abc import Callable, Generator from contextlib import contextmanager from dataclasses import dataclass, replace from datetime import UTC, datetime, timedelta -from typing import NoReturn, cast +from operator import methodcaller +from typing import NoReturn import pytest from sqlalchemy import Select, inspect +from sqlalchemy.orm import Session +from core.dify_builder.models import Actor from models.dify_builder import DifyBuilderExecutionRequest from models.dify_builder_native_tests import ( NativeInvocationState, @@ -27,11 +30,18 @@ from models.dify_builder_native_tests import ( ) from models.workflow import Workflow from runtime_contracts.builder.execution import BuilderExecutionPolicyError, canonical_digest -from runtime_contracts.builder.native.contracts import JsonValue, NativeTestContextRef, OperationKey, operation_digest +from runtime_contracts.builder.native.contracts import NativeTestContextRef, OperationKey, operation_digest from runtime_contracts.builder.native.runtime_contracts import ResolvedNativeTestContext from services.dify_builder.native_testing import state_store as module +from services.dify_builder.native_testing.http_binding import NativeHttpBindingResolver from services.dify_builder.native_testing.private_reads import PrivateReadOwner from services.dify_builder.native_testing.repository import OwnedNativeTest, OwnedNativeTestLoader +from tests.unit_tests.services.dify_builder.native_testing.typed_boundaries import ( + SqlMethods, + checked_row, + sql_session, + sql_session_factory, +) NATIVE_ID = "81d492fd-05ce-46a8-8c82-a35d4b343d48" CANARY = "private-input-canary-982314" @@ -41,23 +51,24 @@ class SQLStandIn: def __init__(self, owner: LoaderStandIn) -> None: self.owner = owner + def sql_methods(self) -> SqlMethods: + return {"get": self.get, "scalar": self.scalar, "scalars": self.scalars, "add": self.add, "flush": self.flush} + def scalar[T](self, query: Select[tuple[T]]) -> T | None: - entity = query.column_descriptions[0]["entity"] + entity: type[T] = query.column_descriptions[0]["entity"] if entity is NativeInvocationState: - return cast(T, self.owner.state) + return checked_row(entity, self.owner.state) if entity is NativePayloadObject: filters = {key.rsplit("_", 1)[0]: value for key, value in query.compile().params.items()} - return cast( - T | None, - next( - ( - row - for row in self.owner.rows - if all(inspect(row).attrs[key].value == value for key, value in filters.items()) - ), - None, + result = next( + ( + row + for row in self.owner.rows + if all(inspect(row).attrs[key].value == value for key, value in filters.items()) ), + None, ) + return checked_row(entity, result) if result is not None else None raise AssertionError("unexpected SQL entity") def scalars(self, query: Select[tuple[object, ...]]) -> list[NativeInvocationState] | list[str]: @@ -72,7 +83,7 @@ class SQLStandIn: del populate_existing assert model is DifyBuilderExecutionRequest assert identity == self.owner.ref.request_id - return cast(T, self.owner.request) + return checked_row(model, self.owner.request) def add(self, row: NativePayloadObject) -> None: self.owner.rows.append(row) @@ -160,9 +171,9 @@ class LoaderStandIn: return self.owned def _load( - self, session: SQLStandIn, *, ref: NativeTestContextRef, lock: bool = False + self, session: Session, *, ref: NativeTestContextRef, lock: bool = False ) -> tuple[OwnedNativeTest, NativeTestScope]: - assert isinstance(session, SQLStandIn) + assert isinstance(session, Session) assert not lock return self.load(ref=ref), self.scope @@ -263,7 +274,7 @@ def case(monkeypatch: pytest.MonkeyPatch) -> NodeInputsCase: assert not loader.writing return bytes.fromhex(raw).decode() - def private_read(owner: LoaderStandIn, ref: NativeTestContextRef) -> PrivateReadOwner: + def private_read(owner: OwnedNativeTestLoader, ref: NativeTestContextRef) -> PrivateReadOwner: owner.load(ref=ref) assert loader.state.app_id is not None assert loader.state.workflow_id is not None @@ -291,7 +302,21 @@ def case(monkeypatch: pytest.MonkeyPatch) -> NodeInputsCase: monkeypatch.setattr(module, "validate_private_read", private_read) monkeypatch.setattr(module, "validate_invocation_workflow", lambda *_args, **_kwargs: workflow) monkeypatch.setattr(module, "native_workflow_revision", lambda _workflow: "revision") - store = module.NativeExecutionStateStore(cast(OwnedNativeTestLoader, loader)) + owner_loader = OwnedNativeTestLoader( + sql_session_factory(monkeypatch, loader.session_factory, methods=SQLStandIn.sql_methods), + actor=Actor(tenant_id="tenant", account_id="actor"), + resolver=NativeHttpBindingResolver(), + ) + monkeypatch.setattr(owner_loader, "load", loader.load) + monkeypatch.setattr(owner_loader, "_load", loader._load) + + @contextmanager + def locked(*, ref: NativeTestContextRef) -> Generator[tuple[Session, OwnedNativeTest, NativeTestScope]]: + with loader.locked(ref=ref) as (boundary, owned, scope): + yield sql_session(monkeypatch, boundary.sql_methods()), owned, scope + + monkeypatch.setattr(owner_loader, "locked", locked) + store = module.NativeExecutionStateStore(owner_loader) receipt = store.prepare_object( ref=loader.ref, tenant_id="tenant", @@ -319,7 +344,11 @@ def persist(case: NodeInputsCase, inputs: object) -> str: """Pass malformed JSON unchanged to exercise the runtime validation boundary.""" method = case.store.store_node_inputs assert callable(method), "independent node input persistence is unavailable" - return method(ref=case.ref, generation=0, native_execution_id=NATIVE_ID, inputs=cast(JsonValue, inputs)) + result = methodcaller( + "store_node_inputs", ref=case.ref, generation=0, native_execution_id=NATIVE_ID, inputs=inputs + )(case.store) + assert isinstance(result, str) + return result @pytest.mark.parametrize("value", [True, "true", [1, None], "[1,null]", None, 1.5, {"file": {"ref": "file-id"}}]) @@ -367,9 +396,9 @@ def test_generation_must_be_exact_current_integer_before_storage( case: NodeInputsCase, generation: None | bool | float | int | str ) -> None: with pytest.raises(BuilderExecutionPolicyError, match="native_generation_conflict"): - case.store.store_node_inputs( - ref=case.ref, generation=cast(int, generation), native_execution_id=NATIVE_ID, inputs=None - ) + methodcaller( + "store_node_inputs", ref=case.ref, generation=generation, native_execution_id=NATIVE_ID, inputs=None + )(case.store) assert "save" not in case.events @@ -438,7 +467,9 @@ def test_invalid_identity_fails_before_private_storage(case: NodeInputsCase, ide method = case.store.store_node_inputs assert callable(method), "independent node input persistence is unavailable" with pytest.raises(BuilderExecutionPolicyError): - method(ref=case.ref, generation=0, native_execution_id=cast(str, identity), inputs={"secret": CANARY}) + methodcaller( + "store_node_inputs", ref=case.ref, generation=0, native_execution_id=identity, inputs={"secret": CANARY} + )(case.store) assert case.events == [] diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_owner.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_owner.py index 7d14c3d0745..c41b5a35a73 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_owner.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_owner.py @@ -8,11 +8,11 @@ from contextlib import contextmanager from copy import deepcopy from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta -from typing import NoReturn, Protocol, cast +from operator import methodcaller +from typing import NoReturn, Protocol import pytest from sqlalchemy import inspect -from sqlalchemy.orm import Session, sessionmaker from core.dify_builder.models import Actor from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus @@ -44,6 +44,7 @@ from tests.unit_tests.services.dify_builder.native_testing.test_observation_priv graph_data, ) from tests.unit_tests.services.dify_builder.native_testing.test_observation_privacy import context as sample_context +from tests.unit_tests.services.dify_builder.native_testing.typed_boundaries import sql_session_factory @dataclass @@ -269,7 +270,8 @@ def case(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> Obs def forbidden(*_: object, **__: object) -> NoReturn: raise AssertionError("writer/provider/credential I/O forbidden") - monkeypatch.setattr(factory, "begin", forbidden, raising=False) + sessions = sql_session_factory(monkeypatch, factory, methods=SQL.sql_methods) + monkeypatch.setattr(sessions, "begin", forbidden) # Protected C03 object readback is the labeled boundary below. Any extra # decryption/storage access or real HTTP transport must remain unreachable. for path in ( @@ -284,7 +286,7 @@ def case(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> Obs assert depth == 0 assert actor == Actor(tenant_id="tenant", account_id="actor") assert app_id == "app" - assert sessions is factory + assert sessions is loader.session_factory events.append("permission") for path in [ @@ -294,7 +296,7 @@ def case(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> Obs ]: monkeypatch.setattr(path, permission) loader = OwnedNativeTestLoader( - cast(sessionmaker[Session], factory), + sessions, actor=Actor(tenant_id="tenant", account_id="actor"), resolver=NativeHttpBindingResolver(), ) @@ -351,11 +353,7 @@ def _refuses(c: ObservationOwnerCase, **overrides: object) -> None: } kwargs.update(overrides) with pytest.raises(NativeObservationError) as error: - owner()( - state=cast(NativeExecutionStateStore, kwargs["state"]), - context=cast(ResolvedNativeTestContext, kwargs["context"]), - on_failure=cast(Callable[[], None], kwargs["on_failure"]), - ) + methodcaller("__call__", **kwargs)(owner()) assert str(error.value) == "native_observation_failed" assert c.notices == [] @@ -395,13 +393,15 @@ def test_caller_context_cannot_replace_actual_protected_snapshot( "fault", ["fake_state", "fake_loader", "missing_context", "membership", "input_owner", "revision", "local_generation"], ) -def test_actual_c03_full_current_owner_chain_is_required(case: ObservationOwnerCase, fault: str) -> None: +def test_actual_c03_full_current_owner_chain_is_required( + case: ObservationOwnerCase, fault: str, monkeypatch: pytest.MonkeyPatch +) -> None: c = case if fault == "fake_state": _refuses(c, state=PretendExecutionState(c.loader, c.context)) return if fault == "fake_loader": - c.store.loader = cast(OwnedNativeTestLoader, object()) + monkeypatch.setattr(c.store, "loader", object()) elif fault == "missing_context": c.rows[NativePayloadObject] = [] elif fault == "membership": @@ -520,7 +520,9 @@ def test_initial_current_profile_keeps_owner_active_and_descriptor_guards( _refuses(c) -def test_code_stored_profile_requires_current_exact_qualification_owner(case: ObservationOwnerCase) -> None: +def test_code_stored_profile_requires_current_exact_qualification_owner( + case: ObservationOwnerCase, monkeypatch: pytest.MonkeyPatch +) -> None: from services.dify_builder.native_testing.sandbox.qualification import ( NativeCodeBindingResolver, describe_code_resource, @@ -564,5 +566,5 @@ def test_code_stored_profile_requires_current_exact_qualification_owner(case: Ob c.loader.resolver = NativeCodeBindingResolver(qualification=q) guard = owner()(state=c.store, context=c.context, on_failure=lambda: None) assert "CANARY" not in json.dumps(guard.graph(graph)) - q.owner.validate = lambda **_kw: (_ for _ in ()).throw(ValueError("revoked private reason")) + monkeypatch.setattr(q.owner, "validate", lambda **_kw: (_ for _ in ()).throw(ValueError("revoked private reason"))) _refuses(c) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_persistence.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_persistence.py index 900df873b9f..680e1aa9540 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_persistence.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_persistence.py @@ -1,6 +1,6 @@ """Actual persistence/domain/serializer/offload code with in-memory SQL/storage ports. -No SQL engine or session is created. The ports acknowledge only this unit fixture, +Real Sessions use explicit SQL boundary doubles. The ports acknowledge only this unit fixture, not durable writes; actual PostgreSQL/storage/observer qualification remains open. """ @@ -14,12 +14,12 @@ from collections.abc import AsyncIterator, Sequence from copy import deepcopy from datetime import datetime from types import ModuleType -from typing import NoReturn, Self, cast, override +from typing import NoReturn, Self, override import pytest from dify_trace_sdk.emitter import TraceScope +from dify_trace_sdk.events import TraceCorrelation, TraceEvent from sqlalchemy import Select -from sqlalchemy.orm import Session, sessionmaker from core.app.app_config.entities import WorkflowUIBasedAppConfig from core.app.entities.app_invoke_entities import WorkflowAppGenerateEntity @@ -49,13 +49,12 @@ from graphon.enums import WorkflowNodeExecutionStatus as Status from graphon.node_events import NodeRunResult from graphon.runtime import ReadOnlyRuntimeStateWrapper, RuntimeState, VariablePool from models import WorkflowNodeExecutionTriggeredFrom, WorkflowRun -from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom +from models.enums import WorkflowRunTriggeredFrom from models.model import AppMode, UploadFile from models.workflow import WorkflowNodeExecutionModel from services.dify_builder.native_testing.http_binding import build_native_http_profile from services.dify_builder.native_testing.observation_adapters import NativeObservationGuard from services.dify_builder.native_testing.observation_privacy import NativeHttpObservationPolicy -from services.file_service import FileService from services.variable_truncator import VariableTruncator from services.workflow_persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer from tests.helpers.unit.model_factories import make_account, make_upload_file @@ -68,6 +67,11 @@ from tests.unit_tests.services.dify_builder.native_testing.test_observation_priv from tests.unit_tests.services.dify_builder.native_testing.test_observation_privacy import ( result as private_result, ) +from tests.unit_tests.services.dify_builder.native_testing.typed_boundaries import ( + SqlMethods, + checked_row, + sql_session_factory, +) NOW = datetime(2026, 10, 16) @@ -95,6 +99,18 @@ class MemorySQLPort: self.flushes = 0 self.fail = False + def sql_methods(self) -> SqlMethods: + return { + "get": self.get, + "add": self.add, + "merge": self.merge, + "execute": self.execute, + "scalar": self.scalar, + "scalars": self.scalars, + "commit": self.commit, + "flush": self.flush, + } + def __call__(self, **_kwargs: object) -> Self: return self @@ -111,7 +127,8 @@ class MemorySQLPort: # Accept the real Session's locked reread shape; this unit port makes # no SQL isolation or durable-lock claim. del with_for_update - return cast(T | None, self.rows.get(identity)) + row = self.rows.get(identity) + return checked_row(_model, row) if row is not None else None def add(self, model: MemoryRow) -> None: self.rows[model.id] = model @@ -164,13 +181,14 @@ class UnitRunRepository(SQLAlchemyWorkflowExecutionRepository): def __init__(self) -> None: self.sql = MemorySQLPort() - self._session_factory = cast(sessionmaker[Session], self.sql) - self._tenant_id = "tenant" - self._app_id = "app" - self._creator_user_id = "actor" - self._creator_user_role = CreatorUserRole.ACCOUNT - self._triggered_from = WorkflowRunTriggeredFrom.DEBUGGING - self._execution_cache = {} + patch = pytest.MonkeyPatch() + super().__init__( + sql_session_factory(patch, self.sql, self.sql.begin, methods=MemorySQLPort.sql_methods), + "tenant", + make_account(account_id="actor"), + "app", + WorkflowRunTriggeredFrom.DEBUGGING, + ) self.saved: list[WorkflowExecution] = [] @override @@ -185,16 +203,16 @@ class UnitNodeRepository(SQLAlchemyWorkflowNodeExecutionRepository): def __init__(self) -> None: self.sql = MemorySQLPort() - self._session_factory = cast(sessionmaker[Session], self.sql) - self._tenant_id = "tenant" - self._app_id = "app" - self._creator_user_id = "actor" - self._creator_user_role = CreatorUserRole.ACCOUNT - self._triggered_from = WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN - self._user = make_account(account_id="actor") - self._node_execution_cache = {} + patch = pytest.MonkeyPatch() + super().__init__( + sql_session_factory(patch, self.sql, self.sql.begin, methods=MemorySQLPort.sql_methods), + "tenant", + make_account(account_id="actor"), + "app", + WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, + ) self.files = MemoryFiles() - self._file_service = cast(FileService, self.files) + patch.setattr(self._file_service, "upload_file", self.files.upload_file) self.saved: list[WorkflowNodeExecution] = [] self.loaded: list[WorkflowNodeExecution] = [] self.load_calls = 0 @@ -230,7 +248,7 @@ class UnitNodeRepository(SQLAlchemyWorkflowNodeExecutionRepository): def make_layer( - *, guard: NativeObservationGuard | None = None, trace: object = None + *, guard: NativeObservationGuard | None = None, trace: TraceScope | None = None ) -> tuple[ WorkflowPersistenceLayer, UnitRunRepository, @@ -272,7 +290,7 @@ def make_layer( workflow_info=info, workflow_execution_repository=runs, workflow_node_execution_repository=nodes, - trace_scope=cast(TraceScope | None, trace), + trace_scope=trace, native_observation=guard, ) layer.initialize(ReadOnlyRuntimeStateWrapper(runtime), command_channel=InMemoryChannel()) @@ -548,7 +566,8 @@ def test_native_resumption_refuses_before_old_row_load_or_cache_and_run_save() - class RejectRawTrace: """Trace queue boundary that must never receive marked persistence events.""" - def emit(self, _task) -> NoReturn: + def emit(self, event: TraceEvent) -> NoReturn: + del event pytest.fail("raw trace enqueue") @@ -557,7 +576,18 @@ def test_trace_owner_is_refused_before_marked_persistence_or_enqueues() -> None: module = adapters() guard = module.NativeObservationGuard(policy=policy(), on_failure=lambda: notices.append(1)) with pytest.raises(module.NativeObservationError): - make_layer(guard=guard, trace=RejectRawTrace()) + make_layer( + guard=guard, + trace=TraceScope( + RejectRawTrace(), + "tenant", + "app", + "actor", + TraceCorrelation( + external_trace_id=None, session_id=None, parent_workflow_run_id=None, parent_node_execution_id=None + ), + ), + ) assert guard.failed is True assert notices == [1] @@ -570,7 +600,9 @@ def test_two_actual_observers_have_independent_diagnostic_caches() -> None: layer.on_event(GraphRunStartedEvent()) layer.on_event(started()) layer.on_event(event) - cast(dict[str, object], left[0]._node_execution_cache["execution"].outputs)["redacted"] = False + left_outputs = left[0]._node_execution_cache["execution"].outputs + assert isinstance(left_outputs, dict) + left_outputs["redacted"] = False right_outputs = right[0]._node_execution_cache["execution"].outputs assert right_outputs is not None assert right_outputs["redacted"] is True @@ -765,7 +797,15 @@ def test_late_trace_owner_cannot_enqueue_after_marked_construction() -> None: guard = adapters().NativeObservationGuard(policy=policy(), on_failure=lambda: None) layer, _, _, _, entity, _ = make_layer(guard=guard) layer.on_event(GraphRunStartedEvent()) - entity.trace_scope = cast(TraceScope, RejectRawTrace()) + entity.trace_scope = TraceScope( + RejectRawTrace(), + "tenant", + "app", + "actor", + TraceCorrelation( + external_trace_id=None, session_id=None, parent_workflow_run_id=None, parent_node_execution_id=None + ), + ) with pytest.raises(adapters().NativeObservationError): layer.on_event(GraphRunSucceededEvent()) assert guard.failed is True diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_privacy.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_privacy.py index 2c46b81dd22..104d6ab05ec 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_privacy.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_privacy.py @@ -5,12 +5,12 @@ from __future__ import annotations import importlib import importlib.util import json -from collections.abc import Callable, Sequence +from collections.abc import Mapping, Sequence from copy import deepcopy from datetime import UTC, datetime from importlib.metadata import version -from types import SimpleNamespace -from typing import NoReturn, cast, override +from operator import methodcaller +from typing import NoReturn, TypeIs, override import pytest from typing_extensions import TypedDict @@ -76,7 +76,8 @@ def owner() -> type[NativeHttpObservationPolicy]: def project_event[T: EngineEvent](policy: NativeHttpObservationPolicy, event: T) -> T: projected = policy.event(event) assert type(projected) is type(event) - return cast(T, projected) + assert isinstance(projected, type(event)) + return projected def context() -> ResolvedNativeTestContext: @@ -165,9 +166,33 @@ class FixtureGraph(TypedDict, extra_items=object): edges: list[dict[str, str]] +def _is_fixture_graph(value: Mapping[str, object]) -> TypeIs[FixtureGraph]: + nodes, edges = value.get("nodes"), value.get("edges") + return ( + isinstance(value, dict) + and all(isinstance(key, str) for key in value) + and isinstance(nodes, list) + and isinstance(edges, list) + and all( + isinstance(node, dict) + and isinstance(node.get("id"), str) + and isinstance(node.get("data"), dict) + and all(isinstance(key, str) for key in node) + and all(isinstance(key, str) for key in node["data"]) + for node in nodes + ) + and all( + isinstance(edge, dict) and all(isinstance(key, str) and isinstance(item, str) for key, item in edge.items()) + for edge in edges + ) + ) + + def graph_data(ctx: ResolvedNativeTestContext) -> FixtureGraph: - """Mutable JSON fixture boundary; production revalidates before projection.""" - return cast(FixtureGraph, ctx.graph) + """Validate the mutable fixture while preserving all graph object identities.""" + graph = ctx.graph + assert _is_fixture_graph(graph) + return graph def result() -> NodeRunResult: @@ -260,7 +285,8 @@ def test_real_result_echo_and_retry_history_are_redacted_with_independent_observ attempt["created_at"], attempt["finished_at"], ) == (1, 0.25, 1, 2) - cast(dict[str, object], left.node_run_result.inputs)["redacted"] = False + assert isinstance(left.node_run_result.inputs, dict) + left.node_run_result.inputs["redacted"] = False attempt["inputs"]["redacted"] = False assert right.node_run_result.inputs["redacted"] is True assert right.node_run_result.process_data["__dify_retry_history"][0]["inputs"]["redacted"] is True @@ -525,10 +551,14 @@ def test_errors_never_render_exceptions_and_only_keep_finite_known_kinds(policy: def test_invalid_contexts_and_descriptor_drift_refuse_without_content(mutation: str) -> None: ctx = context() if mutation == "manifest": - cast(dict[str, object], ctx.resource_manifest[0]["policy"])["cost_units"] = 99 + manifest_policy = ctx.resource_manifest[0]["policy"] + assert isinstance(manifest_policy, dict) + manifest_policy["cost_units"] = 99 elif mutation == "node": raw_data = graph_data(ctx)["nodes"][1]["data"] - raw_data["url"] = cast(str, raw_data["url"]) + "/changed" + url = raw_data["url"] + assert isinstance(url, str) + raw_data["url"] = url + "/changed" elif mutation == "revision": ctx = ctx.model_copy(update={"graph_revision": "c" * 64}) elif mutation == "owner": @@ -556,14 +586,16 @@ def test_actual_server_profile_is_required_and_drift_refuses() -> None: with pytest.raises(BuilderExecutionPolicyError): owner().qualify(context=context(), profile=profile) with pytest.raises(TypeError): - cast(Callable[..., NativeHttpObservationPolicy], owner().qualify)(context=context()) + methodcaller("qualify", context=context())(owner()) def test_policy_snapshots_context_against_later_mutation() -> None: ctx = context() pinned = owner().qualify(context=ctx, profile=build_native_http_profile()) raw_data = graph_data(ctx)["nodes"][1]["data"] - raw_data["url"] = cast(str, raw_data["url"]) + "/changed" + url = raw_data["url"] + assert isinstance(url, str) + raw_data["url"] = url + "/changed" with pytest.raises(BuilderExecutionPolicyError): pinned.graph(ctx.graph) @@ -608,7 +640,7 @@ def test_only_explicit_native_execution_limits_layer_is_structurally_qualified( Hostile(), ): with pytest.raises(BuilderExecutionPolicyError) as caught: - policy.require_layers(cast(Sequence[Layer], [layer])) + methodcaller("require_layers", [layer])(policy) assert SENTINEL not in str(caught.value) @@ -1112,7 +1144,9 @@ def test_empty_http_manifest_keeps_schema_owner_and_privacy_refusals(fault: str) @pytest.mark.parametrize("kind", ["code", "template-transform"]) -def test_code_profile_redacts_source_template_values_and_retry_history(kind: str) -> None: +def test_code_profile_redacts_source_template_values_and_retry_history( + kind: str, monkeypatch: pytest.MonkeyPatch +) -> None: from services.dify_builder.native_testing.sandbox.qualification import ( describe_code_resource, ) @@ -1123,14 +1157,14 @@ def test_code_profile_redacts_source_template_values_and_retry_history(kind: str q, _ = qualification() ctx = context() - graph = deepcopy(ctx.graph) + graph = deepcopy(graph_data(ctx)) raw = ( raw_code(code=SENTINEL) if kind == "code" else {"type": kind, "title": SENTINEL, "variables": list[object](), "template": SENTINEL} ) - nodes = cast(list[dict[str, object]], graph["nodes"]) - nodes[1]["data"] = raw + nodes = graph["nodes"] + nodes[1]["data"] = dict(raw) descriptor = describe_code_resource( qualification=q, node_id="http", @@ -1155,6 +1189,6 @@ def test_code_profile_redacts_source_template_values_and_retry_history(kind: str ) assert SENTINEL not in policy.event(event).model_dump_json() assert SENTINEL in event.model_dump_json() - cast(SimpleNamespace, q.owner).validate = lambda **_kw: True + monkeypatch.setattr(q.owner, "validate", lambda **_kw: True) with pytest.raises(BuilderExecutionPolicyError): owner().qualify(context=ctx, profile=q.profile(), code_qualification=q) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_repository.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_repository.py index dd9924ee313..3f332748d8b 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_repository.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_repository.py @@ -4,22 +4,21 @@ from __future__ import annotations import json from collections import UserList -from collections.abc import Callable, Iterator, Mapping +from collections.abc import Iterator from copy import deepcopy from dataclasses import dataclass from datetime import datetime -from typing import NoReturn, Protocol, Self, cast +from operator import methodcaller +from typing import NoReturn, Self import pytest from sqlalchemy import Select -from sqlalchemy.orm import Session, sessionmaker from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository from graphon.entities import WorkflowExecution, WorkflowNodeExecution from graphon.enums import ( WorkflowExecutionStatus, - WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus, WorkflowType, ) @@ -35,6 +34,11 @@ from services.dify_builder.native_testing.observation_adapters import NativeObse from services.dify_builder.native_testing.observation_privacy import NativeHttpObservationPolicy from services.dify_builder.native_testing.observation_repository import bind_native_repository_observation from tests.unit_tests.services.dify_builder.native_testing.test_observation_privacy import SENTINEL, context, graph_data +from tests.unit_tests.services.dify_builder.native_testing.typed_boundaries import ( + SqlMethods, + checked_row, + sql_session_factory, +) NOW = datetime(2026, 10, 16) @@ -63,6 +67,17 @@ class SQLPort: self.fail = False self.writing = False + def sql_methods(self) -> SqlMethods: + return { + "get": self.get, + "scalars": self.scalars, + "execute": self.execute, + "merge": self.merge, + "add": self.add, + "flush": self.flush, + "commit": self.commit, + } + def __call__(self, **_kwargs: object) -> Self: return self @@ -126,7 +141,7 @@ class RepositoryCase: @pytest.fixture -def case() -> RepositoryCase: +def case(monkeypatch: pytest.MonkeyPatch) -> RepositoryCase: ctx = context() user = Account(name=SENTINEL, email=SENTINEL) user.id = ctx.actor_id @@ -135,13 +150,13 @@ def case() -> RepositoryCase: on_failure=lambda: None, ) sql = SQLPort() + sessions = sql_session_factory(monkeypatch, sql, sql.begin, methods=SQLPort.sql_methods) runs = SQLAlchemyWorkflowExecutionRepository( - sessionmaker(), ctx.tenant_id, user, ctx.app_id, WorkflowRunTriggeredFrom.DEBUGGING + sessions, ctx.tenant_id, user, ctx.app_id, WorkflowRunTriggeredFrom.DEBUGGING ) nodes = SQLAlchemyWorkflowNodeExecutionRepository( - sessionmaker(), ctx.tenant_id, user, ctx.app_id, WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN + sessions, ctx.tenant_id, user, ctx.app_id, WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN ) - runs._session_factory = nodes._session_factory = cast(sessionmaker[Session], sql) return RepositoryCase(context=ctx, guard=guard, sql=sql, runs=runs, nodes=nodes, user=user) @@ -342,13 +357,7 @@ def storage_chunks(content: bytes) -> Iterator[bytes]: yield content -class WrappedTelemetryFixture(Protocol): - @property - def __wrapped__(self) -> Callable[[], Iterator[None]]: ... - - -@pytest.fixture(autouse=True) -def telemetry() -> Iterator[None]: +def _telemetry_routes() -> Iterator[None]: """Initialize/restore real test-owned sinks and routes, never mock readiness.""" import logging @@ -391,6 +400,11 @@ def telemetry() -> Iterator[None]: owned_handler.close() +@pytest.fixture(autouse=True) +def telemetry() -> Iterator[None]: + yield from _telemetry_routes() + + def offloaded(c: RepositoryCase, row: WorkflowNodeExecutionModel, *, value: object = None) -> tuple[UploadFile, bytes]: import hashlib import json @@ -398,7 +412,6 @@ def offloaded(c: RepositoryCase, row: WorkflowNodeExecutionModel, *, value: obje from configs import dify_config from extensions.storage.storage_type import StorageType - from models.enums import ExecutionOffLoadType from models.workflow import WorkflowNodeExecutionOffload from tests.helpers.unit.model_factories import make_upload_file @@ -477,7 +490,7 @@ def test_bad_offload_or_late_content_never_enters_cache( if fault == "relation": row.offload_data[0].node_execution_id = "foreign" if fault == "type": - row.offload_data[0].type_ = cast(ExecutionOffLoadType, "foreign") + monkeypatch.setattr(row.offload_data[0], "type_", "foreign") if fault == "hash": file.hash = "0" * 64 c.sql.selected = [first, row] @@ -630,7 +643,9 @@ def test_empty_success_error_remains_empty(case: RepositoryCase) -> None: assert row.status is WorkflowNodeExecutionStatus.SUCCEEDED -def test_history_owner_objects_cannot_spoof_equality_or_get_rendered(case: RepositoryCase) -> None: +def test_history_owner_objects_cannot_spoof_equality_or_get_rendered( + case: RepositoryCase, monkeypatch: pytest.MonkeyPatch +) -> None: c = case bind(c) calls: list[str] = [] @@ -649,7 +664,7 @@ def test_history_owner_objects_cannot_spoof_equality_or_get_rendered(case: Repos return SENTINEL row = safe_row(c) - row.tenant_id = cast(str, Spoof()) + monkeypatch.setattr(row, "tenant_id", Spoof()) row.offload_data = [] c.sql.selected = [row] with pytest.raises(NativeObservationError): @@ -714,8 +729,8 @@ def test_writer_recheck_failure_notifies_guard_only_after_writer_exit( def get[T: StoredRow](model: type[T], key: str, **kwargs: object) -> T | None: result = original(model, key, **kwargs) if model is WorkflowNodeExecutionModel and kwargs.get("with_for_update"): - assert result is not None - cast(WorkflowNodeExecutionModel, result).outputs = '{"raw":"' + SENTINEL + '"}' + stored = checked_row(WorkflowNodeExecutionModel, result) + stored.outputs = '{"raw":"' + SENTINEL + '"}' return result monkeypatch.setattr(c.sql, "get", get) @@ -815,7 +830,7 @@ def test_test_owned_telemetry_initializes_and_restores_actual_logging_routes( original_route_handlers = route.handlers original_propagation = route.propagate filters = {handler: handler.filters[:] for handler in [*root.handlers, *route.handlers]} - setup = cast(WrappedTelemetryFixture, telemetry).__wrapped__() + setup = _telemetry_routes() next(setup) try: bind(case) @@ -874,13 +889,13 @@ def test_unrecognized_timed_run_subclass_still_refuses_before_writes(case: Repos @pytest.mark.parametrize("elapsed_time", [True, -1.0, float("nan"), float("inf")]) def test_mutated_timed_native_run_refuses_invalid_duration_before_writes( - case: RepositoryCase, elapsed_time: object + case: RepositoryCase, elapsed_time: object, monkeypatch: pytest.MonkeyPatch ) -> None: c = case bind(c) execution = TimedWorkflowExecution(**run(c).model_dump(), accumulated_elapsed_time=0.0) # The upstream DTO is mutable; the repository boundary must revalidate it. - execution.accumulated_elapsed_time = cast(float | None, elapsed_time) + monkeypatch.setattr(execution, "accumulated_elapsed_time", elapsed_time) with pytest.raises(NativeObservationError): c.runs.save(execution) assert c.sql.commits == 0 @@ -1064,11 +1079,13 @@ def test_native_node_absent_metadata_roundtrips_without_mutating_domain( @pytest.mark.parametrize("metadata", [[], False, ""]) -def test_native_node_non_none_malformed_metadata_refuses_before_write(case: RepositoryCase, metadata: object) -> None: +def test_native_node_non_none_malformed_metadata_refuses_before_write( + case: RepositoryCase, metadata: object, monkeypatch: pytest.MonkeyPatch +) -> None: c = case bind(c) execution = node(c) - execution.metadata = cast(Mapping[WorkflowNodeExecutionMetadataKey, object], metadata) + monkeypatch.setattr(execution, "metadata", metadata) with pytest.raises(NativeObservationError): c.nodes.save(execution) @@ -1084,8 +1101,8 @@ def test_service_binding_requires_exact_guard_before_attaching_observer( ) -> None: repository = {"runs": case.runs, "nodes": case.nodes}[repository_name] with pytest.raises(NativeObservationError, match="^native_observation_failed$"): - bind_native_repository_observation( - repository=repository, context=case.context, guard=cast(NativeObservationGuard, object()) + methodcaller("__call__", repository=repository, context=case.context, guard=object())( + bind_native_repository_observation ) assert repository._native_observation is None assert not repository._native_observation_started diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_stream.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_stream.py index 12bd2c222b0..02d7acc61d2 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_stream.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_observation_stream.py @@ -2,15 +2,15 @@ from collections.abc import Callable from importlib import import_module, util +from operator import methodcaller from types import ModuleType -from typing import NoReturn, Protocol, cast +from typing import NoReturn import pytest from redis import Redis from core.app.app_config.entities import WorkflowUIBasedAppConfig -from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom -from core.app.apps.draft_variable_saver import DraftVariableSaverFactory +from core.app.apps.base_app_queue_manager import PublishFrom from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, WorkflowAppGenerateEntity from core.app.entities.queue_entities import ( QueueIterationCompletedEvent, @@ -20,7 +20,6 @@ from core.app.entities.queue_entities import ( QueueLoopNextEvent, QueueLoopStartEvent, QueueWorkflowSucceededEvent, - WorkflowQueueMessage, ) from core.app.entities.task_entities import ( ErrorStreamResponse, @@ -28,15 +27,12 @@ from core.app.entities.task_entities import ( LoopNodeCompletedStreamResponse, WorkflowPauseStreamResponse, ) -from core.base.tts.app_generator_tts_publisher import AppGeneratorTTSPublisher from core.workflow.system_variables import build_system_variables from graphon.runtime import RuntimeState, VariablePool from models.account import Account from models.model import AppMode -from models.workflow import Workflow from repositories.entities.workflow_execution import WorkflowExecutionTiming from runtime_contracts.builder.execution import BuilderExecutionPolicyError -from services.dify_builder.native_testing.host_execution import NativeHostLaunch from services.dify_builder.native_testing.http_binding import build_native_http_profile from services.dify_builder.native_testing.observation_adapters import NativeObservationError, NativeObservationGuard from services.dify_builder.native_testing.observation_privacy import NativeHttpObservationPolicy @@ -46,7 +42,7 @@ from services.workflow_runtime.workflow_queue import WorkflowAppQueueManager from tests.unit_tests.services.dify_builder.native_testing.test_observation_privacy import NOW, SENTINEL, context -class _QueueWithRuntimeState(Protocol): +class _QueueWithRuntimeState(WorkflowAppQueueManager): runtime_state: RuntimeState @@ -138,14 +134,15 @@ def test_marked_converter_requires_guard_before_input_conversion(monkeypatch: py def test_marked_pipeline_requires_guard_before_any_initialization() -> None: with pytest.raises(NativeObservationError, match="native_observation_failed"): - WorkflowAppGenerateTaskPipeline( + methodcaller( + "__call__", application_generate_entity=entity(), - workflow=cast(Workflow, None), - queue_manager=cast(AppQueueManager, None), - user=cast(Account, None), + workflow=None, + queue_manager=None, + user=None, stream=True, - draft_var_saver_factory=cast(DraftVariableSaverFactory, None), - ) + draft_var_saver_factory=None, + )(WorkflowAppGenerateTaskPipeline) def test_queue_projects_before_base_model_dump_and_direct_publish( @@ -318,7 +315,7 @@ def test_wrong_node_type_and_unqualified_hitl_fail_before_projection(guard: Nati def make_pipeline( guard: NativeObservationGuard, monkeypatch: pytest.MonkeyPatch, **updates: object -) -> tuple[WorkflowAppGenerateTaskPipeline, WorkflowAppQueueManager, RejectDraftSaverFactory]: +) -> tuple[WorkflowAppGenerateTaskPipeline, _QueueWithRuntimeState, RejectDraftSaverFactory]: from tests.unit_tests.services.workflow_runtime.test_workflow_pipeline import _make_pipeline ordinary = _make_pipeline() @@ -326,7 +323,7 @@ def make_pipeline( update={"native_test_required": True, "invoke_from": InvokeFrom.DEBUGGER, **updates} ) bind_queue_cache(monkeypatch) - queue = WorkflowAppQueueManager("task", "actor", InvokeFrom.DEBUGGER, "workflow", native_observation=guard) + queue = _QueueWithRuntimeState("task", "actor", InvokeFrom.DEBUGGER, "workflow", native_observation=guard) saver = RejectDraftSaverFactory() pipeline = WorkflowAppGenerateTaskPipeline( application_generate_entity=e, @@ -363,8 +360,8 @@ def test_pipeline_projects_internal_events_and_noops_draft_saver( assert p._workflow_response_converter._native_observation is guard assert q.native_observation is guard live = RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0) - cast(_QueueWithRuntimeState, q).runtime_state = live - assert cast(_QueueWithRuntimeState, q).runtime_state is live + q.runtime_state = live + assert q.runtime_state is live def test_pipeline_refuses_tts_trace_and_files_before_actions( @@ -460,11 +457,12 @@ def test_direct_native_text_handler_rejects_tts_before_publish( tts = RejectedTTSPublisher() with pytest.raises(NativeObservationError): list( - p._handle_text_chunk_event( + methodcaller( + "_handle_text_chunk_event", QueueTextChunkEvent(text=SENTINEL), - tts_publisher=cast(AppGeneratorTTSPublisher, tts), - queue_message=cast(WorkflowQueueMessage, object()), - ) + tts_publisher=tts, + queue_message=object(), + )(p) ) assert tts.published == [] @@ -701,12 +699,13 @@ def test_queue_unknown_value_refuses_before_base_serializer( @pytest.mark.parametrize("bad", [None, object()]) def test_marked_converter_wrong_guard_refuses_before_inputs(bad: object) -> None: with pytest.raises(NativeObservationError): - WorkflowResponseConverter( + methodcaller( + "__call__", application_generate_entity=entity(), user=account(), system_variables=[], - native_observation=cast(NativeObservationGuard, bad), - ) + native_observation=bad, + )(WorkflowResponseConverter) def test_native_no_hitl_or_history_resume(guard: NativeObservationGuard) -> None: @@ -743,21 +742,23 @@ def test_resumption_requires_actual_host_not_a_caller_port( def construct() -> WorkflowAppQueueManager | WorkflowResponseConverter: if route == "converter": - return WorkflowResponseConverter( + return methodcaller( + "__call__", application_generate_entity=entity(), user=account(), system_variables=[], native_observation=guard, - native_host=cast(NativeHostLaunch, supplied_host), - ) - return WorkflowAppQueueManager( + native_host=supplied_host, + )(WorkflowResponseConverter) + return methodcaller( + "__call__", "task", "actor", InvokeFrom.DEBUGGER, "workflow", native_observation=guard, - native_host=cast(NativeHostLaunch, supplied_host), - ) + native_host=supplied_host, + )(WorkflowAppQueueManager) with pytest.raises(BuilderExecutionPolicyError, match="native_resume_owner_mismatch"): construct() @@ -767,9 +768,7 @@ def test_resumption_requires_actual_host_not_a_caller_port( def project() -> object: nonlocal queue if route == "projector": - return domain().project_native_queue_event( - guard=guard, event=event, native_host=cast(NativeHostLaunch, supplied_host) - ) + return domain().project_native_queue_event(guard=guard, event=event, native_host=supplied_host) if route == "converter": return converter(guard).workflow_start_to_stream_response( task_id="task", workflow_run_id="run", workflow_id="workflow", reason=WorkflowStartReason.RESUMPTION diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_operation_journal.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_operation_journal.py index 1602ee3f275..fbefe15b3b4 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_operation_journal.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_operation_journal.py @@ -6,7 +6,8 @@ import importlib import importlib.util from copy import deepcopy from dataclasses import dataclass -from typing import Unpack, cast, override +from operator import methodcaller +from typing import NoReturn, Unpack, override import pytest from typing_extensions import TypedDict @@ -86,6 +87,16 @@ class State: raise BuilderExecutionPolicyError("native_result_owner_mismatch") return deepcopy(self.values[result_ref]) + def commit_checkpoint( + self, *, ref: NativeTestContextRef, generation: int, payload: NativeCheckpointPayload + ) -> NoReturn: + del ref, generation, payload + raise AssertionError("journal read fixture must not commit checkpoints") + + def store_result(self, *, ref: NativeTestContextRef, key: OperationKey, result: JsonValue) -> NoReturn: + del ref, key, result + raise AssertionError("journal read fixture must not write results") + class Authorization: def __init__(self, decision: EffectDecision | None = None) -> None: @@ -132,10 +143,13 @@ class ImpostorRunContext: def make_journal( - state: State | None = None, authorization: Authorization | None = None + state: State | None = None, + authorization: Authorization | None = None, + *, + state_owner: NativeStatePort | None = None, ) -> tuple[NodeOperationJournal, State, Authorization]: state, authorization = state or State(), authorization or Authorization() - return journal_class()(state=cast(NativeStatePort, state), authorization=authorization), state, authorization + return journal_class()(state=state_owner or state, authorization=authorization), state, authorization class IntentChanges(TypedDict, total=False, closed=True): @@ -592,14 +606,17 @@ def claimed_case(metadata_case: ClaimedCheckpointCase, monkeypatch: pytest.Monke cancellation_ref="request", execution_mode="native", ) - c.service.store = cast(NativeExecutionStateStore, state) - source, _, authorization = make_journal(state) + store = c.service.store + monkeypatch.setattr(store, "load_context", lambda **kwargs: state.load_context(**kwargs)) + monkeypatch.setattr(store, "read_checkpoint", lambda **kwargs: state.read_checkpoint(**kwargs)) + monkeypatch.setattr(store, "load_result", lambda **kwargs: state.load_result(**kwargs)) + source, _, authorization = make_journal(state, state_owner=store) cursor = source.open(ref=c.ref, occurrence_path=PATH) cursor.bind_input(input_result_ref="inputs", inputs=INPUT) original = intent(cursor.next_key()) cursor.before_call(intent=original) state.payload = payload(source.snapshot(ref=c.ref)) - target, _, _ = make_journal(state, authorization) + target, _, _ = make_journal(state, authorization, state_owner=store) return ClaimedJournalCase( owner=c, state=state, @@ -663,7 +680,7 @@ def test_claimed_restore_requires_actual_lifecycle_and_same_state(claimed_case: if owner == "namespace": lifecycle = ImpostorCheckpointLifecycle(store=c.state) elif owner == "second_state": - lifecycle = NativeTestLifecycleService(c.owner.loader, state_store=cast(NativeExecutionStateStore, State())) + lifecycle = NativeTestLifecycleService(c.owner.loader, state_store=NativeExecutionStateStore(c.owner.loader)) else: class FakeLifecycle(NativeTestLifecycleService): @@ -671,15 +688,16 @@ def test_claimed_restore_requires_actual_lifecycle_and_same_state(claimed_case: def claimed_resume_checkpoint(self, **_: object) -> CheckpointRef: return CP - lifecycle = FakeLifecycle(c.owner.loader, state_store=cast(NativeExecutionStateStore, c.state)) + lifecycle = FakeLifecycle(c.owner.loader, state_store=c.lifecycle.store) with pytest.raises(BuilderExecutionPolicyError): - c.target.restore_claimed( + methodcaller( + "restore_claimed", ref=c.owner.ref, generation=2, dispatch_id="dispatch", - lifecycle=cast(NativeTestLifecycleService, lifecycle), + lifecycle=lifecycle, expected_payload=c.expected, - ) + )(c.target) _assert_retained(c, cursor, saved) @@ -701,7 +719,7 @@ def test_claimed_restore_requires_actual_lifecycle_and_same_state(claimed_case: ], ) def test_claimed_restore_revalidates_runtime_arguments_and_actual_context( - claimed_case: ClaimedJournalCase, fault: str + claimed_case: ClaimedJournalCase, fault: str, monkeypatch: pytest.MonkeyPatch ) -> None: c = claimed_case cursor, saved = _retained_cursor(c) @@ -730,17 +748,16 @@ def test_claimed_restore_revalidates_runtime_arguments_and_actual_context( elif fault == "context_mode": c.state.context = c.state.context.model_copy(update={"execution_mode": "mock"}) else: - c.state.context = cast( - ResolvedNativeTestContext, ImpostorRunContext(ref=ref, generation=2, native_run_id="run") - ) + monkeypatch.setattr(c.state, "context", ImpostorRunContext(ref=ref, generation=2, native_run_id="run")) with pytest.raises(BuilderExecutionPolicyError): - c.target.restore_claimed( + methodcaller( + "restore_claimed", ref=ref, - generation=cast(int, generation), + generation=generation, dispatch_id="dispatch", lifecycle=c.lifecycle, - expected_payload=cast(NativeCheckpointPayload, expected), - ) + expected_payload=expected, + )(c.target) _assert_retained(c, cursor, saved) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_pending_decision_guard.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_pending_decision_guard.py index e9b827eb988..b2bd8016f2b 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_pending_decision_guard.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_pending_decision_guard.py @@ -1,10 +1,11 @@ """Post-read guard compares the complete frozen checkpoint identity.""" import importlib.util +from collections.abc import Mapping from types import ModuleType -from typing import cast import pytest +from pydantic import TypeAdapter from typing_extensions import TypedDict from models.dify_builder_native_tests import ( @@ -118,12 +119,12 @@ def test_guard_rejects_changed_generation_checkpoint_key_intent_scope_and_owner( "payload_digest": "d" * 64, } - def validate(**overrides: object) -> None: + def validate(**overrides: Mapping[str, object]) -> None: # Mutation cases retain the snapshot shape and change one selected value. - p = cast(PendingSnapshot, overrides.get("p", pending)) - o = cast(OperationSnapshot, overrides.get("o", operation)) - s = cast(StateSnapshot, overrides.get("s", state)) - checkpoint = cast(ContinuationSnapshot, overrides.get("checkpoint", cp)) + p = TypeAdapter(PendingSnapshot).validate_python(overrides.get("p", pending), strict=True) + o = TypeAdapter(OperationSnapshot).validate_python(overrides.get("o", operation), strict=True) + s = TypeAdapter(StateSnapshot).validate_python(overrides.get("s", state), strict=True) + checkpoint = TypeAdapter(ContinuationSnapshot).validate_python(overrides.get("checkpoint", cp), strict=True) m.validate_pending_snapshot( resolved=resolved, tenant_id="t", diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_runtime_bridge.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_runtime_bridge.py index 4b8865c4569..7fd06917030 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_runtime_bridge.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_runtime_bridge.py @@ -3,7 +3,9 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, cast +from operator import methodcaller +from types import MappingProxyType +from typing import TYPE_CHECKING from unittest.mock import Mock, patch import pytest @@ -15,15 +17,13 @@ from graphon.engine import Engine from graphon.entities.base_node_data import BaseNodeData from graphon.http import HttpResponse from graphon.nodes.base.node import Node -from graphon.runtime import InitParams, RuntimeState +from graphon.runtime import InitParams, RuntimeState, VariablePool from runtime_contracts.builder.native.contracts import NativeTestContextRef, OperationKey from services.dify_builder.native_testing.api_service import NativeTestApiService -from services.dify_builder.native_testing.authorization import EffectAuthorizationService from services.dify_builder.native_testing.control import ( BoundaryOwnerState, NativeControlAccepted, NativeControlRecord, - NativeControlSink, ) from services.dify_builder.native_testing.runtime import DifyNativeRuntime from services.dify_builder.native_testing.runtime_bridge import ( @@ -108,7 +108,7 @@ def test_marked_factory_requires_bridge_before_eager_model_setup(mocker: pytest_ init_params=InitParams( run_context={"_dify": context}, workflow_id="workflow", graph_config={}, call_depth=0 ), - runtime_state=cast(RuntimeState, object()), + runtime_state=RuntimeState(workflow_id="unbound", variable_pool=VariablePool(), start_at=0), ) eager.assert_not_called() @@ -133,10 +133,10 @@ def test_factory_preserves_bridge_on_runtime_clone_and_checks_owners(mocker: pyt ) factory = node_factory.DifyNodeFactory( init_params=init, - runtime_state=cast(RuntimeState, object()), + runtime_state=RuntimeState(workflow_id="unbound", variable_pool=VariablePool(), start_at=0), native_runtime=bridge, ) - clone = factory.with_runtime_state(cast(RuntimeState, object())) + clone = factory.with_runtime_state(RuntimeState(workflow_id="unbound", variable_pool=VariablePool(), start_at=0)) assert clone._native_runtime is bridge provider.assert_not_called() eager.assert_not_called() @@ -144,7 +144,7 @@ def test_factory_preserves_bridge_on_runtime_clone_and_checks_owners(mocker: pyt with pytest.raises(NativeContextUnavailable, match="native_context_mismatch"): node_factory.DifyNodeFactory( init_params=init, - runtime_state=cast(RuntimeState, object()), + runtime_state=RuntimeState(workflow_id="unbound", variable_pool=VariablePool(), start_at=0), native_runtime=bridge, ) @@ -161,7 +161,7 @@ def test_unmarked_factory_cannot_accept_native_bridge(mocker: pytest_mock.Mocker with pytest.raises(NativeContextUnavailable, match="native_context_missing"): node_factory.DifyNodeFactory( init_params=InitParams(run_context={"_dify": run}, workflow_id="w", graph_config={}, call_depth=0), - runtime_state=cast(RuntimeState, object()), + runtime_state=RuntimeState(workflow_id="unbound", variable_pool=VariablePool(), start_at=0), native_runtime=bridge, ) eager.assert_not_called() @@ -306,7 +306,7 @@ def test_association_rejects_foreign_or_changed_identity_without_context_read( if mutation == "node_id": foreign.id = "private-canary" if mutation == "runtime": - foreign.runtime_state = cast(RuntimeState, object()) + foreign.runtime_state = RuntimeState(workflow_id="unbound", variable_pool=VariablePool(), start_at=0) if mutation in {"class", "node_id", "runtime"}: with pytest.raises(NativeContextUnavailable, match="native_node_mismatch") as caught: prepared.attach(foreign) @@ -347,13 +347,14 @@ def test_preparation_requires_exact_owner_ref_and_explicit_control_dependencies( ) provider.assert_not_called() with pytest.raises(NativeContextUnavailable, match="native_control_unavailable"): - NativeRuntimeBridge( + methodcaller( + "__call__", state=bridge.state, collaborators=provider, node_arguments=lambda **_: {}, - boundary_owner=cast(BoundaryOwnerState, None), - control_sink=cast(NativeControlSink, None), - ) + boundary_owner=None, + control_sink=None, + )(NativeRuntimeBridge) def test_effect_revalidates_full_private_context_each_time(mocker: pytest_mock.MockerFixture) -> None: @@ -756,8 +757,8 @@ def test_preparation_public_expected_identity_cannot_be_replaced(mocker: pytest_ def test_constructor_argument_mapping_cannot_replace_control_dependencies(mocker: pytest_mock.MockerFixture) -> None: _, _, prepared, _, _ = preparation_fixture(mocker) - with pytest.raises(TypeError): - cast(dict[str, object], prepared.arguments)["native_control_owner"] = "private-canary" + assert isinstance(prepared.arguments, MappingProxyType) + assert isinstance(prepared.arguments["native_control_owner"], BoundaryOwnerState) @pytest.mark.parametrize("mutation", ["id", "runtime", "ref", "execution_id"]) @@ -773,7 +774,7 @@ def test_effect_rechecks_attached_identity_after_private_context_read( if mutation == "id": node.id = "private-canary" elif mutation == "runtime": - node.runtime_state = cast(RuntimeState, object()) + node.runtime_state = RuntimeState(workflow_id="unbound", variable_pool=VariablePool(), start_at=0) elif mutation == "ref": bridge._boundary_owner.ref = context.ref.model_copy(update={"invocation_id": "private-canary"}) else: @@ -814,7 +815,7 @@ def test_bound_node_getter_rejects_attached_identity_drift_without_context_read( if mutation == "node_id": node.id = "private-canary" elif mutation == "runtime": - node.runtime_state = cast(RuntimeState, object()) + node.runtime_state = RuntimeState(workflow_id="unbound", variable_pool=VariablePool(), start_at=0) elif mutation == "class": property_name = "__class__" foreign_class = type("ForeignMarkedNode", (type(node),), {"__slots__": ()}) @@ -872,7 +873,9 @@ def _delivery_runtime(mocker: pytest_mock.MockerFixture, case: LaunchCase) -> Di ) runtime.host = case.host runtime._journal = case.journal - runtime.authorization = cast(EffectAuthorizationService, case.authorization) + mocker.patch.object(runtime.authorization, "before_call", side_effect=case.authorization.before_call) + mocker.patch.object(runtime.authorization, "claim_dispatch", side_effect=case.authorization.claim_dispatch) + mocker.patch.object(runtime.authorization, "record_outcome", side_effect=case.authorization.record_outcome) runtime.code_owner = NativeCodeHostOwner(host=case.host, loader=api.loader) load.reset_mock() return runtime diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_stop_sealing.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_stop_sealing.py index e95b64e5630..608df65f6af 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_stop_sealing.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_stop_sealing.py @@ -6,11 +6,12 @@ from contextlib import contextmanager from copy import deepcopy from dataclasses import dataclass from datetime import UTC, datetime -from typing import Literal, cast +from typing import Literal import pytest from sqlalchemy import Select, inspect from sqlalchemy.orm import Session, sessionmaker +from typing_extensions import TypedDict from core.dify_builder.models import Actor from models.dify_builder import DifyBuilderExecutionRequest @@ -202,19 +203,26 @@ def case(monkeypatch: pytest.MonkeyPatch) -> StopSealCase: return StopSealCase(sql=sql, loader=loader, lifecycle=lifecycle) -def seal(case: StopSealCase, **changes: object) -> None: - values: dict[str, object] = { - "ref": REF, - "expected_generation": 3, - "dispatch_id": "dispatch", - "reason_code": "effect_denied", - } - values.update(changes) +class SealArguments(TypedDict, total=False, closed=True): + ref: NativeTestContextRef + expected_generation: int + dispatch_id: str + reason_code: str + + +def seal( + case: StopSealCase, + *, + ref: NativeTestContextRef = REF, + expected_generation: int = 3, + dispatch_id: str = "dispatch", + reason_code: str = "effect_denied", +) -> None: return case.lifecycle.seal_stop( - ref=cast(NativeTestContextRef, values["ref"]), - expected_generation=cast(int, values["expected_generation"]), - dispatch_id=cast(str, values["dispatch_id"]), - reason_code=cast(str, values["reason_code"]), + ref=ref, + expected_generation=expected_generation, + dispatch_id=dispatch_id, + reason_code=reason_code, ) @@ -241,9 +249,7 @@ def test_stop_seals_authority_after_commit_without_claiming_physical_cleanup(cas {"reason_code": "raw\nsecret"}, ], ) -def test_stop_rejects_foreign_or_malformed_control_without_mutation( - case: StopSealCase, change: dict[str, object] -) -> None: +def test_stop_rejects_foreign_or_malformed_control_without_mutation(case: StopSealCase, change: SealArguments) -> None: with pytest.raises(BuilderExecutionPolicyError): seal(case, **change) assert case.sql.request.state == "running" @@ -317,9 +323,9 @@ def test_sealed_request_cannot_be_finalized_as_completed_or_resumable_wait( native_status="succeeded" if status == "completed" else None, output_result_ref=None, ) - with pytest.raises(BuilderExecutionPolicyError, match="native_request_sealed"): + with Session() as session, pytest.raises(BuilderExecutionPolicyError, match="native_request_sealed"): case.lifecycle.finalize_dispatch_locked( - session=cast(Session, case.sql), + session=session, owned=owned, scope=case.loader.scope, dispatch_id="dispatch", diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_stream_runtime.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_stream_runtime.py index f876c233e43..1bde9e5b29d 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_stream_runtime.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_stream_runtime.py @@ -12,11 +12,10 @@ from collections.abc import Generator, Mapping from inspect import GEN_SUSPENDED, getgeneratorstate from threading import Event, Thread, current_thread from time import monotonic -from typing import Self, cast +from typing import Self import pytest from sqlalchemy import Select -from sqlalchemy.orm import Session, sessionmaker from core.dify_builder.models import Actor from graphon.enums import WorkflowExecutionStatus @@ -38,6 +37,7 @@ from services.dify_builder.native_testing.repository import OwnedNativeTestLoade from services.dify_builder.native_testing.state_store import NativeExecutionStateStore from services.dify_builder.native_testing.stream_runtime import DifyNativeStreamOwner from tests.unit_tests.services.dify_builder.native_testing.test_host_execution import LaunchCase, native_entry +from tests.unit_tests.services.dify_builder.native_testing.typed_boundaries import sql_session_factory def stream_class() -> type[DifyNativeStreamOwner]: @@ -78,7 +78,10 @@ class StreamCase: self.source_release.set() self.source_closed = [] self.source_finished = Event() - self.checkpoint_error = self.cleanup_error = self.read_error = self.source_error = None + self.checkpoint_error: Exception | None = None + self.cleanup_error: Exception | None = None + self.read_error: Exception | None = None + self.source_error: Exception | None = None self.saved: NativeCheckpointPayload | None = None if waiting: decision = ApprovalWait(pending_operation_id="pending", wait_reason="approval") @@ -140,13 +143,17 @@ class StreamCase: scalar = staticmethod(read) - self.sessions = SessionPort + self.sessions = sql_session_factory( + monkeypatch, SessionPort, methods=lambda boundary: {"scalar": boundary.scalar} + ) loader = OwnedNativeTestLoader( - cast(sessionmaker[Session], self.sessions), + self.sessions, actor=Actor(tenant_id=context.tenant_id, account_id=context.actor_id), resolver=NativeHttpBindingResolver(), ) - lifecycle = NativeTestLifecycleService(loader, state_store=cast(NativeExecutionStateStore, case.state)) + state = NativeExecutionStateStore(loader) + monkeypatch.setattr(state, "load_context", case.state.load_context) + lifecycle = NativeTestLifecycleService(loader, state_store=state) monkeypatch.setattr(lifecycle, "pause", self.pause) monkeypatch.setattr(lifecycle, "_mark_unknown", lambda **_kwargs: self.events.append("quarantine")) self.capacity = NativeHostCapacity(lifecycle, cleanup_verifier=case.lifetime) @@ -223,7 +230,7 @@ class StreamCase: return stream_class()( host=self.case.host, capacity=self.capacity, - session_factory=cast(sessionmaker[Session], self.sessions), + session_factory=self.sessions, state=self.case.state, authorization=self.case.authorization, journal=self.case.journal, diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_unconsumed_failure.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_unconsumed_failure.py index e3adffddb05..115834b4675 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_unconsumed_failure.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_unconsumed_failure.py @@ -3,10 +3,11 @@ from collections.abc import Callable, Mapping from datetime import UTC, datetime from pathlib import Path -from typing import Protocol, cast +from typing import Protocol, runtime_checkable import pytest import pytest_mock +from celery import Celery from dify_agent.protocol.schemas import RunEventsResponse from sqlalchemy import Table, select from sqlalchemy.orm import Session as SqlSession @@ -780,35 +781,49 @@ def test_periodic_worker_excludes_unproven_unknown_candidates( # Celery's installed typing omits these real Beat/signal/AMQP APIs. These # protocols describe the boundary; the test still executes the actual objects. +@runtime_checkable class _BeatScheduler(Protocol): def apply_async(self, entry: object, *, advance: bool) -> object: ... +@runtime_checkable class _PublishSignal(Protocol): def connect(self, receiver: Callable[..., None], *, weak: bool) -> None: ... def disconnect(self, receiver: Callable[..., None]) -> bool: ... +@runtime_checkable class _ConsumerQueues(Protocol): consume_from: Mapping[str, object] def select(self, include: list[str]) -> None: ... +@runtime_checkable class _RoutingAMQP(Protocol): queues: _ConsumerQueues +@runtime_checkable class _MemoryChannel(Protocol): def queue_purge(self, queue: str) -> int: ... +@runtime_checkable +class _EntryFactory(Protocol): + def __call__(self, *, name: str, task: str, app: Celery) -> object: ... + + +@runtime_checkable +class _SchedulerFactory(Protocol): + def __call__(self, *, app: Celery, lazy: bool) -> object: ... + + def test_periodic_worker_beat_routes_registered_task_to_builder_consumer_queue( monkeypatch: pytest.MonkeyPatch, ) -> None: """Beat must publish to the existing worker queue, not an unconsumed default.""" - from celery import Celery from celery.beat import ScheduleEntry, Scheduler from celery.signals import before_task_publish from kombu.transport.memory import Channel as MemoryTransportChannel @@ -831,13 +846,22 @@ def test_periodic_worker_beat_routes_registered_task_to_builder_consumer_queue( assert app.tasks[task_name].app is app with app.connection_for_write() as connection: assert isinstance(connection.channel(), MemoryTransportChannel) - queues = cast(_RoutingAMQP, app.amqp).queues + amqp = app.amqp + assert isinstance(amqp, _RoutingAMQP) + queues = amqp.queues + assert isinstance(queues, _ConsumerQueues) queues.select(["dify_builder"]) - entry = cast(Callable[..., object], ScheduleEntry)(name="dify_builder_reconcile", task=task_name, app=app) - signal = cast(_PublishSignal, before_task_publish) + entry_factory: object = ScheduleEntry + assert isinstance(entry_factory, _EntryFactory) + entry = entry_factory(name="dify_builder_reconcile", task=task_name, app=app) + signal = before_task_publish + assert isinstance(signal, _PublishSignal) signal.connect(capture_route, weak=False) try: - scheduler = cast(Callable[..., _BeatScheduler], Scheduler)(app=app, lazy=True) + scheduler_factory: object = Scheduler + assert isinstance(scheduler_factory, _SchedulerFactory) + scheduler = scheduler_factory(app=app, lazy=True) + assert isinstance(scheduler, _BeatScheduler) scheduler.apply_async(entry, advance=False) assert published == ["dify_builder"] assert published[0] in queues.consume_from @@ -845,4 +869,6 @@ def test_periodic_worker_beat_routes_registered_task_to_builder_consumer_queue( signal.disconnect(capture_route) with app.connection_for_write() as connection: for queue_name in ("celery", "dify_builder"): - cast(_MemoryChannel, connection.channel()).queue_purge(queue_name) + channel = connection.channel() + assert isinstance(channel, _MemoryChannel) + channel.queue_purge(queue_name) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/test_zero_http_admission.py b/api/tests/unit_tests/services/dify_builder/native_testing/test_zero_http_admission.py index e18622f9038..7d0a3a37625 100644 --- a/api/tests/unit_tests/services/dify_builder/native_testing/test_zero_http_admission.py +++ b/api/tests/unit_tests/services/dify_builder/native_testing/test_zero_http_admission.py @@ -6,10 +6,10 @@ units qualify no database authorization, persistence, host cleanup or external I import json from collections.abc import Callable, Generator -from contextlib import AbstractContextManager, contextmanager +from contextlib import contextmanager from copy import deepcopy from dataclasses import dataclass, field -from typing import NoReturn, cast +from typing import NoReturn import httpx import pytest @@ -33,6 +33,7 @@ from services.dify_builder.native_testing.context_binding import NativeAdmission from services.dify_builder.native_testing.control import BoundaryOwnerState from services.workflow_runtime.node_factory import DifyNodeFactory from tests.unit_tests.services.dify_builder.native_testing.test_observation_privacy import FixtureGraph, FixtureNode +from tests.unit_tests.services.dify_builder.native_testing.typed_boundaries import sql_session_factory @dataclass @@ -47,7 +48,7 @@ class ZeroHttpCase: workflow_reads: int = 0 permission_failure: int | None = None workflow_failure: str | None = None - sessions: Callable[[], AbstractContextManager[object]] | None = None + sessions: sessionmaker[OrmSession] | None = None forbidden_io: Callable[..., NoReturn] | None = None snapshot: http_binding.NativeHttpResourceSnapshot | None = None compiler: NativeAdmissionCompiler | None = None @@ -150,7 +151,7 @@ def zero_http_case(monkeypatch: pytest.MonkeyPatch) -> ZeroHttpCase: assert case.active == 0 assert actual_actor is actor assert app_id == "app" - assert factory is sessions + assert factory is case.sessions case.permissions.append((actual_actor.tenant_id, actual_actor.account_id, app_id)) if case.permission_failure == len(case.permissions): raise BuilderExecutionPolicyError("execution_permission_denied") @@ -163,11 +164,12 @@ def zero_http_case(monkeypatch: pytest.MonkeyPatch) -> ZeroHttpCase: monkeypatch.setattr(context_binding, "check_execution_permission", permission) monkeypatch.setattr(http_binding, "check_execution_permission", permission) monkeypatch.setattr(httpx.Client, "send", forbidden_io) - case.sessions, case.forbidden_io = sessions, forbidden_io - case.snapshot = http_binding.NativeHttpResourceSnapshot(session_factory=sessions) - case.compiler = NativeAdmissionCompiler( - session_factory=cast(sessionmaker[OrmSession], sessions), resources=case.snapshot + case.sessions = sql_session_factory( + monkeypatch, sessions, methods=lambda boundary: {"get": boundary.get, "scalar": boundary.scalar} ) + case.forbidden_io = forbidden_io + case.snapshot = http_binding.NativeHttpResourceSnapshot(session_factory=case.sessions) + case.compiler = NativeAdmissionCompiler(session_factory=case.sessions, resources=case.snapshot) return case @@ -191,8 +193,9 @@ def delivery_factory( # This unit replaces protected-object/RBAC readback, while preserving the # actual delivery, host, journal, control and collaborator composition. + assert case.sessions is not None api = NativeTestApiService( - session_factory=cast(sessionmaker[OrmSession], case.sessions), + session_factory=case.sessions, actor=case.actor, resolver=http_binding.NativeHttpBindingResolver(), admission=None, @@ -341,7 +344,7 @@ def test_zero_http_snapshot_preserves_profile_refusal_before_owner_reads(zero_ht actor=case.actor, app_id="app", workflow_id="workflow", - graph=cast(dict[str, object], case.graph), + graph=dict(case.graph), profile=http_binding.build_native_http_profile().model_copy(update={"implementation_revision": "foreign"}), ) assert case.permissions == [] @@ -415,4 +418,4 @@ def test_empty_manifest_does_not_enable_unsupported_native_collaborators(zero_ht assert compiled.context.resource_manifest == () factory, _, _ = delivery_factory(case, compiled.context) with pytest.raises(BuilderExecutionPolicyError, match="native_capability_unavailable$"): - factory.create_node(cast(dict[str, object], node)) + factory.create_node(dict(node)) diff --git a/api/tests/unit_tests/services/dify_builder/native_testing/typed_boundaries.py b/api/tests/unit_tests/services/dify_builder/native_testing/typed_boundaries.py new file mode 100644 index 00000000000..ae1005a5fcb --- /dev/null +++ b/api/tests/unit_tests/services/dify_builder/native_testing/typed_boundaries.py @@ -0,0 +1,97 @@ +"""Checked fixture rows and real SQLAlchemy objects with explicit IO boundaries.""" + +from collections.abc import Callable, Generator, Mapping, Sequence +from contextlib import AbstractContextManager, contextmanager +from types import TracebackType +from typing import Self, override + +import pytest +from sqlalchemy import Select +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.types import TypeDecorator + +type SqlMethods = Mapping[str, Callable[..., object]] + + +def checked_row[T](model: type[T], value: object) -> T: + """Require the selected fixture row to be an instance of its declared model.""" + assert isinstance(value, model) + return value + + +def checked_rows[T](model: type[T], values: Sequence[object]) -> list[T]: + """Validate selected rows while preserving the identity of each ORM object.""" + return [checked_row(model, value) for value in values] + + +def selected_rows[T](query: Select[tuple[T]], values: Sequence[object]) -> list[T]: + """Validate entity/scalar results against the Select's runtime type metadata.""" + selected_type = query.column_descriptions[0]["type"] + if isinstance(selected_type, TypeDecorator): + selected_type = selected_type.impl + result_type: type[T] = selected_type if isinstance(selected_type, type) else selected_type.python_type + return checked_rows(result_type, values) + + +def _bind_sql_methods(monkeypatch: pytest.MonkeyPatch, session: Session, methods: SqlMethods) -> None: + """Bind explicitly selected IO methods; all other Session behavior stays real.""" + for name, method in methods.items(): + monkeypatch.setattr(session, name, method) + + +def sql_session(monkeypatch: pytest.MonkeyPatch, methods: SqlMethods) -> Session: + """Construct a real Session with the fixture's declared SQL operations.""" + session = Session() + _bind_sql_methods(monkeypatch, session, methods) + return session + + +def sql_session_factory[Boundary]( + monkeypatch: pytest.MonkeyPatch, + read: Callable[[], AbstractContextManager[Boundary]], + write: Callable[[], AbstractContextManager[Boundary]] | None = None, + *, + methods: Callable[[Boundary], SqlMethods], +) -> sessionmaker[Session]: + """Retain fixture context entry/exit around a normally initialized Session. + + The context owns SQL timing and commit observations. No database durability + is claimed by the patched IO methods. + """ + + class BoundarySession(Session): + scope: AbstractContextManager[Boundary] + + @override + def __enter__(self) -> Self: + boundary = self.scope.__enter__() + _bind_sql_methods(monkeypatch, self, methods(boundary)) + return self + + @override + def __exit__( + self, + type_: type[BaseException] | None, + value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.scope.__exit__(type_, value, traceback) + + class BoundaryFactory(sessionmaker[Session]): + @override + def __call__(self, **local_kw: object) -> Session: + session = super().__call__(**local_kw) + assert isinstance(session, BoundarySession) + session.scope = read() + return session + + @override + @contextmanager + def begin(self) -> Generator[Session]: + session = super().__call__() + assert isinstance(session, BoundarySession) + session.scope = (write or read)() + with session: + yield session + + return BoundaryFactory(class_=BoundarySession) diff --git a/api/tests/unit_tests/services/dify_builder/test_advance_task.py b/api/tests/unit_tests/services/dify_builder/test_advance_task.py index a5b8001f422..5c4f18e6061 100644 --- a/api/tests/unit_tests/services/dify_builder/test_advance_task.py +++ b/api/tests/unit_tests/services/dify_builder/test_advance_task.py @@ -17,7 +17,7 @@ from __future__ import annotations from collections.abc import Callable, Iterator, Mapping from dataclasses import asdict -from typing import NoReturn, TypedDict, cast, override +from typing import NoReturn, TypedDict, override import pytest from sqlalchemy import create_engine @@ -1151,13 +1151,13 @@ def test_observer_publish_failure_is_best_effort_and_terminal_state_is_still_pub monkeypatch.setattr(mod, "WorkflowServiceDifyPort", FakeDifyPort) monkeypatch.setattr(mod, "build_dify_builder_agent", lambda **_kwargs: StubAgent()) - events: CapturedEvents = [] + events: list[tuple[str, Mapping[str, object]]] = [] released: list[tuple[str, str]] = [] def publish(session_id: str, event: Mapping[str, object]) -> None: if event["kind"] in {"canvas", "node", "progress"}: raise RuntimeError("progress bus unavailable") - events.append((session_id, cast(CapturedEvent, event))) + events.append((session_id, event)) monkeypatch.setattr(mod.progress_bus, "publish", publish) monkeypatch.setattr(mod.session_lock, "release", lambda sid, tok: released.append((sid, tok))) diff --git a/api/tests/unit_tests/services/dify_builder/test_agent_endpoint_lineage.py b/api/tests/unit_tests/services/dify_builder/test_agent_endpoint_lineage.py index cb406775ce7..4ef17cd5ba6 100644 --- a/api/tests/unit_tests/services/dify_builder/test_agent_endpoint_lineage.py +++ b/api/tests/unit_tests/services/dify_builder/test_agent_endpoint_lineage.py @@ -1,6 +1,5 @@ """Actual lineage policy; backend and protected state I/O are explicit doubles.""" -from typing import cast from unittest.mock import patch import pytest @@ -128,9 +127,9 @@ def owner( sessionmaker(), actor=Actor(account_id="actor", tenant_id="tenant"), resolver=AgentOperationBindingResolver() ) store = NativeExecutionStateStore(loader) - store.load_context = load_context - store.load_prepared_config = load_prepared_config - store.discover_agent_run = discover_agent_run + patch.object(store, "load_context", side_effect=load_context).start() + patch.object(store, "load_prepared_config", side_effect=load_prepared_config).start() + patch.object(store, "discover_agent_run", side_effect=discover_agent_run).start() def settlement_identity( **kwargs: object, @@ -140,7 +139,7 @@ def owner( raise BuilderExecutionPolicyError("native_agent_identity_mismatch") return load_context(**kwargs), load_prepared_config(**kwargs), saved - store.load_agent_settlement_identity = settlement_identity + patch.object(store, "load_agent_settlement_identity", side_effect=settlement_identity).start() backend = Client(base_url="http://agent.invalid") patch.object(backend, "read_run_identity_sync", side_effect=read_run_identity_sync).start() preparation = AgentPreparationService(state_store=store, backend=backend, workspace=None, config_builder=None) @@ -221,11 +220,12 @@ def test_caller_cannot_override_owned_run_or_parent(caller: dict[str, JsonValue] lineage.resolve(principal=principal, selection=selection, boundary="model", caller=caller) -def test_resolver_requires_authenticated_principal() -> None: +@pytest.mark.parametrize("principal", [None]) +def test_resolver_requires_authenticated_principal(principal: AgentServicePrincipal) -> None: lineage, _principal, selection, phase = owner(parent=False) # Deliberately violate the typed internal contract to test the runtime authentication guard. with pytest.raises(BuilderExecutionPolicyError): - lineage.resolve(principal=cast(AgentServicePrincipal, None), selection=selection, boundary="model", caller={}) + lineage.resolve(principal=principal, selection=selection, boundary="model", caller={}) assert phase == [] @@ -277,10 +277,11 @@ def test_validated_native_context_checks_exact_effect_but_never_spends_permit(ch if change == "ref": envelope = envelope.model_copy(update={"ref": envelope.ref.model_copy(update={"request_id": "foreign"})}) with Flask(__name__).test_request_context(): - from flask import Request from werkzeug.local import LocalProxy - g.agent_runtime_request = cast("LocalProxy[Request]", request)._get_current_object() + proxy: object = request + assert isinstance(proxy, LocalProxy) + g.agent_runtime_request = proxy._get_current_object() g.agent_runtime_context = context with pytest.raises(BuilderExecutionPolicyError, match=reason): claim_agent_endpoint( diff --git a/api/tests/unit_tests/services/dify_builder/test_agent_operation_owner.py b/api/tests/unit_tests/services/dify_builder/test_agent_operation_owner.py index cc2e3657606..174b68ed4d4 100644 --- a/api/tests/unit_tests/services/dify_builder/test_agent_operation_owner.py +++ b/api/tests/unit_tests/services/dify_builder/test_agent_operation_owner.py @@ -1,7 +1,7 @@ """The actual API dispatcher spends only a current, exact operation permit.""" from collections.abc import Generator -from typing import Never, Unpack, cast +from typing import Never, Unpack import pytest import sqlalchemy.orm @@ -12,7 +12,6 @@ from dify_agent.protocol.native_testing import ( AgentNativeTestRef, AgentOperationKey, ) -from flask import Request from sqlalchemy.orm import Session from typing_extensions import TypedDict @@ -647,8 +646,6 @@ def test_actual_inner_tool_service_never_prepares_or_dispatches_before_permissio def test_direct_service_cannot_lose_a_known_native_envelope_before_runtime() -> None: - from typing import cast - from flask import Flask, g, request from werkzeug.local import LocalProxy @@ -659,11 +656,13 @@ def test_direct_service_cannot_lose_a_known_native_envelope_before_runtime() -> lineage, principal, selection, _phase = owner() context = lineage.resolve(principal=principal, selection=selection, boundary="core_tool", caller={}) app = Flask(__name__) - with app.test_request_context(): + with app.test_request_context(), Session() as session: g.agent_runtime_context = context - g.agent_runtime_request = cast(LocalProxy[Request], request)._get_current_object() + proxy: object = request + assert isinstance(proxy, LocalProxy) + g.agent_runtime_request = proxy._get_current_object() with pytest.raises(BuilderExecutionPolicyError, match="native_endpoint_owner_mismatch"): - AgentToolInnerService().invoke(_request(), session=cast(Session, object())) + AgentToolInnerService().invoke(_request(), session=session) def test_confirmed_tool_error_never_returns_private_provider_text( @@ -680,8 +679,8 @@ def test_confirmed_tool_error_never_returns_private_provider_text( monkeypatch.setattr(module, "agent_operations_for_ref", lambda _ref: operations_service) request = _request() request.native_dispatch = envelope() - with pytest.raises(BuilderExecutionPolicyError) as error: - AgentToolInnerService().invoke(request, session=cast(Session, object())) + with Session() as session, pytest.raises(BuilderExecutionPolicyError) as error: + AgentToolInnerService().invoke(request, session=session) assert "PRIVATE_PROVIDER_TOKEN_CANARY" not in str(error.value) assert error.value.reason_code == "native_agent_tool_dispatch_failed" diff --git a/api/tests/unit_tests/services/dify_builder/test_agent_preparation_owner.py b/api/tests/unit_tests/services/dify_builder/test_agent_preparation_owner.py index baf84488882..837347d2b3b 100644 --- a/api/tests/unit_tests/services/dify_builder/test_agent_preparation_owner.py +++ b/api/tests/unit_tests/services/dify_builder/test_agent_preparation_owner.py @@ -2,7 +2,7 @@ from collections.abc import Callable, Generator, Sequence from contextlib import contextmanager -from typing import Never, NotRequired, Protocol, Unpack, cast +from typing import Never, NotRequired, Unpack import pytest from dify_agent.client import Client @@ -16,7 +16,7 @@ from sqlalchemy.orm import Session from typing_extensions import TypedDict from core.workflow.nodes.agent_v2.entities import DifyAgentNodeData -from graphon.runtime import InitParams, RuntimeState +from graphon.runtime import InitParams from models import agent as agent_models from models.dify_builder_native_tests import NativePayloadObject, NativeTestScope from runtime_contracts.builder.execution import ( @@ -35,7 +35,7 @@ from runtime_contracts.builder.native.runtime_contracts import ResolvedNativeTes from services.dify_builder.native_testing import state_store as state from services.dify_builder.native_testing.agent_operations import AgentOperationService from services.dify_builder.native_testing.agent_workspace import AgentTestWorkspaceService -from services.dify_builder.native_testing.control import BoundaryOwnerState, NativeControlSink +from services.dify_builder.native_testing.control import _NativeControlBoundary from services.dify_builder.native_testing.private_reads import PrivateReadOwner from services.dify_builder.native_testing.repository import OwnedNativeTest from services.dify_builder.native_testing.runtime_bridge import NativeNodePreparation @@ -58,20 +58,6 @@ class PrepareObjectArgs(TypedDict, closed=True): max_bytes: NotRequired[int | None] -class MarkedAgentConstructor(Protocol): - def __call__( - self, - *, - node_id: str, - data: DifyAgentNodeData, - init_params: InitParams, - runtime_state: RuntimeState, - native_control_owner: BoundaryOwnerState, - native_control_sink: NativeControlSink, - **kwargs: object, - ) -> DifyAgentNode: ... - - @pytest.fixture def agent_backend() -> Generator[Client]: with Client(base_url="http://agent.invalid") as client: @@ -251,7 +237,6 @@ def node_association( context: ResolvedNativeTestContext, data: DifyAgentNodeData, ) -> NativeNodePreparation: - from graphon.runtime import InitParams from services.dify_builder.native_testing.checkpoint_bridge import NativeCheckpointBridge from services.dify_builder.native_testing.control import marked_native_node_class from services.dify_builder.native_testing.host_execution import NativeHostLaunch @@ -259,7 +244,6 @@ def node_association( from services.dify_builder.native_testing.lifecycle import NativeTestLifecycleService from services.dify_builder.native_testing.operation_journal import NodeOperationJournal from services.dify_builder.native_testing.runtime_bridge import NativeNodePreparation, NativeRuntimeBridge - from services.workflow_runtime.agent_node import DifyAgentNode from tests.unit_tests.services.dify_builder.test_effect_mock import _factory store = operations_service.state_store @@ -295,9 +279,8 @@ def node_association( association = NativeNodePreparation( bridge=bridge, ref=context.ref, node_id="node", node_class=node_class, runtime_state=factory.runtime_state ) - # The factory documents the additional constructor arguments of its generated subclass. - constructor = cast(MarkedAgentConstructor, node_class) - node = constructor( + assert issubclass(node_class, _NativeControlBoundary) + node = node_class( node_id="node", data=data, init_params=InitParams( @@ -311,6 +294,7 @@ def node_association( native_control_sink=host.control_sink, **factory._build_agent_node_init_kwargs(node_class=DifyAgentNode), ) + assert isinstance(node, DifyAgentNode) association.attach(node) node.bind_execution_id(PARENT.execution_id) return association @@ -1143,11 +1127,11 @@ def test_current_published_binding_must_match_graph_outputs_and_routes( agent_id="agent", current_snapshot_id="pin", node_job_config=job, - binding_type=cast(WorkflowAgentBindingType, "foreign") - if mutation == "binding-kind" - else WorkflowAgentBindingType.ROSTER_AGENT, + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, ) + if mutation == "binding-kind": + monkeypatch.setattr(binding, "binding_type", "foreign") loader = operations_service.loader session = loader.session_factory() monkeypatch.setattr(session, "get", lambda model, _identity: snapshot if model is AgentConfigSnapshot else agent) diff --git a/api/tests/unit_tests/services/dify_builder/test_agent_tool_declarations.py b/api/tests/unit_tests/services/dify_builder/test_agent_tool_declarations.py index 274264fe760..3ee9dcd1c81 100644 --- a/api/tests/unit_tests/services/dify_builder/test_agent_tool_declarations.py +++ b/api/tests/unit_tests/services/dify_builder/test_agent_tool_declarations.py @@ -4,10 +4,9 @@ import importlib import importlib.util from collections.abc import Generator, Iterator from types import ModuleType -from typing import Literal, Never, cast +from typing import Literal, Never import pytest -from flask import Request as FlaskRequest from pydantic import JsonValue from sqlalchemy import Select from sqlalchemy.orm import Session @@ -960,7 +959,9 @@ def test_endpoint_lineage_requires_exact_envelope_before_physical_owner_refusal( "caller": "native_endpoint_owner_mismatch", }[case] with Flask(__name__).test_request_context(): - g.agent_runtime_request = cast("LocalProxy[FlaskRequest]", request)._get_current_object() + proxy: object = request + assert isinstance(proxy, LocalProxy) + g.agent_runtime_request = proxy._get_current_object() def claim_endpoint() -> None: g.agent_runtime_context = lineage.resolve( @@ -1274,7 +1275,9 @@ def test_endpoint_typed_json_guard_precedes_physical_owner_refusal( lambda ref: claims.append(ref) or pytest.fail("Controller claimed"), ) with app.test_request_context(): - g.agent_runtime_request = cast("LocalProxy[FlaskRequest]", request)._get_current_object() + proxy: object = request + assert isinstance(proxy, LocalProxy) + g.agent_runtime_request = proxy._get_current_object() g.agent_runtime_context = context reason = "native_endpoint_dependency_unavailable" if matches else "native_endpoint_arguments_mismatch" with pytest.raises(BuilderExecutionPolicyError, match=reason): diff --git a/api/tests/unit_tests/services/dify_builder/test_api_tool_output_context.py b/api/tests/unit_tests/services/dify_builder/test_api_tool_output_context.py index 49bca700d56..812cc6a4785 100644 --- a/api/tests/unit_tests/services/dify_builder/test_api_tool_output_context.py +++ b/api/tests/unit_tests/services/dify_builder/test_api_tool_output_context.py @@ -4,7 +4,6 @@ import json from collections.abc import Sequence from copy import deepcopy from pathlib import Path -from typing import cast import pytest from sqlalchemy.engine import Engine @@ -29,6 +28,7 @@ from tests.unit_tests.services.dify_builder.installed_tool_inventory import ( forbid_secret_and_business_calls, install_fake_daemon, ) +from tests.unit_tests.services.dify_builder.typed_fixtures import dictionaries, dictionary, list_value from tests.unit_tests.services.tools.test_api_tool_catalogue import ACTION, PROVIDER_ID, _add_provider type PublicApi = tuple[Engine, list[str]] @@ -53,7 +53,7 @@ def public_api(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> Public def _public_schema() -> dict[str, object]: fixture = Path(__file__).parents[1] / "tools" / "fixtures" / "qa_news_search_openapi.json" - return cast(dict[str, object], json.loads(fixture.read_text())) + return dictionary(json.loads(fixture.read_text())) def _response_schema() -> dict[str, object]: @@ -81,8 +81,8 @@ def _response_schema() -> dict[str, object]: def _entry(engine: Engine, response_schemas: dict[str, object] | None = None) -> ToolCatalogueEntry: schema = _public_schema() if response_schemas is not None: - paths = cast(dict[str, object], schema["paths"]) - operation = cast(dict[str, object], cast(dict[str, object], paths["/news"])["get"]) + paths = dictionary(schema["paths"]) + operation = dictionary(dictionary(paths["/news"])["get"]) operation["responses"] = response_schemas bundles = ApiBasedToolSchemaParser.parse_openapi_to_tool_bundle(schema) _add_provider(engine, tools=[bundle.model_dump(mode="json") for bundle in bundles]) @@ -167,8 +167,8 @@ def _capture_consumer( def _contracts(prompt: str) -> list[dict[str, object]]: marker = "# Native API output contracts\n\n" assert marker in prompt - payload = cast(dict[str, object], json.loads(prompt.split(marker, 1)[1].splitlines()[0])) - return cast(list[dict[str, object]], payload["tools"]) + payload = dictionary(json.loads(prompt.split(marker, 1)[1].splitlines()[0])) + return dictionaries(payload["tools"]) def test_direct_public_response_schema_stays_separate_from_native_outputs(public_api: PublicApi) -> None: @@ -279,9 +279,9 @@ def test_many_relevant_api_nodes_have_bounded_context_and_explicit_omissions(pub marker = "# Native API output contracts\n\n" assert marker in prompt context = prompt.split(marker, 1)[1].split("# Normalized plan and topology", 1)[0] - payload = cast(dict[str, object], json.loads(context.splitlines()[0])) + payload = dictionary(json.loads(context.splitlines()[0])) - assert len(cast(list[object], payload["tools"])) == 8 + assert len(list_value(payload["tools"])) == 8 assert payload["omitted_upstream_api_nodes"] == 32 assert len(context.encode()) < 16_000 assert forbidden == [] @@ -356,7 +356,7 @@ def test_api_code_system_rules_keep_undeclared_shapes_unknown( assert _SYSTEM_RULE_MARKER in system assert "If no direct response declaration establishes a shape, do not invent fields" in system assert '"news"' not in system - assert cast(dict[str, object], _contracts(user)[0]["response_contracts"])["schemas"] == [] + assert dictionary(_contracts(user)[0]["response_contracts"])["schemas"] == [] assert forbidden == [] diff --git a/api/tests/unit_tests/services/dify_builder/test_business_acceptance_integration.py b/api/tests/unit_tests/services/dify_builder/test_business_acceptance_integration.py index dbeeaa73e4c..85cadce0e3a 100644 --- a/api/tests/unit_tests/services/dify_builder/test_business_acceptance_integration.py +++ b/api/tests/unit_tests/services/dify_builder/test_business_acceptance_integration.py @@ -143,12 +143,16 @@ def test_contract_metadata_stays_out_of_start_inputs_and_invalid_only_cannot_pub assert cards[0].payload["business_acceptance_state"] == "passed" assert cards[0].payload["business_acceptance_case_id"] == "invalid" from services.dify_builder.service import DifyBuilderService + from tests.unit_tests.services.dify_builder.test_service import FakeSessionLock - service = object.__new__(DifyBuilderService) - service._repo = setup.repo - service._get_verification_identity_fn = lambda _app_id, _actor: ( - setup.dify.hash, - setup.dify.graph_revision(setup.dify.graph), + service = DifyBuilderService( + repo=setup.repo, + session_lock=FakeSessionLock(), + enqueue_fn=lambda *_args: pytest.fail("publication inspection must not enqueue work"), + get_verification_identity_fn=lambda _app_id, _actor: ( + setup.dify.hash, + setup.dify.graph_revision(setup.dify.graph), + ), ) assert not service._publication_for_session(s, fc, actor).allowed diff --git a/api/tests/unit_tests/services/dify_builder/test_card_options.py b/api/tests/unit_tests/services/dify_builder/test_card_options.py index 527b85e84be..21272e1c6ec 100644 --- a/api/tests/unit_tests/services/dify_builder/test_card_options.py +++ b/api/tests/unit_tests/services/dify_builder/test_card_options.py @@ -4,8 +4,6 @@ The action bar used to change shape per gate -- four buttons at review, one at plan approval. These project the same choice into the fixed interaction dock. """ -from typing import cast - import pytest from core.dify_builder.contract import CONFIRM_ACTION_ID, ActionKind, Decision @@ -184,6 +182,6 @@ class TestResolvingWhatTheClientPosted: ) @pytest.mark.parametrize("payload", [{}, None, {"option_id": ""}, {"option_id": None}, "not-a-dict"]) - def test_confirm_naming_no_option_resolves_to_nothing(self, payload: Graph | None | str) -> None: + def test_confirm_naming_no_option_resolves_to_nothing(self, payload: Graph | None) -> None: # "" is not legal in any state, so the existing per-state check rejects it. - assert resolve_submitted_action(CONFIRM_ACTION_ID, cast(Graph | None, payload)) == "" + assert resolve_submitted_action(CONFIRM_ACTION_ID, payload) == "" diff --git a/api/tests/unit_tests/services/dify_builder/test_code_risk_preparation.py b/api/tests/unit_tests/services/dify_builder/test_code_risk_preparation.py index bc87bd9545f..511a7b369df 100644 --- a/api/tests/unit_tests/services/dify_builder/test_code_risk_preparation.py +++ b/api/tests/unit_tests/services/dify_builder/test_code_risk_preparation.py @@ -414,7 +414,7 @@ def test_changed_code_input_redisplays_before_accepting_checkbox_and_reuses_show @pytest.mark.parametrize("flow_index", [0, 1, 2]) def test_stale_worker_risk_returns_existing_input_interaction_without_repair( - mocker: pytest_mock.MockerFixture, flow_index: int + mocker: pytest_mock.MockerFixture, flow_index: int, monkeypatch: pytest.MonkeyPatch ) -> None: from runtime_contracts.builder.execution import BuilderExecutionPolicyError @@ -424,8 +424,10 @@ def test_stale_worker_risk_returns_existing_input_interaction_without_repair( flow = FLOWS[flow_index] module, port, repo, env, session, turn = _fixture(flow, disclosed=False) fc = saved_test_context(repo, session.id) - port.run_draft = lambda *_, **__: dify_port._unsupported_run( - BuilderExecutionPolicyError("code_risk_assessment_stale") + monkeypatch.setattr( + port, + "run_draft", + lambda *_, **__: dify_port._unsupported_run(BuilderExecutionPolicyError("code_risk_assessment_stale")), ) mocker.patch.object( env.agent, "diagnose", side_effect=AssertionError("Stale disclosure is not a failed Code execution") diff --git a/api/tests/unit_tests/services/dify_builder/test_cognition_parameters.py b/api/tests/unit_tests/services/dify_builder/test_cognition_parameters.py index 3ce3eee6e31..b65823fb4f0 100644 --- a/api/tests/unit_tests/services/dify_builder/test_cognition_parameters.py +++ b/api/tests/unit_tests/services/dify_builder/test_cognition_parameters.py @@ -1,7 +1,5 @@ """Policy forwarding preserves trusted generator budgets and administrator caps.""" -from typing import cast - import pytest from extensions.application_services.dify_builder_cognition_policy import _ConfiguredInvocation @@ -14,6 +12,7 @@ from services.workflow_generation.runner import ( _clamp_for_tool_router, ) from tests.helpers.unit.core.model_fixtures import make_model_instance +from tests.unit_tests.services.dify_builder.typed_fixtures import dictionary def _invoke_planner(model: PolicyModelInstance, parameters: dict[str, object]) -> None: @@ -42,7 +41,7 @@ def test_actual_planner_budget_survives_inherited_empty_policy(monkeypatch: pyte _invoke_planner(proxy, {}) expected = _clamp_for_planner({}) assert requests[0]["model_parameters"] == expected - budget = cast(dict[str, object], requests[0]["model_parameters"])["max_tokens"] + budget = dictionary(requests[0]["model_parameters"])["max_tokens"] assert isinstance(budget, int) assert budget > 4096 diff --git a/api/tests/unit_tests/services/dify_builder/test_cognition_policy.py b/api/tests/unit_tests/services/dify_builder/test_cognition_policy.py index 5657ec5a845..15285d735fe 100644 --- a/api/tests/unit_tests/services/dify_builder/test_cognition_policy.py +++ b/api/tests/unit_tests/services/dify_builder/test_cognition_policy.py @@ -120,7 +120,9 @@ def test_configuration_rejects_secret_fields_and_unconfigured_fallback(subject: service.put(ACTOR, CognitionPolicy(True, (MODEL,), "primary", ("missing",), 0, "explicit"), expected_revision=0) -def test_unknown_primary_metadata_does_not_claim_available_fallback(subject: Subject) -> None: +def test_unknown_primary_metadata_does_not_claim_available_fallback( + subject: Subject, monkeypatch: pytest.MonkeyPatch +) -> None: service, policies, models = subject policies.policy = CognitionPolicy(True, (MODEL, OTHER), "primary", ("fallback",), 1, "explicit") @@ -130,7 +132,7 @@ def test_unknown_primary_metadata_does_not_claim_available_fallback(subject: Sub raise RuntimeError("metadata unavailable") return True - models.available = uncertain + monkeypatch.setattr(models, "available", uncertain) status = service.availability(ACTOR) assert status.health == {"primary": "unknown", "fallback": "configured"} assert not status.available @@ -212,7 +214,9 @@ def test_absent_policy_keeps_valid_native_model_and_parameter_selection( @pytest.mark.parametrize("default_broken", [False, True]) -def test_requested_native_selection_does_not_need_a_workspace_default(subject: Subject, default_broken: bool) -> None: +def test_requested_native_selection_does_not_need_a_workspace_default( + subject: Subject, default_broken: bool, monkeypatch: pytest.MonkeyPatch +) -> None: service, _, models = subject default_reads: list[int] = [] @@ -223,7 +227,7 @@ def test_requested_native_selection_does_not_need_a_workspace_default(subject: S raise RuntimeError("default metadata unavailable") return None - models.workspace_default = missing_default + monkeypatch.setattr(models, "workspace_default", missing_default) selected = service.select(ACTOR, OTHER.config()) assert selected.config() == OTHER.config() service.authorize_selected(ACTOR, selected) @@ -231,7 +235,9 @@ def test_requested_native_selection_does_not_need_a_workspace_default(subject: S @pytest.mark.parametrize("reason", ["unconfigured", "disabled", "private_credential", "foreign_credential"]) -def test_absent_policy_refuses_native_unavailable_session_choice(subject: Subject, reason: str) -> None: +def test_absent_policy_refuses_native_unavailable_session_choice( + subject: Subject, reason: str, monkeypatch: pytest.MonkeyPatch +) -> None: service, _, models = subject inspected: list[str] = [] @@ -240,14 +246,16 @@ def test_absent_policy_refuses_native_unavailable_session_choice(subject: Subjec inspected.append(model.name) return False - models.available = unavailable + monkeypatch.setattr(models, "available", unavailable) requested = {**OTHER.config(), "name": reason} with pytest.raises(CognitionPolicyDeniedError, match="model_unavailable"): service.select(ACTOR, requested) assert inspected == [reason] -def test_absent_policy_keeps_unknown_native_metadata_unknown_without_default_fallback(subject: Subject) -> None: +def test_absent_policy_keeps_unknown_native_metadata_unknown_without_default_fallback( + subject: Subject, monkeypatch: pytest.MonkeyPatch +) -> None: service, _, models = subject inspected: list[str] = [] @@ -256,7 +264,7 @@ def test_absent_policy_keeps_unknown_native_metadata_unknown_without_default_fal inspected.append(model.name) raise CognitionPolicyDeniedError("model_availability_unknown") - models.available = unknown + monkeypatch.setattr(models, "available", unknown) with pytest.raises(CognitionPolicyDeniedError, match="model_availability_unknown"): service.select(ACTOR, OTHER.config()) assert inspected == [OTHER.name] @@ -286,7 +294,7 @@ def test_absent_policy_rejects_untrusted_fields_before_native_lookup( @pytest.mark.parametrize("change", ["restrict", "disable", "membership"]) @pytest.mark.parametrize("requested", [None, OTHER.config()]) def test_metadata_cannot_outlive_new_explicit_policy_or_revoked_membership( - subject: Subject, change: str, requested: dict[str, object] | None + subject: Subject, change: str, requested: dict[str, object] | None, monkeypatch: pytest.MonkeyPatch ) -> None: service, policies, models = subject inspected: list[str] = [] @@ -301,7 +309,7 @@ def test_metadata_cannot_outlive_new_explicit_policy_or_revoked_membership( policies.policy = CognitionPolicy(change != "disable", models_allowed, "new", (), 1, "explicit") return True - models.available = changed + monkeypatch.setattr(models, "available", changed) reason = {"restrict": "model_not_allowed", "disable": "builder_disabled", "membership": "policy_permission_denied"}[ change ] diff --git a/api/tests/unit_tests/services/dify_builder/test_cognition_policy_repository.py b/api/tests/unit_tests/services/dify_builder/test_cognition_policy_repository.py index 54a9eb4c44c..9ce1a1f8b47 100644 --- a/api/tests/unit_tests/services/dify_builder/test_cognition_policy_repository.py +++ b/api/tests/unit_tests/services/dify_builder/test_cognition_policy_repository.py @@ -1,7 +1,6 @@ """Policy CAS, membership and tenant isolation through real SQL sessions.""" from collections.abc import Iterator -from typing import cast import pytest from sqlalchemy import Table, create_engine @@ -28,7 +27,8 @@ type SqlContext = tuple[SqlDifyBuilderRepository, sessionmaker[Session]] def sql() -> Iterator[SqlContext]: engine = create_engine("sqlite://") for table in (Tenant.__table__, TenantAccountJoin.__table__, DifyBuilderCognitionPolicy.__table__): - cast(Table, table).create(engine) + assert isinstance(table, Table) + table.create(engine) factory = sessionmaker(bind=engine) with factory() as session, session.begin(): tenant = Tenant(name="test", custom_config='{"branding":"preserved"}') diff --git a/api/tests/unit_tests/services/dify_builder/test_condition_evidence.py b/api/tests/unit_tests/services/dify_builder/test_condition_evidence.py index 747089adae3..253629761e7 100644 --- a/api/tests/unit_tests/services/dify_builder/test_condition_evidence.py +++ b/api/tests/unit_tests/services/dify_builder/test_condition_evidence.py @@ -5,7 +5,7 @@ from collections.abc import Iterator from copy import deepcopy from dataclasses import dataclass from datetime import datetime -from typing import Never, cast, override +from typing import Never, override from unittest.mock import MagicMock, patch import pytest @@ -909,11 +909,13 @@ def test_runtime_handler_write_reloads_trusted_failed_run_and_refuses_value_only native_adapter.sync.assert_not_called() -def test_runtime_non_if_fix_keeps_existing_native_write_behavior(native_adapter: NativeEvidenceCase) -> None: +def test_runtime_non_if_fix_keeps_existing_native_write_behavior( + native_adapter: NativeEvidenceCase, monkeypatch: pytest.MonkeyPatch +) -> None: native_adapter.rows[0].status = WorkflowNodeExecutionStatus.FAILED native_adapter.rows[0].error = "NameError: missing_variable" # Deliberately malformed persisted status must not authorize a failed-node repair. - native_adapter.rows[1].status = cast(WorkflowNodeExecutionStatus, "skipped") + monkeypatch.setattr(native_adapter.rows[1], "status", "skipped") intent = MutationIntent( op="set_node_config", args={"node_id": "producer", "path": "code", "value": "def main(): return {}"} ) @@ -1013,14 +1015,14 @@ def test_review_r1_handled_exception_cannot_hide_terminal_failed_if( ) @pytest.mark.parametrize("boundary", ["proposal", "write"]) def test_review_r1_r2_unknown_failure_identity_cannot_authorize_producer_rewrite( - native_adapter: NativeEvidenceCase, kind: str, boundary: str + native_adapter: NativeEvidenceCase, kind: str, boundary: str, monkeypatch: pytest.MonkeyPatch ) -> None: if kind == "two-terminal-failures": native_adapter.rows[0].status = WorkflowNodeExecutionStatus.FAILED elif kind == "only-handled-exception": native_adapter.rows[0].status = WorkflowNodeExecutionStatus.EXCEPTION # Deliberately malformed persisted status must not authorize a failed-node repair. - native_adapter.rows[1].status = cast(WorkflowNodeExecutionStatus, "skipped") + monkeypatch.setattr(native_adapter.rows[1], "status", "skipped") elif kind == "missing-run": repository = native_adapter.repositories.create_api_workflow_run_repository.return_value repository.get_workflow_run_by_id.return_value = None @@ -1129,11 +1131,11 @@ def test_review_r4_normalized_explicit_credentials_never_reach_prompts_or_eviden @pytest.mark.parametrize("boundary", ["proposal", "write"]) def test_review_r2_missing_history_cannot_use_current_non_if_type( - native_adapter: NativeEvidenceCase, boundary: str + native_adapter: NativeEvidenceCase, boundary: str, monkeypatch: pytest.MonkeyPatch ) -> None: native_adapter.rows[0].status = WorkflowNodeExecutionStatus.FAILED # Deliberately malformed persisted status must not authorize a failed-node repair. - native_adapter.rows[1].status = cast(WorkflowNodeExecutionStatus, "skipped") + monkeypatch.setattr(native_adapter.rows[1], "status", "skipped") native_adapter.run.graph = json.dumps({}) if boundary == "proposal": _, intents, _, _ = _real_context_proposal(native_adapter) @@ -1212,11 +1214,13 @@ def test_review_r2_unavailable_context_rejects_schema_and_routing( native_adapter.sync.assert_not_called() -def test_review_r2_successful_run_cannot_authorize_a_terminal_non_if_repair(native_adapter: NativeEvidenceCase) -> None: +def test_review_r2_successful_run_cannot_authorize_a_terminal_non_if_repair( + native_adapter: NativeEvidenceCase, monkeypatch: pytest.MonkeyPatch +) -> None: native_adapter.run.status = WorkflowExecutionStatus.SUCCEEDED native_adapter.rows[0].status = WorkflowNodeExecutionStatus.FAILED # Deliberately malformed persisted status must not authorize a failed-node repair. - native_adapter.rows[1].status = cast(WorkflowNodeExecutionStatus, "skipped") + monkeypatch.setattr(native_adapter.rows[1], "status", "skipped") with pytest.raises(PreflightError): apply_native( native_adapter, diff --git a/api/tests/unit_tests/services/dify_builder/test_confirmed_tool_launch.py b/api/tests/unit_tests/services/dify_builder/test_confirmed_tool_launch.py index 52614caf132..13091a6c632 100644 --- a/api/tests/unit_tests/services/dify_builder/test_confirmed_tool_launch.py +++ b/api/tests/unit_tests/services/dify_builder/test_confirmed_tool_launch.py @@ -16,7 +16,7 @@ from dataclasses import asdict, dataclass from datetime import datetime, timedelta from threading import Thread from types import ModuleType -from typing import TYPE_CHECKING, Literal, Never, NoReturn, cast, override +from typing import TYPE_CHECKING, Literal, Never, NoReturn, override from uuid import uuid4 import pytest @@ -85,7 +85,6 @@ from tests.unit_tests.services.dify_builder.test_real_call_consent_service impor ) if TYPE_CHECKING: - from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository from services.dify_builder.application.execution_policy import BuilderExecutionContext from services.dify_builder.dify_port import ConfirmedLaunchRecorder from services.dify_builder.real_call_consent_service import ( @@ -98,10 +97,8 @@ if TYPE_CHECKING: def as_env_collaborators( port: FakeDifyPort, agent: StubAgent, repo: "Repository | InMemoryRepository" ) -> tuple[DifyPort, DifyBuilderAgent, Repository]: - """The shared fakes (``fakes.py``, not a Task 6 owner) name parameters with a leading - underscore, so they are not structurally assignable to the Protocols; they implement - every member the engine calls. One explicit, local conversion instead of Any.""" - return cast(DifyPort, port), cast(DifyBuilderAgent, agent), cast(Repository, repo) + """Expose the shared collaborators through their checked application protocols.""" + return port, agent, repo # The internal reserved-command action kind; a literal here so this module never @@ -1964,6 +1961,10 @@ def _drive_generator_draft_saving( from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig from core.app.apps.draft_variable_saver import DraftVariableSaverFactory from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, WorkflowAppGenerateEntity + from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository + from core.repositories.sqlalchemy_workflow_node_execution_repository import ( + SQLAlchemyWorkflowNodeExecutionRepository, + ) from graphon.enums import BuiltinNodeTypes from models.account import Account from models.model import App, AppMode @@ -2050,15 +2051,28 @@ def _drive_generator_draft_saving( call_depth=0, workflow_execution_id=str(uuid4()), ) - native_execution_repository, native_node_repository = object(), object() + native_execution_repository = SQLAlchemyWorkflowExecutionRepository( + session_factory=owner.factory, + tenant_id=owner.actor.tenant_id, + user=account, + app_id=owner.app_id, + triggered_from=WorkflowRunTriggeredFrom.DEBUGGING, + ) + native_node_repository = SQLAlchemyWorkflowNodeExecutionRepository( + session_factory=owner.factory, + tenant_id=owner.actor.tenant_id, + user=account, + app_id=owner.app_id, + triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, + ) stream = generator._generate( app_model=app, workflow=workflow, user=account, application_generate_entity=entity, invoke_from=InvokeFrom.DEBUGGER, - workflow_execution_repository=cast("WorkflowExecutionRepository", native_execution_repository), - workflow_node_execution_repository=cast("WorkflowNodeExecutionRepository", native_node_repository), + workflow_execution_repository=native_execution_repository, + workflow_node_execution_repository=native_node_repository, streaming=True, ) assert isinstance(stream, Iterator) diff --git a/api/tests/unit_tests/services/dify_builder/test_conflict_chain.py b/api/tests/unit_tests/services/dify_builder/test_conflict_chain.py index 21f9a7ec38b..254c20481f7 100644 --- a/api/tests/unit_tests/services/dify_builder/test_conflict_chain.py +++ b/api/tests/unit_tests/services/dify_builder/test_conflict_chain.py @@ -5,7 +5,7 @@ from __future__ import annotations import json from collections.abc import Callable, Mapping from dataclasses import asdict -from typing import TYPE_CHECKING, TypedDict, Unpack, cast +from typing import TYPE_CHECKING, TypedDict, Unpack import pytest @@ -57,6 +57,7 @@ from tests.unit_tests.services.dify_builder.test_advance_task import ( ) from tests.unit_tests.services.dify_builder.test_dify_port import PortDatabase from tests.unit_tests.services.dify_builder.test_execution_policy_service import owned as owned # noqa: PLC0414 +from tests.unit_tests.services.dify_builder.typed_fixtures import dictionaries, dictionary if TYPE_CHECKING: from sqlalchemy.orm import Session as DatabaseSession @@ -426,8 +427,8 @@ def test_interrupted_conflict_is_classified_before_working_handler_can_resume( assert not port.published recovered = events[-1][1] assert recovered["kind"] == "command_finished" - assert cast(dict[str, object], recovered["recovery"])["recovery_class"] == drift - actions = cast(list[dict[str, object]], recovered["actions"]) + assert dictionary(recovered["recovery"])["recovery_class"] == drift + actions = dictionaries(recovered["actions"]) if drift == "structural_invalidating": assert [action["id"] for action in actions] == ["restart"] with pytest.raises(BadRequestError): diff --git a/api/tests/unit_tests/services/dify_builder/test_conversation_lifecycle.py b/api/tests/unit_tests/services/dify_builder/test_conversation_lifecycle.py index 98cd08b364f..c00df298658 100644 --- a/api/tests/unit_tests/services/dify_builder/test_conversation_lifecycle.py +++ b/api/tests/unit_tests/services/dify_builder/test_conversation_lifecycle.py @@ -65,12 +65,12 @@ def forbidden(*_args: object, **_kwargs: object) -> NoReturn: def test_clear_requires_confirmation_and_permanently_hides_history_without_graph_reads( - service: DifyBuilderService, repo: SqlDifyBuilderRepository + service: DifyBuilderService, repo: SqlDifyBuilderRepository, monkeypatch: pytest.MonkeyPatch ) -> None: session = seed(repo) service._get_app_revision_fn = forbidden service._read_task_dsl_fn = forbidden - service._build_session_view = forbidden + monkeypatch.setattr(service, "_build_session_view", forbidden) service._enqueue_fn = forbidden owner = lifecycle() with pytest.raises(BadRequestError): @@ -124,7 +124,7 @@ def test_clear_fences_late_insert_in_its_sql_transaction( @pytest.mark.parametrize("settlement", ["settled", "pending", "unknown"]) def test_clear_reload_projects_durable_settlement_without_reviving_history( - service: DifyBuilderService, repo: SqlDifyBuilderRepository, settlement: str + service: DifyBuilderService, repo: SqlDifyBuilderRepository, settlement: str, monkeypatch: pytest.MonkeyPatch ) -> None: from copy import deepcopy @@ -145,7 +145,7 @@ def test_clear_reload_projects_durable_settlement_without_reviving_history( assert receipt["settlement"] == settlement service._get_app_revision_fn = forbidden service._read_task_dsl_fn = forbidden - service._build_session_view = forbidden + monkeypatch.setattr(service, "_build_session_view", forbidden) service._enqueue_fn = forbidden view = service.get_session_view(session.id, _actor()) assert view.lifecycle_settlement == settlement @@ -299,10 +299,10 @@ def test_clear_keeps_run_evidence_and_rejects_cross_owner_or_key_reuse( def test_abandon_keeps_transcript_but_invalidates_cards_and_switch_stop_is_view_free( - service: DifyBuilderService, repo: SqlDifyBuilderRepository + service: DifyBuilderService, repo: SqlDifyBuilderRepository, monkeypatch: pytest.MonkeyPatch ) -> None: session = seed(repo) - service._build_session_view = forbidden + monkeypatch.setattr(service, "_build_session_view", forbidden) owner = lifecycle() stopped, context = owner.stop_task_for_switch( service, session.id, _actor(), expected_task_id=session.id + ":0", client_action_id="switch" diff --git a/api/tests/unit_tests/services/dify_builder/test_dify_port_preflight.py b/api/tests/unit_tests/services/dify_builder/test_dify_port_preflight.py index 7a426fd92ce..56d860c3643 100644 --- a/api/tests/unit_tests/services/dify_builder/test_dify_port_preflight.py +++ b/api/tests/unit_tests/services/dify_builder/test_dify_port_preflight.py @@ -28,6 +28,7 @@ from tests.unit_tests.services.dify_builder.test_dify_port import ( _workflow, ) from tests.unit_tests.services.dify_builder.test_native_http_output_contract import http_graph +from tests.unit_tests.services.dify_builder.typed_fixtures import dictionary @pytest.fixture @@ -85,7 +86,7 @@ def _apply(mock_session: Session, graph_dict: Graph, intents: list[MutationInten def test_apply_repair_refuses_numeric_http_status_string_operator_before_sync(mock_session: Session) -> None: - graph = http_graph("=", "200") + graph = dictionary(http_graph("=", "200")) original = deepcopy(graph) intent = MutationIntent( op="set_node_config", diff --git a/api/tests/unit_tests/services/dify_builder/test_edit_plan_generation.py b/api/tests/unit_tests/services/dify_builder/test_edit_plan_generation.py index 9dbbb295778..8052dd45917 100644 --- a/api/tests/unit_tests/services/dify_builder/test_edit_plan_generation.py +++ b/api/tests/unit_tests/services/dify_builder/test_edit_plan_generation.py @@ -3,7 +3,6 @@ import json from collections.abc import Sequence from copy import deepcopy -from typing import cast import pytest @@ -33,7 +32,9 @@ class RecordingModel: self.systems: list[str] = [] def invoke_llm(self, **kwargs: object) -> Reply: - messages = cast(list[SystemPromptMessage | UserPromptMessage], kwargs["prompt_messages"]) + messages = kwargs["prompt_messages"] + assert isinstance(messages, list) + assert all(isinstance(message, (SystemPromptMessage, UserPromptMessage)) for message in messages) assert isinstance(messages[1].content, str) assert isinstance(messages[0].content, str) self.systems.append(messages[0].content) diff --git a/api/tests/unit_tests/services/dify_builder/test_execution_policy_service.py b/api/tests/unit_tests/services/dify_builder/test_execution_policy_service.py index f242b79151d..65249281445 100644 --- a/api/tests/unit_tests/services/dify_builder/test_execution_policy_service.py +++ b/api/tests/unit_tests/services/dify_builder/test_execution_policy_service.py @@ -9,7 +9,7 @@ from collections.abc import Iterator from datetime import datetime from pathlib import Path from types import ModuleType -from typing import Literal, NoReturn, Unpack, cast +from typing import Literal, NoReturn, Unpack from uuid import uuid4 import pytest @@ -576,7 +576,7 @@ def test_all_handlers_persist_actual_server_stamp( port = FakeDifyPort() port.graph = graph(http=True) port.hash = revision - port.stamp_http_fixtures = dify_port.WorkflowServiceDifyPort().stamp_http_fixtures + monkeypatch.setattr(port, "stamp_http_fixtures", dify_port.WorkflowServiceDifyPort().stamp_http_fixtures) repo = InMemoryRepository() env = Env(dify=port, repo=repo, agent=StubAgent(), now=lambda: datetime.min) session = BuilderSession( @@ -777,7 +777,9 @@ def test_native_rbac_agent_app_binding_precedes_maintainer_bypass( from services.enterprise.rbac_service import RBACService factory, actor, app, workflow, sid, tid = owned - cast(Table, Agent.__table__).create(factory.kw["bind"], checkfirst=True) + table = Agent.__table__ + assert isinstance(table, Table) + table.create(factory.kw["bind"], checkfirst=True) with factory.begin() as db: row = db.get(App, app.id) assert row is not None diff --git a/api/tests/unit_tests/services/dify_builder/test_initial_chatflow.py b/api/tests/unit_tests/services/dify_builder/test_initial_chatflow.py index 85fa5353ad7..281a1f0824a 100644 --- a/api/tests/unit_tests/services/dify_builder/test_initial_chatflow.py +++ b/api/tests/unit_tests/services/dify_builder/test_initial_chatflow.py @@ -5,7 +5,7 @@ import json from collections.abc import Callable from dataclasses import asdict from datetime import datetime -from typing import Unpack, cast, override +from typing import Unpack, override import pytest from typing_extensions import TypedDict @@ -34,7 +34,6 @@ from services.dify_builder.application.handlers_build import ( handle_resource_recommendation, ) from services.dify_builder.application.placeholder_agent import PlaceholderAgent -from services.dify_builder.application.ports import DifyPort, Repository from services.dify_builder.application.runner import Env from services.dify_builder.initial_draft import build_intents_for_draft, is_initial_chatflow from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort, InMemoryRepository @@ -216,8 +215,7 @@ def _build_env( port.graph = copy.deepcopy(initial_graph) agent = _GeneratedAgent(intents) repo = InMemoryRepository() - # The shared fakes use underscored parameter names; these handlers call them positionally. - env = Env(dify=cast(DifyPort, port), agent=agent, repo=cast(Repository, repo), now=lambda: datetime.min) + env = Env(dify=port, agent=agent, repo=repo, now=lambda: datetime.min) actor = Actor(account_id="account", tenant_id="tenant") session = Session( app_id="app", diff --git a/api/tests/unit_tests/services/dify_builder/test_json_tool_runtime.py b/api/tests/unit_tests/services/dify_builder/test_json_tool_runtime.py index cb672982ce0..cea89cf4ec4 100644 --- a/api/tests/unit_tests/services/dify_builder/test_json_tool_runtime.py +++ b/api/tests/unit_tests/services/dify_builder/test_json_tool_runtime.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Generator, Mapping from copy import deepcopy from dataclasses import dataclass, replace from pathlib import Path -from typing import Never, cast +from typing import Never import pytest from sqlalchemy.engine import Engine @@ -830,7 +830,7 @@ def test_metadata_and_native_dispatch_have_no_active_own_transaction( super().__init__(bind=bind, autoflush=autoflush, expire_on_commit=expire_on_commit) sessions.append(self) - maker = sessionmaker(sqlite_engine, class_=TrackedSession) + maker = sessionmaker[Session](sqlite_engine, class_=TrackedSession) transaction_states = [] def metadata_reader(_tenant: object) -> InstalledJsonParse: @@ -843,9 +843,7 @@ def test_metadata_and_native_dispatch_have_no_active_own_transaction( yield _message("ok") monkeypatch.setattr(plugin_tool_module, "PluginToolManager", Manager) - runtime, recorder = _runtime( - monkeypatch, sqlite_engine, metadata_reader=metadata_reader, maker=cast(sessionmaker[Session], maker) - ) + runtime, recorder = _runtime(monkeypatch, sqlite_engine, metadata_reader=metadata_reader, maker=maker) handle = runtime.get_runtime( node_id="node2", node_data=_node_data(ascii_value=False, wrapped=False), diff --git a/api/tests/unit_tests/services/dify_builder/test_native_api_output_contract.py b/api/tests/unit_tests/services/dify_builder/test_native_api_output_contract.py index 0b84775eacf..989dcd7af34 100644 --- a/api/tests/unit_tests/services/dify_builder/test_native_api_output_contract.py +++ b/api/tests/unit_tests/services/dify_builder/test_native_api_output_contract.py @@ -1,12 +1,12 @@ """Native ApiTool envelopes stay distinct from controlled HTTP response fields.""" from copy import deepcopy -from typing import cast import pytest -from core.workflow.generator.types import GraphDict, GraphNodeDict, WorkflowGenerateErrorDict +from core.workflow.generator.types import GraphDict, WorkflowGenerateErrorDict from services.workflow_generation.runner import WorkflowGenerator +from tests.unit_tests.services.dify_builder.typed_fixtures import dictionaries, graph_node, graph_payload, strings PROVIDER = "14076462-4152-4ef0-aad2-3eaea9369b1a" @@ -29,45 +29,38 @@ def _graph(selector: list[str], **overrides: object) -> GraphDict: "output_schema": {}, } tool.update(overrides) - return cast( - GraphDict, - { - "nodes": [ + return graph_payload( + [ + graph_node( + "node1", { - "id": "node1", - "data": { - "type": "start", - "title": "Start", - "variables": [{"variable": "keyword", "type": "text-input", "required": True}], - }, + "type": "start", + "title": "Start", + "variables": [{"variable": "keyword", "type": "text-input", "required": True}], }, - {"id": "node2", "data": tool}, + ), + graph_node("node2", tool), + graph_node( + "node3", { - "id": "node3", - "data": { - "type": "code", - "title": "Project result", - "code_language": "python3", - "code": "def main(payload):\n return {'result': payload}", - "variables": [{"variable": "payload", "value_selector": selector}], - "outputs": {"result": {"type": "array[object]"}}, - }, + "type": "code", + "title": "Project result", + "code_language": "python3", + "code": "def main(payload):\n return {'result': payload}", + "variables": [{"variable": "payload", "value_selector": selector}], + "outputs": {"result": {"type": "array[object]"}}, }, + ), + graph_node( + "node4", { - "id": "node4", - "data": { - "type": "end", - "title": "End", - "outputs": [{"variable": "result", "value_selector": ["node3", "result"]}], - }, + "type": "end", + "title": "End", + "outputs": [{"variable": "result", "value_selector": ["node3", "result"]}], }, - ], - "edges": [ - {"source": "node1", "target": "node2"}, - {"source": "node2", "target": "node3"}, - {"source": "node3", "target": "node4"}, - ], - }, + ), + ], + [("node1", "node2"), ("node2", "node3"), ("node3", "node4")], ) @@ -77,7 +70,7 @@ def _process(graph: GraphDict) -> tuple[GraphDict, list[WorkflowGenerateErrorDic def _selector(graph: GraphDict) -> list[str]: - return cast(list[str], graph["nodes"][2]["data"]["variables"][0]["value_selector"]) + return strings(graph["nodes"][2]["data"]["variables"][0]["value_selector"]) @pytest.mark.parametrize("root", ["news", "result", "payload"]) @@ -204,7 +197,7 @@ def test_native_api_literal_selector_keys_are_not_reference_fields(container: st from services.dify_builder.native_api_output_contract import collect_native_api_selector_refs graph = _literal_graph(container) - nodes = cast(list[dict[str, object]], graph["nodes"]) + nodes = dictionaries(graph["nodes"]) assert collect_native_api_selector_refs(nodes, placeholder_pattern=WorkflowGenerator._VAR_REF_RE) == { ("node2", "json") } @@ -250,7 +243,7 @@ def test_native_api_authoritative_refs_include_real_placeholders() -> None: graph = _graph(["node2", "json"]) graph["nodes"][3]["data"]["response_template"] = "{{#node2.news#}} {{#node2.text.news#}} {{#node1.keyword#}}" - nodes = cast(list[dict[str, object]], graph["nodes"]) + nodes = dictionaries(graph["nodes"]) assert collect_native_api_selector_refs(nodes, placeholder_pattern=WorkflowGenerator._VAR_REF_RE) == { ("node2", "json"), ("node2", "news"), @@ -351,20 +344,17 @@ def _runtime_graph(path: str, selector: list[str]) -> GraphDict: graph = _graph(["node2", "json"]) consumer = _runtime_consumer(path, selector) consumer["title"] = "Runtime consumer" - graph["nodes"].append(cast(GraphNodeDict, {"id": "node5", "data": consumer})) + graph["nodes"].append(graph_node("node5", consumer)) if path.startswith("iteration") or path == "loop-variable": consumer["start_node_id"] = "node6" graph["nodes"].append( - cast( - GraphNodeDict, + graph_node( + "node6", { - "id": "node6", - "parentId": "node5", - "data": { - "type": "iteration-start" if path.startswith("iteration") else "loop-start", - "title": "Inner start", - }, + "type": "iteration-start" if path.startswith("iteration") else "loop-start", + "title": "Inner start", }, + parent_id="node5", ) ) return graph @@ -556,14 +546,9 @@ def _value_template_graph(path: str, template: object) -> GraphDict: parameter_name: {"query": {"type": "constant" if path == "agent-constant" else "mixed", "value": template}}, } data["title"] = "Template consumer" - graph["nodes"].append(cast(GraphNodeDict, {"id": "node5", "data": data})) + graph["nodes"].append(graph_node("node5", data)) if path.startswith("loop"): - graph["nodes"].append( - cast( - GraphNodeDict, - {"id": "node6", "parentId": "node5", "data": {"type": "loop-start", "title": "Inner start"}}, - ) - ) + graph["nodes"].append(graph_node("node6", {"type": "loop-start", "title": "Inner start"}, parent_id="node5")) return graph @@ -687,8 +672,8 @@ def test_native_api_model_context_and_vision_refs_follow_runtime_enabled_flags( ) if enabled is not None: config["enabled"] = enabled - data = {"type": node_type, "title": "Model reference consumer", branch: config} - graph["nodes"].append(cast(GraphNodeDict, {"id": "node5", "data": data})) + data: dict[str, object] = {"type": node_type, "title": "Model reference consumer", branch: config} + graph["nodes"].append(graph_node("node5", data)) processed, errors = _process(graph) unresolved = [error for error in errors if error["code"] == "UNRESOLVED_REFERENCE"] diff --git a/api/tests/unit_tests/services/dify_builder/test_native_api_string_input_binding.py b/api/tests/unit_tests/services/dify_builder/test_native_api_string_input_binding.py index 02fd2c77e25..ee9201b2324 100644 --- a/api/tests/unit_tests/services/dify_builder/test_native_api_string_input_binding.py +++ b/api/tests/unit_tests/services/dify_builder/test_native_api_string_input_binding.py @@ -4,7 +4,6 @@ import json from collections.abc import Iterator from copy import deepcopy from pathlib import Path -from typing import cast import pytest @@ -15,13 +14,19 @@ from core.workflow.generator.types import GraphDict from core.workflow.variable_pool_initializer import add_node_inputs_to_pool from graphon.nodes.tool.entities import ToolNodeData from graphon.nodes.tool.exc import ToolParameterError -from graphon.nodes.tool.tool_node import ToolNode from graphon.nodes.tool_runtime_entities import ToolRuntimeParameter from graphon.runtime import VariablePool from graphon.variables.input_entities import VariableEntity, VariableEntityType from graphon.variables.segments import NoneSegment, StringSegment from services.workflow_generation.runner import WorkflowGenerator from tests.unit_tests.services.dify_builder.installed_tool_inventory import forbid_secret_and_business_calls +from tests.unit_tests.services.dify_builder.typed_fixtures import ( + dictionaries, + dictionary, + graph_node, + graph_payload, + native_parameter_node, +) @pytest.fixture(autouse=True) @@ -37,49 +42,43 @@ _ACTION = "qa_news_search" def _entry() -> ToolCatalogueEntry: fixture = Path(__file__).parents[1] / "tools" / "fixtures" / "qa_news_search_openapi.json" - schema = cast(dict[str, object], json.loads(fixture.read_text())) + schema = dictionary(json.loads(fixture.read_text())) bundle = ApiBasedToolSchemaParser.parse_openapi_to_tool_bundle(schema)[0] assert bundle.parameters is not None - return cast( - ToolCatalogueEntry, - { - "provider_name": _PROVIDER, - "provider_type": "api", - "plugin_id": "", - "tool_name": _ACTION, - "tool_label": "QA News Search", - "description": "Controlled public metadata", - "needs_credentials": False, - "parameters": [parameter.model_dump(mode="json") for parameter in bundle.parameters], - "output_schema": {}, - }, - ) + return { + "provider_name": _PROVIDER, + "provider_type": "api", + "plugin_id": "", + "tool_name": _ACTION, + "tool_label": "QA News Search", + "description": "Controlled public metadata", + "needs_credentials": False, + "parameters": [parameter.model_dump(mode="json") for parameter in bundle.parameters], + "output_schema": {}, + } def _graph( binding: object, *, source_type: str = "text-input", source_id: str = "node1", field: str = "keyword" ) -> GraphDict: - return cast( - GraphDict, - { - "nodes": [ - {"id": source_id, "data": {"type": "start", "variables": [{"variable": field, "type": source_type}]}}, + return graph_payload( + [ + graph_node(source_id, {"type": "start", "variables": [{"variable": field, "type": source_type}]}), + graph_node( + "node2", { - "id": "node2", - "data": { - "type": "tool", - "provider_id": _PROVIDER, - "tool_name": _ACTION, - "tool_parameters": { - "query": binding, - "timespan": {"type": "constant", "value": "24h"}, - "maxrecords": {"type": "constant", "value": 5}, - }, + "type": "tool", + "provider_id": _PROVIDER, + "tool_name": _ACTION, + "tool_parameters": { + "query": binding, + "timespan": {"type": "constant", "value": "24h"}, + "maxrecords": {"type": "constant", "value": 5}, }, }, - ], - "edges": [], - }, + ), + ], + [], ) @@ -91,7 +90,7 @@ def _hydrate(graph: GraphDict, entry: ToolCatalogueEntry) -> dict[str, object]: ], tool_catalogue_entries=[entry], ) - return cast(dict[str, object], graph["nodes"][1]["data"]) + return dictionary(graph["nodes"][1]["data"]) @pytest.mark.parametrize("source_type", ["text-input", "paragraph"]) @@ -119,7 +118,7 @@ def test_representable_start_strings_keep_exact_selector(source_type: str, sourc graph = _graph( {"type": "variable", "value": [source_id, field]}, source_type=source_type, source_id=source_id, field=field ) - parameters = cast(dict[str, object], _hydrate(graph, _entry())["tool_parameters"]) + parameters = dictionary(_hydrate(graph, _entry())["tool_parameters"]) assert parameters["query"] == {"type": "mixed", "value": "{{#" + source_id + "." + field + "#}}"} @@ -144,7 +143,7 @@ def test_representable_start_strings_keep_exact_selector(source_type: str, sourc ) def test_existing_and_unproven_bindings_are_preserved(binding: object) -> None: graph = _graph(deepcopy(binding)) - parameters = cast(dict[str, object], _hydrate(graph, _entry())["tool_parameters"]) + parameters = dictionary(_hydrate(graph, _entry())["tool_parameters"]) assert parameters["query"] == binding @@ -153,9 +152,7 @@ def test_existing_and_unproven_bindings_are_preserved(binding: object) -> None: ) def test_nonstring_start_types_are_preserved(source_type: str) -> None: binding = {"type": "variable", "value": ["node1", "keyword"]} - parameters = cast( - dict[str, object], _hydrate(_graph(binding, source_type=source_type), _entry())["tool_parameters"] - ) + parameters = dictionary(_hydrate(_graph(binding, source_type=source_type), _entry())["tool_parameters"]) assert parameters["query"] == binding @@ -174,7 +171,7 @@ def test_nonstring_start_types_are_preserved(source_type: str) -> None: def test_unrepresentable_selector_grammar_is_preserved(source_id: str, field: str) -> None: binding = {"type": "variable", "value": [source_id, field]} graph = _graph(binding, source_id=source_id, field=field) - parameters = cast(dict[str, object], _hydrate(graph, _entry())["tool_parameters"]) + parameters = dictionary(_hydrate(graph, _entry())["tool_parameters"]) assert parameters["query"] == binding @@ -191,7 +188,7 @@ def test_other_provider_identities_are_preserved(provider_type: str, provider_id entry = _entry() entry["provider_type"], entry["provider_name"] = provider_type, provider_id binding = {"type": "variable", "value": ["node1", "keyword"]} - parameters = cast(dict[str, object], _hydrate(_graph(binding), entry)["tool_parameters"]) + parameters = dictionary(_hydrate(_graph(binding), entry)["tool_parameters"]) assert parameters["query"] == binding @@ -213,7 +210,7 @@ def test_only_exact_public_string_llm_declarations_convert(parameter_type: str, parameters = entry["parameters"] parameters[0]["type"], parameters[0]["form"] = parameter_type, form binding = {"type": "variable", "value": ["node1", "keyword"]} - hydrated = cast(dict[str, object], _hydrate(_graph(binding), entry)["tool_parameters"]) + hydrated = dictionary(_hydrate(_graph(binding), entry)["tool_parameters"]) assert hydrated["query"] == binding @@ -224,40 +221,39 @@ def test_only_exact_public_string_llm_declarations_convert(parameter_type: str, def test_unproven_or_ambiguous_source_declarations_are_preserved(mutation: str) -> None: binding = {"type": "variable", "value": ["node1", "keyword"]} graph, entry = _graph(binding), _entry() - start = cast(dict[str, object], graph["nodes"][0]["data"]) + start = dictionary(graph["nodes"][0]["data"]) if mutation == "not_start": start["type"] = "code" elif mutation == "missing_type": - cast(list[dict[str, object]], start["variables"])[0].pop("type") + dictionaries(start["variables"])[0].pop("type") elif mutation == "missing_variables": start.pop("variables") elif mutation == "duplicate_field": - cast(list[dict[str, object]], start["variables"]).append({"type": "number", "variable": "keyword"}) + dictionaries(start["variables"]).append({"type": "number", "variable": "keyword"}) elif mutation == "duplicate_node": graph["nodes"].append(deepcopy(graph["nodes"][0])) else: entry["parameters"].append(deepcopy(entry["parameters"][0])) - parameters = cast(dict[str, object], _hydrate(graph, entry)["tool_parameters"]) + parameters = dictionary(_hydrate(graph, entry)["tool_parameters"]) assert parameters["query"] == binding def _render_parameters(data: dict[str, object], pool: VariablePool) -> dict[str, object]: - # This real native method is pure: self is unused. No constructor, installed - # runtime lookup, invocation, transport, graph execution or Code executor runs. - node = ToolNode.__new__(ToolNode) + node_data = ToolNodeData.model_validate({"title": "Controlled Tool", "tool_configurations": {}, **data}) + node = native_parameter_node(node_data) return node._generate_parameters( tool_parameters=[ ToolRuntimeParameter(name=name, required=True) for name in ("query", "timespan", "maxrecords") ], variable_pool=pool, - node_data=ToolNodeData.model_validate({"title": "Controlled Tool", "tool_configurations": {}, **data}), + node_data=node_data, ) @pytest.mark.parametrize("keyword", ["alpha", "beta"]) def test_native_present_string_rendering_is_equivalent(keyword: str) -> None: graph = _graph({"type": "variable", "value": ["node1", "keyword"]}) - original = deepcopy(cast(dict[str, object], graph["nodes"][1]["data"])) + original = deepcopy(dictionary(graph["nodes"][1]["data"])) data = _hydrate(graph, _entry()) before = {**data, "tool_parameters": original["tool_parameters"]} pool = VariablePool() @@ -276,7 +272,7 @@ def test_native_present_string_rendering_is_equivalent(keyword: str) -> None: def test_missing_selector_is_a_separate_native_diagnostic_not_string_equivalence() -> None: graph = _graph({"type": "variable", "value": ["node1", "keyword"]}) - original = deepcopy(cast(dict[str, object], graph["nodes"][1]["data"])["tool_parameters"]) + original = deepcopy(dictionary(graph["nodes"][1]["data"])["tool_parameters"]) data = _hydrate(graph, _entry()) pool = VariablePool() with pytest.raises(ToolParameterError, match="does not exist"): @@ -295,7 +291,7 @@ def test_complete_start_input_preparation_registers_optional_absence_as_none() - add_node_inputs_to_pool(pool, node_id="node1", inputs=inputs) assert isinstance(pool.get(["node1", "keyword"]), NoneSegment) graph = _graph({"type": "variable", "value": ["node1", "keyword"]}) - original = deepcopy(cast(dict[str, object], graph["nodes"][1]["data"])["tool_parameters"]) + original = deepcopy(dictionary(graph["nodes"][1]["data"])["tool_parameters"]) data = _hydrate(graph, _entry()) assert _render_parameters({**data, "tool_parameters": original}, pool)["query"] is None assert _render_parameters(data, pool)["query"] == "" @@ -327,7 +323,7 @@ def test_missing_installed_catalogue_entry_does_not_normalize() -> None: def test_malformed_start_type_is_preserved(source_type: object) -> None: binding = {"type": "variable", "value": ["node1", "keyword"]} graph = _graph(binding) - start = cast(dict[str, object], graph["nodes"][0]["data"]) - cast(list[dict[str, object]], start["variables"])[0]["type"] = source_type - parameters = cast(dict[str, object], _hydrate(graph, _entry())["tool_parameters"]) + start = dictionary(graph["nodes"][0]["data"]) + dictionaries(start["variables"])[0]["type"] = source_type + parameters = dictionary(_hydrate(graph, _entry())["tool_parameters"]) assert parameters["query"] == binding diff --git a/api/tests/unit_tests/services/dify_builder/test_native_http_output_contract.py b/api/tests/unit_tests/services/dify_builder/test_native_http_output_contract.py index 0a9e764af8a..37e26e774ca 100644 --- a/api/tests/unit_tests/services/dify_builder/test_native_http_output_contract.py +++ b/api/tests/unit_tests/services/dify_builder/test_native_http_output_contract.py @@ -3,7 +3,6 @@ import json from collections.abc import Callable from copy import deepcopy -from typing import cast import pytest @@ -24,15 +23,16 @@ from services.dify_builder.preflight import new_preflight_problems, preflight_er from services.workflow_generation.graph_normalizers import derive_if_else_var_types from services.workflow_generation.runner import WorkflowGenerator from tests.helpers.unit.core.model_fixtures import make_model_instance +from tests.unit_tests.services.dify_builder.typed_fixtures import dictionary, graph_node -def http_graph(operator: str = "is", value: str = "private-expected-value") -> Graph: +def http_graph(operator: str = "is", value: str = "private-expected-value") -> GraphDict: return { "nodes": [ - {"id": "start", "data": {"type": "start", "title": "Start", "variables": []}}, - { - "id": "request", - "data": { + graph_node("start", {"type": "start", "title": "Start", "variables": []}), + graph_node( + "request", + { "type": "http-request", "title": "HTTP", "version": "1", @@ -43,10 +43,10 @@ def http_graph(operator: str = "is", value: str = "private-expected-value") -> G "params": "", "body": {"type": "none", "data": []}, }, - }, - { - "id": "branch", - "data": { + ), + graph_node( + "branch", + { "type": "if-else", "title": "Branch", "cases": [ @@ -65,19 +65,20 @@ def http_graph(operator: str = "is", value: str = "private-expected-value") -> G } ], }, - }, - {"id": "end", "data": {"type": "end", "title": "End", "outputs": []}}, + ), + graph_node("end", {"type": "end", "title": "End", "outputs": []}), ], "edges": [ - {"source": "start", "target": "request"}, - {"source": "request", "target": "branch"}, - {"source": "branch", "target": "end", "sourceHandle": "true"}, - {"source": "branch", "target": "end", "sourceHandle": "false"}, + {"id": "edge0", "type": "custom", "source": "start", "target": "request"}, + {"id": "edge1", "type": "custom", "source": "request", "target": "branch"}, + {"id": "edge2", "type": "custom", "source": "branch", "target": "end", "sourceHandle": "true"}, + {"id": "edge3", "type": "custom", "source": "branch", "target": "end", "sourceHandle": "false"}, ], + "viewport": {"x": 0, "y": 0, "zoom": 1}, } -def condition(graph: Graph) -> Graph: +def condition(graph: GraphDict) -> Graph: return graph["nodes"][2]["data"]["cases"][0]["conditions"][0] @@ -85,7 +86,7 @@ def test_preflight_refuses_a_known_numeric_status_with_a_string_operator_without graph = http_graph() original = deepcopy(graph) - errors = preflight_errors(graph) + errors = preflight_errors(dictionary(graph)) assert len(errors) == 1 assert "node 'branch' (if-else)" in errors[0] @@ -101,8 +102,8 @@ def test_preflight_refuses_a_known_numeric_status_with_a_string_operator_without def test_touched_invalid_consumer_is_refused_but_an_untouched_existing_problem_is_exempt() -> None: graph = http_graph() - assert len(new_preflight_problems(graph, deepcopy(graph), ["branch"])) == 1 - assert new_preflight_problems(graph, deepcopy(graph), ["start"]) == [] + assert len(new_preflight_problems(dictionary(graph), dictionary(deepcopy(graph)), ["branch"])) == 1 + assert new_preflight_problems(dictionary(graph), dictionary(deepcopy(graph)), ["start"]) == [] def test_producer_change_that_establishes_numeric_status_creates_a_new_problem() -> None: @@ -117,7 +118,7 @@ def test_producer_change_that_establishes_numeric_status_creates_a_new_problem() } after = http_graph() - assert len(new_preflight_problems(before, after, ["request"])) == 1 + assert len(new_preflight_problems(dictionary(before), dictionary(after), ["request"])) == 1 def test_candidate_vetting_rejects_the_entire_invalid_status_operator_batch() -> None: @@ -127,7 +128,7 @@ def test_candidate_vetting_rejects_the_entire_invalid_status_operator_batch() -> args={"node_id": "branch", "path": "cases.0.conditions.0.comparison_operator", "value": "is"}, ) - vetted = vet_intents(graph, [intent]) + vetted = vet_intents(dictionary(graph), [intent]) assert len(vetted.rejections) == 1 assert "status_code" in vetted.rejections[0] @@ -140,7 +141,7 @@ def test_generator_refuses_proven_string_equality_on_native_numeric_status(opera graph = http_graph(operator) original = deepcopy(graph) - errors = WorkflowGenerator._validate_structure(graph=cast(GraphDict, graph), mode="workflow") + errors = WorkflowGenerator._validate_structure(graph=graph, mode="workflow") assert [(error["code"], error["node_id"]) for error in errors] == [("INVALID_SCHEMA", "branch")] assert "cases.0.conditions.0.comparison_operator" in errors[0]["detail"] @@ -158,16 +159,14 @@ def test_native_numeric_unary_predicates_remain_valid_with_no_expected_literal(o condition(graph)["value"] = None original = deepcopy(graph) - assert WorkflowGenerator._validate_structure(graph=cast(GraphDict, graph), mode="workflow") == [] - assert preflight_errors(graph) == [] + assert WorkflowGenerator._validate_structure(graph=graph, mode="workflow") == [] + assert preflight_errors(dictionary(graph)) == [] assert graph == original @pytest.mark.parametrize("operator", ["=", "≠", ">", "<", "≥", "≤", "null", "not null", "future-operator"]) def test_numeric_and_unknown_operator_validation_remains_with_native_dto(operator: str) -> None: - assert ( - WorkflowGenerator._validate_structure(graph=cast(GraphDict, http_graph(operator, "200")), mode="workflow") == [] - ) + assert WorkflowGenerator._validate_structure(graph=http_graph(operator, "200"), mode="workflow") == [] def test_legacy_if_conditions_are_checked_at_their_stable_path() -> None: @@ -175,7 +174,7 @@ def test_legacy_if_conditions_are_checked_at_their_stable_path() -> None: data = graph["nodes"][2]["data"] data["conditions"] = data.pop("cases")[0]["conditions"] - errors = WorkflowGenerator._validate_structure(graph=cast(GraphDict, graph), mode="workflow") + errors = WorkflowGenerator._validate_structure(graph=graph, mode="workflow") semantic = [error for error in errors if "status_code" in error["detail"]] assert len(semantic) == 1 @@ -204,7 +203,7 @@ def test_only_native_active_if_conditions_establish_status_operator_problems(cas assert problems[0].location == ("conditions", "0", "comparison_operator") else: assert problems == () - assert preflight_errors(graph) == [] + assert preflight_errors(dictionary(graph)) == [] assert graph == original @@ -226,21 +225,21 @@ def test_unknown_versions_keep_generic_roots_and_response_alias_but_gain_no_nume assert derive_if_else_var_types(graph["nodes"]) == [] assert "varType" not in condition(graph) - assert WorkflowGenerator._validate_structure(graph=cast(GraphDict, graph), mode="workflow") == [] + assert WorkflowGenerator._validate_structure(graph=graph, mode="workflow") == [] producer = graph["nodes"][1] - assert WorkflowGenerator._declares_variable(producer, "status_code") - assert WorkflowGenerator._aliased_output(producer, "response") == "body" - assert WorkflowGenerator._aliased_output(producer, "http_response_body") is None + assert WorkflowGenerator._declares_variable(dictionary(producer), "status_code") + assert WorkflowGenerator._aliased_output(dictionary(producer), "response") == "body" + assert WorkflowGenerator._aliased_output(dictionary(producer), "http_response_body") is None def test_duplicate_producer_ids_never_establish_a_numeric_hint_or_semantic_error() -> None: graph = http_graph() - graph["nodes"].append({"id": "request", "data": {"type": "tool"}}) + graph["nodes"].append(graph_node("request", {"type": "tool"})) condition(graph).pop("varType") assert derive_if_else_var_types(graph["nodes"]) == [] assert "varType" not in condition(graph) - errors = WorkflowGenerator._validate_structure(graph=cast(GraphDict, graph), mode="workflow") + errors = WorkflowGenerator._validate_structure(graph=graph, mode="workflow") assert [error["code"] for error in errors] == ["DUPLICATE_NODE_ID"] @@ -584,17 +583,19 @@ def test_native_http_timeout_parser_and_selector_preserve_canonical_duration_sem raw_timeout: object, parsed_axes: dict[str, int | None] | None, effective_axes: dict[str, int] ) -> None: """Characterize the native boundary that discarded the confirmed10s maximum fields.""" + from core.workflow.node_runtime import DifyFileReferenceFactory, DifyToolFileManager + from graphon.file.file_manager import file_manager from graphon.nodes.http_request.entities import HttpRequestNodeConfig, HttpRequestNodeData from graphon.nodes.http_request.node import HttpRequestNode + from graphon.runtime import RuntimeState, VariablePool + from tests.workflow_test_utils import build_test_graph_init_params raw_data = deepcopy(http_graph("=", "200")["nodes"][1]["data"]) if raw_timeout != "MISSING": raw_data["timeout"] = deepcopy(raw_timeout) before = deepcopy(raw_data) parsed = HttpRequestNodeData.model_validate(raw_data) - # These are the actual qualified Local public defaults, not a10/10/10 stub. - owner = object.__new__(HttpRequestNode) - owner._http_request_config = HttpRequestNodeConfig( + http_request_config = HttpRequestNodeConfig( max_connect_timeout=10, max_read_timeout=600, max_write_timeout=600, @@ -604,6 +605,17 @@ def test_native_http_timeout_parser_and_selector_preserve_canonical_duration_sem ssrf_default_max_retries=0, ) + init_params = build_test_graph_init_params(graph_config={"nodes": [], "edges": []}) + owner = HttpRequestNode( + node_id="request", + data=parsed, + init_params=init_params, + runtime_state=RuntimeState(workflow_id=init_params.workflow_id, variable_pool=VariablePool(), start_at=0.0), + http_request_config=http_request_config, + tool_file_manager_factory=lambda: DifyToolFileManager(init_params.run_context), + file_manager=file_manager, + file_reference_factory=DifyFileReferenceFactory(init_params.run_context), + ) effective = HttpRequestNode._get_request_timeout(owner, parsed) assert (parsed.timeout.model_dump() if parsed.timeout is not None else None) == parsed_axes diff --git a/api/tests/unit_tests/services/dify_builder/test_native_metadata.py b/api/tests/unit_tests/services/dify_builder/test_native_metadata.py index be2cb3b26f2..3230cd885c2 100644 --- a/api/tests/unit_tests/services/dify_builder/test_native_metadata.py +++ b/api/tests/unit_tests/services/dify_builder/test_native_metadata.py @@ -1,13 +1,11 @@ """Metadata uses native App/draft representations and preserves user content.""" import json -from typing import cast import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -from core.dify_builder.models import Graph from models.base import Base from models.model import App, AppMode from services.dify_builder.agent.graph_translate import to_intents @@ -16,6 +14,7 @@ from services.dify_builder.preflight import preflight_errors, vet_intents from services.dify_builder.revision import executable_graph_revision from services.workflow_generation.runner import WorkflowGenerator from tests.helpers.unit.model_factories import make_app +from tests.unit_tests.services.dify_builder.typed_fixtures import dictionary def test_description_from_approved_plan_preserves_user_name_and_existing_description() -> None: @@ -93,7 +92,7 @@ def test_planned_canvas_note_survives_generator_translation_and_native_candidate existing_by_id={}, ) generated = WorkflowGenerator._postprocess_graph(graph=generated, mode="workflow") - intents = to_intents(cast(Graph, generated)) + intents = to_intents(dictionary(generated)) candidate = vet_intents({"nodes": [], "edges": []}, intents, {"start", "code", "end"}) assert candidate.rejections == [] assert len(candidate.applicable) == 6 @@ -122,6 +121,6 @@ def test_generator_does_not_treat_note_like_executable_types_as_canvas_annotatio existing_by_id={}, ) generated = WorkflowGenerator._postprocess_graph(graph=generated, mode="workflow") - candidate = vet_intents({"nodes": [], "edges": []}, to_intents(cast(Graph, generated)), {"start", "code", "end"}) + candidate = vet_intents({"nodes": [], "edges": []}, to_intents(dictionary(generated)), {"start", "code", "end"}) assert candidate.applicable == [] assert any("node_type not allowed" in reason for reason in candidate.rejections) diff --git a/api/tests/unit_tests/services/dify_builder/test_new_agent_recovery.py b/api/tests/unit_tests/services/dify_builder/test_new_agent_recovery.py index ac964ba2986..d6a46b79148 100644 --- a/api/tests/unit_tests/services/dify_builder/test_new_agent_recovery.py +++ b/api/tests/unit_tests/services/dify_builder/test_new_agent_recovery.py @@ -7,7 +7,7 @@ These are regression checks, not native runtime/remote cleanup acceptance. import json from collections.abc import Iterator -from typing import Never, cast +from typing import Never from uuid import uuid4 import httpx @@ -159,7 +159,9 @@ def test_agent_acknowledged_attempt_recovers_parent_without_second_child( def generate(**kwargs: object) -> Iterator[dict[str, str | dict[str, str]]]: parent_dispatches.append(kwargs) - result = cast(StreamCompletedEvent, list(child._run())[-1]).node_run_result + completed = list(child._run())[-1] + assert isinstance(completed, StreamCompletedEvent) + result = completed.node_run_result assert result.status.value == ("failed" if status == "failed" else "succeeded") with harness.factory.begin() as session: session.add( @@ -242,7 +244,9 @@ def test_graph_abort_waits_for_child_terminal_or_preserves_cleanup_uncertainty(c with httpx.Client(transport=httpx.MockTransport(transport)) as http: node = _node(agent_backend_client=_client(http), session_store=store) node.runtime_state.graph_execution.aborted = True - result = cast(StreamCompletedEvent, list(node._run())[-1]).node_run_result + completed = list(node._run())[-1] + assert isinstance(completed, StreamCompletedEvent) + result = completed.node_run_result assert len(transport.created) == 1 assert transport.cancel_requests == [ diff --git a/api/tests/unit_tests/services/dify_builder/test_notice_retirement.py b/api/tests/unit_tests/services/dify_builder/test_notice_retirement.py index d03dc47dce2..2c05c5cadbf 100644 --- a/api/tests/unit_tests/services/dify_builder/test_notice_retirement.py +++ b/api/tests/unit_tests/services/dify_builder/test_notice_retirement.py @@ -2,7 +2,6 @@ from dataclasses import asdict from datetime import datetime -from typing import cast import pytest @@ -14,7 +13,6 @@ from core.dify_builder.state import PcState from services.dify_builder.application.handlers_build import handle_publish as publish_build from services.dify_builder.application.handlers_edit import handle_publish as publish_edit from services.dify_builder.application.handlers_fix import handle_publish as publish_fix -from services.dify_builder.application.ports import DifyBuilderAgent, DifyPort, Repository from services.dify_builder.application.runner import Env, Handler, Runner from services.dify_builder.service import DifyBuilderService from tests.unit_tests.core.dify_builder.fakes import FakeDifyPort, InMemoryRepository, StubAgent, seed_verified_run @@ -57,7 +55,7 @@ def test_recovery_reason_is_in_the_decision_and_old_history_keeps_its_watermark( ] repo.create_session(session, DifyBuilderContext(recovery_class=recovery_class, reply_language="ja"), history) lock = FakeSessionLock() - service = DifyBuilderService(cast(Repository, repo), lock, lambda *_args: None) + service = DifyBuilderService(repo, lock, lambda *_args: None) view = service.get_session_view(session.id, actor) serialized = DifyBuilderSessionViewResponse.model_validate(asdict(view)).model_dump(mode="json") @@ -111,9 +109,9 @@ def test_publication_refusal_commits_a_complete_reply_without_publishing_or_stre delivered.append(event.item) env = Env( - dify=cast(DifyPort, dify), - agent=cast(DifyBuilderAgent, StubAgent()), - repo=cast(Repository, repo), + dify=dify, + agent=StubAgent(), + repo=repo, now=lambda: datetime.min, emit_message=streamed.append, emit_item=receive, diff --git a/api/tests/unit_tests/services/dify_builder/test_planning_tool_contract.py b/api/tests/unit_tests/services/dify_builder/test_planning_tool_contract.py index b1fbe515667..6fc9cdc5c4f 100644 --- a/api/tests/unit_tests/services/dify_builder/test_planning_tool_contract.py +++ b/api/tests/unit_tests/services/dify_builder/test_planning_tool_contract.py @@ -1,14 +1,13 @@ """Keep public action declarations intact at the planning model boundary.""" -from typing import cast - import pytest -from core.workflow.generator.tool_catalogue import ToolCatalogueEntry from graphon.model_runtime.entities.message_entities import PromptMessage from services.dify_builder.agent import build from services.dify_builder.agent.resources import ResourceRef, _tool_ref from tests.unit_tests.services.dify_builder.test_build_cognition import _FakeInstance, _plan_reply +from tests.unit_tests.services.dify_builder.test_plugin_tool_output_context import _entry +from tests.unit_tests.services.dify_builder.typed_fixtures import dictionary def test_planner_and_discovery_receive_the_actual_action_parameters() -> None: @@ -49,26 +48,26 @@ def test_unknown_parameter_contract_is_distinct_from_a_no_parameter_action() -> def test_inventory_exposes_output_schema_availability_to_the_high_level_planner( declaration: dict[str, object], expected_state: str ) -> None: - entry = cast( - ToolCatalogueEntry, - {"provider_name": "fixture/decoder/decoder", "tool_name": "decode", **declaration}, - ) + entry = _entry({}) + if "output_schema" in declaration: + entry["output_schema"] = dictionary(declaration["output_schema"]) + else: + del entry["output_schema"] resource = _tool_ref(entry, set()) assert f"Action output schema: {expected_state}" in build._planner_tool_section([resource]) def test_high_level_plan_receives_tool_channels_before_committing_consumer_steps() -> None: - entry = cast( - ToolCatalogueEntry, - {"provider_name": "fixture/decoder/decoder", "tool_name": "decode", "output_schema": {}}, - ) + entry = _entry({}) model = _FakeInstance([_plan_reply(["tool node decodes content", "code node validates the decoded records"])]) plan = build.propose_plan_v1(model, {}, tools=[_tool_ref(entry, set())], goal_text="Count paid records only.") assert plan is not None - messages = cast(list[PromptMessage], model.calls[0][0]) + messages = model.calls[0][0] + assert isinstance(messages, list) + assert isinstance(messages[0], PromptMessage) system = messages[0].get_text_content() assert "Action output schema: absent" in system assert "json is a collection of JSON messages" in system diff --git a/api/tests/unit_tests/services/dify_builder/test_plugin_tool_output_context.py b/api/tests/unit_tests/services/dify_builder/test_plugin_tool_output_context.py index 0ed4808f46c..fbcb4350082 100644 --- a/api/tests/unit_tests/services/dify_builder/test_plugin_tool_output_context.py +++ b/api/tests/unit_tests/services/dify_builder/test_plugin_tool_output_context.py @@ -2,7 +2,6 @@ import json from copy import deepcopy -from typing import cast import pytest @@ -16,21 +15,18 @@ from services.workflow_generation.runner import WorkflowGenerator def _entry(schema: dict[str, object]) -> ToolCatalogueEntry: - return cast( - ToolCatalogueEntry, - { - "provider_name": "fixture/decoder/decoder", - "provider_type": "builtin", - "plugin_id": "fixture/decoder", - "plugin_unique_identifier": "fixture/decoder:1.0.0@pinned", - "tool_name": "decode", - "tool_label": "Decode document", - "description": "Extract JSON content", - "needs_credentials": False, - "parameters": [], - "output_schema": schema, - }, - ) + return { + "provider_name": "fixture/decoder/decoder", + "provider_type": "builtin", + "plugin_id": "fixture/decoder", + "plugin_unique_identifier": "fixture/decoder:1.0.0@pinned", + "tool_name": "decode", + "tool_label": "Decode document", + "description": "Extract JSON content", + "needs_credentials": False, + "parameters": [], + "output_schema": schema, + } def _plan() -> dict[str, object]: diff --git a/api/tests/unit_tests/services/dify_builder/test_real_call_consent_service.py b/api/tests/unit_tests/services/dify_builder/test_real_call_consent_service.py index 5305526518b..5b8a6a0ad35 100644 --- a/api/tests/unit_tests/services/dify_builder/test_real_call_consent_service.py +++ b/api/tests/unit_tests/services/dify_builder/test_real_call_consent_service.py @@ -15,13 +15,14 @@ from collections.abc import Callable from dataclasses import dataclass, replace from datetime import UTC, datetime, timedelta from types import ModuleType -from typing import TYPE_CHECKING, Literal, Never, cast +from typing import TYPE_CHECKING, Literal, Unpack from uuid import uuid4 import pytest from sqlalchemy import Table, event, func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, sessionmaker +from typing_extensions import TypedDict from core.dify_builder.errors import ConflictError from core.dify_builder.input_schema import schema_hash, start_schema @@ -231,10 +232,25 @@ def consent_owner( return Owner(factory, actor, app.id, workflow.id, session_id, test_input_id, credential_id, client_id, reads) -def overrides(changes: dict[str, object]) -> dict[str, Never]: - # Refusal cases deliberately pass ill-typed values (bool versions, unknown decisions); - # the localized cast keeps the typed dataclass fields while allowing those cases. - return cast(dict[str, Never], changes) +class PrepareChanges(TypedDict, total=False, closed=True): + actor: Actor + session_id: str + test_input_id: str + credential_id: str + base_app_revision: str + node_id: str + at_version: int + idempotency_key: str + + +class DecisionChanges(TypedDict, total=False, closed=True): + actor: Actor + session_id: str + consent_id: str + scope_digest: str + at_version: int + decision: Literal["confirm", "decline"] + idempotency_key: str @dataclass(frozen=True) @@ -260,10 +276,10 @@ class DecisionArgs: idempotency_key: str = "confirm-1" -def prepare(owner: Owner, **changes: object) -> ConsentReceipt: +def prepare(owner: Owner, **changes: Unpack[PrepareChanges]) -> ConsentReceipt: args = replace( PrepareArgs(owner.actor, owner.session_id, owner.test_input_id, owner.credential_id, owner.revision()), - **overrides(changes), + **changes, ) return owner.service().prepare( actor=args.actor, @@ -277,12 +293,12 @@ def prepare(owner: Owner, **changes: object) -> ConsentReceipt: ) -def decision_args(owner: Owner, receipt: ConsentReceipt, **changes: object) -> DecisionArgs: +def decision_args(owner: Owner, receipt: ConsentReceipt, **changes: Unpack[DecisionChanges]) -> DecisionArgs: base = DecisionArgs(owner.actor, owner.session_id, receipt.consent_id, receipt.scope_digest, receipt.at_version) - return replace(base, **overrides(changes)) + return replace(base, **changes) -def confirm(owner: Owner, receipt: ConsentReceipt, **changes: object) -> ConsentReceipt: +def confirm(owner: Owner, receipt: ConsentReceipt, **changes: Unpack[DecisionChanges]) -> ConsentReceipt: args = decision_args(owner, receipt, **changes) return owner.service().confirm( actor=args.actor, @@ -295,8 +311,9 @@ def confirm(owner: Owner, receipt: ConsentReceipt, **changes: object) -> Consent ) -def reserve(owner: Owner, receipt: ConsentReceipt, **changes: object) -> tuple[ConsentReceipt, bool]: - args = decision_args(owner, receipt, **{"idempotency_key": "execute-1", **changes}) +def reserve(owner: Owner, receipt: ConsentReceipt, **changes: Unpack[DecisionChanges]) -> tuple[ConsentReceipt, bool]: + changes.setdefault("idempotency_key", "execute-1") + args = decision_args(owner, receipt, **changes) return owner.service().reserve_execution( actor=args.actor, session_id=args.session_id, @@ -438,7 +455,8 @@ def test_counters_and_prepare_key_are_database_constrained(consent_owner: Owner) owner = consent_owner receipt = prepare(owner) model = consent_model() - table = cast(Table, model.__table__) + table = model.__table__ + assert isinstance(table, Table) def write(**values: object) -> None: with owner.factory.begin() as db: @@ -570,7 +588,8 @@ def test_prepare_replay_returns_saved_receipt_and_changed_body_conflicts(consent owner = consent_owner first = prepare(owner) assert prepare(owner) == first - for changes in ({"node_id": "end"}, {"at_version": 2}, {"base_app_revision": "0" * 64}): + variants: tuple[PrepareChanges, ...] = ({"node_id": "end"}, {"at_version": 2}, {"base_app_revision": "0" * 64}) + for changes in variants: with pytest.raises(BuilderExecutionPolicyError, match="idempotency_conflict"): prepare(owner, **changes) assert owner.consents() == 1 @@ -600,7 +619,7 @@ def test_prepare_refusals_leave_no_row( consent_owner: Owner, monkeypatch: pytest.MonkeyPatch, change: str, reason: str ) -> None: owner = consent_owner - changes: dict[str, object] = {} + changes: PrepareChanges = {} if change == "stale_revision": changes["base_app_revision"] = "0" * 64 elif change == "stale_version": @@ -694,7 +713,10 @@ def test_identical_decision_replay_returns_saved_receipt_after_later_advancement assert owner.consent_row(prepared.consent_id).confirm_receipt == decision.model_dump(mode="json") -def test_decision_conflicts_and_mismatches_do_not_mutate(consent_owner: Owner) -> None: +@pytest.mark.parametrize("invalid_decision", ["approve"]) +def test_decision_conflicts_and_mismatches_do_not_mutate( + consent_owner: Owner, invalid_decision: Literal["confirm", "decline"] +) -> None: owner = consent_owner prepared = prepare(owner) with pytest.raises(BuilderExecutionPolicyError, match="consent_scope_mismatch"): @@ -702,7 +724,7 @@ def test_decision_conflicts_and_mismatches_do_not_mutate(consent_owner: Owner) - with pytest.raises(BuilderExecutionPolicyError, match="stale_consent_version"): confirm(owner, prepared, at_version=2) with pytest.raises(BuilderExecutionPolicyError, match="invalid_decision"): - confirm(owner, prepared, decision="approve") + confirm(owner, prepared, decision=invalid_decision) with owner.factory.begin() as db: other = Account(name="Teammate", email="teammate@example.invalid") db.add(other) diff --git a/api/tests/unit_tests/services/dify_builder/test_reconcile.py b/api/tests/unit_tests/services/dify_builder/test_reconcile.py index 50dc664fa58..49da5242127 100644 --- a/api/tests/unit_tests/services/dify_builder/test_reconcile.py +++ b/api/tests/unit_tests/services/dify_builder/test_reconcile.py @@ -263,12 +263,14 @@ def test_waiting_binding_retention_has_own_one_minute_schedule(enabled: bool, mo def test_waiting_binding_task_has_a_finite_wall_time_budget() -> None: - from typing import Protocol, cast + from typing import Protocol, runtime_checkable + @runtime_checkable class QueuedTask(Protocol): queue: str task = mod.retain_waiting_agent_bindings assert task.soft_time_limit == 45 assert task.time_limit == 50 - assert cast(QueuedTask, task).queue == "dify_builder" + assert isinstance(task, QueuedTask) + assert task.queue == "dify_builder" diff --git a/api/tests/unit_tests/services/dify_builder/test_resource_grounding.py b/api/tests/unit_tests/services/dify_builder/test_resource_grounding.py index 6cd0c201f82..61572b3a4fd 100644 --- a/api/tests/unit_tests/services/dify_builder/test_resource_grounding.py +++ b/api/tests/unit_tests/services/dify_builder/test_resource_grounding.py @@ -674,7 +674,7 @@ def test_edit_gate_resolves_resource_choices_before_asking_for_plan(monkeypatch: title="Real model", input_summary="Input", output_summary="Output", items=["Use the selected model"] ) - env.agent.propose_edit_plan = plan + monkeypatch.setattr(env.agent, "propose_edit_plan", plan) session = Session( id="s", app_id="app", @@ -719,8 +719,10 @@ def test_missing_choice_returns_to_edit_form_instead_of_failed_task(monkeypatch: monkeypatch.setattr(env.agent, "resolve_edit_bindings", refuse, raising=False) from services.dify_builder.plan import WorkflowPlan - env.agent.propose_edit_plan = lambda *_a, **_k: WorkflowPlan( - title="Plan", input_summary="Input", output_summary="Output", items=["Change"] + monkeypatch.setattr( + env.agent, + "propose_edit_plan", + lambda *_a, **_k: WorkflowPlan(title="Plan", input_summary="Input", output_summary="Output", items=["Change"]), ) session = Session( id="s", diff --git a/api/tests/unit_tests/services/dify_builder/test_service.py b/api/tests/unit_tests/services/dify_builder/test_service.py index 5a6ff4e14c6..d9f657dd8ed 100644 --- a/api/tests/unit_tests/services/dify_builder/test_service.py +++ b/api/tests/unit_tests/services/dify_builder/test_service.py @@ -9,7 +9,7 @@ Redis-backed ``session_lock`` module. import json from collections.abc import Callable, Iterator -from typing import Never, Protocol, cast, override +from typing import Never, Protocol, override import pytest from sqlalchemy import create_engine @@ -316,14 +316,15 @@ def test_create_build_session_records_the_apps_current_name( assert fc.app_name == "Refund approval" +@pytest.mark.parametrize("app_name", [None]) def test_create_build_session_tolerates_an_unreadable_app_name( - repo: SqlDifyBuilderRepository, lock: FakeSessionLock, enqueued: list[Enqueued] + repo: SqlDifyBuilderRepository, lock: FakeSessionLock, enqueued: list[Enqueued], app_name: str ) -> None: svc = DifyBuilderService( repo, lock, lambda sid, action, actor, token: enqueued.append((sid, action, actor, token)), - get_app_name_fn=lambda _app_id, _actor: cast(str, None), + get_app_name_fn=lambda _app_id, _actor: app_name, ) view = svc.create_build_session(APP_ID, _actor(), goal_text="Build it") @@ -413,20 +414,20 @@ def test_create_fix_session_converts_valid_checklist_dicts_before_persisting( ], ) def test_create_fix_session_rejects_malformed_checklist_items_before_persisting( - service: DifyBuilderService, enqueued: list[Enqueued], entry: object + service: DifyBuilderService, enqueued: list[Enqueued], entry: dict[str, object] ) -> None: with pytest.raises(BadRequestError, match="invalid checklist_errors item"): - service.create_fix_session(APP_ID, _actor(), checklist_errors=cast(list[ChecklistError], [entry])) + service.create_fix_session(APP_ID, _actor(), checklist_errors=[entry]) assert enqueued == [] @pytest.mark.parametrize("app_id", ["", " ", None, 1]) def test_create_rejects_invalid_app_id_before_persisting( - service: DifyBuilderService, enqueued: list[Enqueued], app_id: object + service: DifyBuilderService, enqueued: list[Enqueued], app_id: str ) -> None: with pytest.raises(BadRequestError, match="app_id is required"): - service.create_build_session(cast(str, app_id), _actor(), goal_text="Build it") + service.create_build_session(app_id, _actor(), goal_text="Build it") assert enqueued == [] @@ -958,21 +959,24 @@ def test_submit_action_rejects_removed_action_during_verification( @pytest.mark.parametrize( - "action", + ("kind", "payload", "base_version"), [ - Action(kind="publish", payload=cast(dict[str, object], []), base_version=1), - Action(kind="publish", base_version=True), - Action(kind="publish", base_version=cast(int, "1")), - Action(kind="message", payload={"text": 1}, base_version=1), - Action(kind="message", payload={"text": " "}, base_version=1), + pytest.param("publish", [], 1, id="action0"), + pytest.param("publish", {}, True, id="action1"), + pytest.param("publish", {}, "1", id="action2"), + pytest.param("message", {"text": 1}, 1, id="action3"), + pytest.param("message", {"text": " "}, 1, id="action4"), ], ) def test_submit_action_rejects_malformed_action_without_enqueue( service: DifyBuilderService, repo: SqlDifyBuilderRepository, enqueued: list[Enqueued], - action: Action, + kind: str, + payload: dict[str, object], + base_version: int, ) -> None: + action = Action(kind=kind, payload=payload, base_version=base_version) session = _seed_session_at(repo, PcState.FIX_AWAIT_DECISION) with pytest.raises(BadRequestError): diff --git a/api/tests/unit_tests/services/dify_builder/test_template_presentation.py b/api/tests/unit_tests/services/dify_builder/test_template_presentation.py index ad0caf78336..20a2ac07865 100644 --- a/api/tests/unit_tests/services/dify_builder/test_template_presentation.py +++ b/api/tests/unit_tests/services/dify_builder/test_template_presentation.py @@ -127,20 +127,20 @@ def test_json_coverage_refusal_uses_only_static_actionable_copy(reason: str, exp assert card.terminal_outputs is None -def test_template_refusal_clears_staged_repairs_and_cannot_publish() -> None: +def test_template_refusal_clears_staged_repairs_and_cannot_publish(monkeypatch: pytest.MonkeyPatch) -> None: from services.dify_builder.application.handlers_build import handle_test_and_repair from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort, StubAgent run = refused_run("unsupported_template_syntax") dify = FakeBuildDifyPort() - dify.run_draft = lambda *_args, **_kwargs: run + monkeypatch.setattr(dify, "run_draft", lambda *_args, **_kwargs: run) agent = StubAgent() def forbidden(*_args: object, **_kwargs: object) -> Never: pytest.fail("policy refusal entered automatic diagnosis or repair") - agent.diagnose = forbidden - agent.propose_repair = forbidden + monkeypatch.setattr(agent, "diagnose", forbidden) + monkeypatch.setattr(agent, "propose_repair", forbidden) env, repo = _new_env(dify=dify, agent=agent) session = _session(current_state=PcState.BUILD_TEST_AND_REPAIR) repo.save_test_input(TestInput(id="ti", session_id=session.id, source="user", inputs={"body": "SECRET-INPUT"})) diff --git a/api/tests/unit_tests/services/dify_builder/test_tool_parameter_semantics.py b/api/tests/unit_tests/services/dify_builder/test_tool_parameter_semantics.py index 114cd71d56c..51272996299 100644 --- a/api/tests/unit_tests/services/dify_builder/test_tool_parameter_semantics.py +++ b/api/tests/unit_tests/services/dify_builder/test_tool_parameter_semantics.py @@ -7,9 +7,8 @@ request, credentials, native graph execution, or sandbox service is involved. import json import re -from collections.abc import Callable, Iterator, Sequence +from collections.abc import Iterator, Sequence from copy import deepcopy -from typing import cast import pytest @@ -21,7 +20,6 @@ from core.workflow.generator.tool_catalogue import ToolCatalogueEntry from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, PromptMessage from graphon.nodes.tool.entities import ToolNodeData -from graphon.nodes.tool.tool_node import ToolNode from graphon.nodes.tool_runtime_entities import ToolRuntimeParameter from graphon.runtime import VariablePool from services.dify_builder import graph_ops @@ -29,6 +27,7 @@ from services.dify_builder.agent import edit, fix from services.workflow_generation.runner import WorkflowGenerator from tests.helpers.unit.core.model_fixtures import make_model_config from tests.unit_tests.services.dify_builder.installed_tool_inventory import forbid_secret_and_business_calls +from tests.unit_tests.services.dify_builder.typed_fixtures import dictionary, native_parameter_node @pytest.fixture(autouse=True) @@ -40,33 +39,31 @@ def no_external_calls(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: # Representative declared tool contract. Production obtains the selected action # from the actor-scoped inventory; this fixture does not assert it is installed. -_ENTRY = cast( - ToolCatalogueEntry, - { - "provider_name": "langgenius/github/github", - "provider_type": "builtin", - "plugin_id": "langgenius/github", - "tool_name": "list_issues", - "tool_label": "List Issues", - "description": "List repository issues", - "parameters": [ - { - "name": "owner", - "type": "string", - "form": "llm", - "required": True, - "llm_description": "The repository owner (a user or organization), not a repository URL", - }, - { - "name": "repo", - "type": "string", - "form": "llm", - "required": True, - "llm_description": "The repository name without its owner or URL", - }, - ], - }, -) +_ENTRY: ToolCatalogueEntry = { + "provider_name": "langgenius/github/github", + "provider_type": "builtin", + "plugin_id": "langgenius/github", + "tool_name": "list_issues", + "tool_label": "List Issues", + "description": "List repository issues", + "needs_credentials": True, + "parameters": [ + { + "name": "owner", + "type": "string", + "form": "llm", + "required": True, + "llm_description": "The repository owner (a user or organization), not a repository URL", + }, + { + "name": "repo", + "type": "string", + "form": "llm", + "required": True, + "llm_description": "The repository name without its owner or URL", + }, + ], +} _START_INPUT = {"variable": "repository", "label": "Repository (owner/repo or GitHub URL)", "type": "text-input"} # This is the deterministic model's generated Code response, not a production # normalizer. Production must generate such a transformation from the contract. @@ -179,7 +176,7 @@ def _generate(monkeypatch: pytest.MonkeyPatch) -> tuple[Graph, dict[str, str]]: installed_tools={(_ENTRY["provider_name"], _ENTRY["tool_name"])}, ) assert result["error"] == "", result - return cast(Graph, result["graph"]), prompts + return dictionary(result["graph"]), prompts def _render(graph: Graph, repository: str) -> dict[str, object]: @@ -192,16 +189,19 @@ def _render(graph: Graph, repository: str) -> dict[str, object]: item["variable"]: start_values[item["value_selector"][0]][item["value_selector"][1]] for item in configs["node2"]["variables"] } - outputs = cast(Callable[..., dict[str, str]], namespace["main"])(**arguments) + main = namespace["main"] + assert callable(main) + outputs = dictionary(main(**arguments)) + assert all(isinstance(value, str) for value in outputs.values()) assert set(outputs) == set(configs["node2"]["outputs"]) pool = VariablePool() for name, value in outputs.items(): pool.add(["node2", name], value) - # This real native method is pure; no Tool runtime lookup/invocation occurs. - return ToolNode.__new__(ToolNode)._generate_parameters( + node_data = ToolNodeData.model_validate(configs["node3"]) + return native_parameter_node(node_data)._generate_parameters( tool_parameters=[ToolRuntimeParameter(name=name, required=True) for name in ("owner", "repo")], variable_pool=pool, - node_data=ToolNodeData.model_validate(configs["node3"]), + node_data=node_data, ) diff --git a/api/tests/unit_tests/services/dify_builder/typed_fixtures.py b/api/tests/unit_tests/services/dify_builder/typed_fixtures.py new file mode 100644 index 00000000000..76e82456997 --- /dev/null +++ b/api/tests/unit_tests/services/dify_builder/typed_fixtures.py @@ -0,0 +1,76 @@ +"""Validated mutable payloads and normally constructed native fixtures.""" + +from typing import NotRequired + +from core.workflow.generator.types import GraphDict, GraphEdgeDict, GraphNodeDict +from core.workflow.node_runtime import DifyToolFileManager, DifyToolNodeRuntime +from graphon.nodes.tool.entities import ToolNodeData +from graphon.nodes.tool.tool_node import ToolNode +from graphon.runtime import RuntimeState, VariablePool +from tests.workflow_test_utils import build_test_graph_init_params + + +def dictionary(value: object) -> dict[str, object]: + """Require a string-keyed dict and retain its mutable identity.""" + assert isinstance(value, dict) + assert all(isinstance(key, str) for key in value) + return value + + +def list_value(value: object) -> list[object]: + """Require a list and retain its mutable identity.""" + assert isinstance(value, list) + return value + + +def dictionaries(value: object) -> list[dict[str, object]]: + """Require a list of string-keyed dicts without copying the fixture.""" + assert isinstance(value, list) + for item in value: + dictionary(item) + return value + + +def strings(value: object) -> list[str]: + """Require string items and retain the original list.""" + assert isinstance(value, list) + assert all(isinstance(item, str) for item in value) + return value + + +class _GraphNodeWithParent(GraphNodeDict): + parentId: NotRequired[str] + + +def graph_node(node_id: str, data: dict[str, object], *, parent_id: str | None = None) -> GraphNodeDict: + """Build a complete graph node while retaining the supplied mutable data.""" + node: _GraphNodeWithParent = { + "id": node_id, + "type": "custom", + "position": {"x": 0, "y": 0}, + "data": data, + } + if parent_id is not None: + node["parentId"] = parent_id + return node + + +def graph_payload(nodes: list[GraphNodeDict], edges: list[tuple[str, str]]) -> GraphDict: + """Build a complete native graph payload from typed nodes and endpoint pairs.""" + graph_edges: list[GraphEdgeDict] = [ + {"id": f"{source}-{target}", "type": "custom", "source": source, "target": target} for source, target in edges + ] + return {"nodes": nodes, "edges": graph_edges, "viewport": {"x": 0, "y": 0, "zoom": 1}} + + +def native_parameter_node(data: ToolNodeData) -> ToolNode: + """Construct a real Tool node with the runtime used by parameter-rendering tests.""" + init_params = build_test_graph_init_params(graph_config={"nodes": [], "edges": []}) + return ToolNode( + node_id="parameter-renderer", + data=data, + init_params=init_params, + runtime_state=RuntimeState(workflow_id=init_params.workflow_id, variable_pool=VariablePool(), start_at=0.0), + tool_file_manager=DifyToolFileManager(init_params.run_context), + runtime=DifyToolNodeRuntime(init_params.run_context), + ) diff --git a/api/tests/unit_tests/services/test_app_generate_service_in_process.py b/api/tests/unit_tests/services/test_app_generate_service_in_process.py index 30567097ed9..a840fd414af 100644 --- a/api/tests/unit_tests/services/test_app_generate_service_in_process.py +++ b/api/tests/unit_tests/services/test_app_generate_service_in_process.py @@ -2,7 +2,7 @@ import json from collections.abc import Callable, Generator -from typing import Literal, cast +from typing import Literal, Never import pytest import pytest_mock @@ -15,7 +15,6 @@ from enums import DeploymentEdition from models.model import Account, App, AppMode from models.workflow import Workflow from services.app_generate_service import AppGenerateService -from services.workflow_runtime.workflow_generator import BuilderExecutionAdmission from tests.unit_tests.installed_spies import installed_mock @@ -34,6 +33,16 @@ class _RefusalAdmission: def on_refusal(self, error: Exception, *, preworker: bool = False) -> None: self.refusals.append((error, preworker)) + def __call__(self, *_args: object, **_kwargs: object) -> Never: + raise AssertionError("Refusal path must not execute admission") + + def admit_raw(self, workflow: Workflow, app: App) -> Never: + del workflow, app + raise AssertionError("Refusal path must not admit a raw workflow") + + def on_worker_finished(self, **_kwargs: object) -> Never: + raise AssertionError("Refusal path must not start a worker") + @pytest.mark.parametrize("mode", [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) @pytest.mark.parametrize("ending", ["complete", "close", "error"]) @@ -144,7 +153,7 @@ def test_posted_live_mode_cannot_select_builder_policy( session=session, workflow_execution_mode="in_process", builder_execution=policy, - builder_execution_admit=cast(BuilderExecutionAdmission, _RefusalAdmission()) if policy else None, + builder_execution_admit=_RefusalAdmission() if policy else None, ) list(response) assert generate.call_args.kwargs["builder_execution"] is policy @@ -174,7 +183,7 @@ def test_restricted_dispatch_rejects_other_modes_before_guardrails( session=session, workflow_execution_mode="in_process", builder_execution=context(), - builder_execution_admit=cast(BuilderExecutionAdmission, _RefusalAdmission()), + builder_execution_admit=_RefusalAdmission(), ) guardrails.assert_not_called() @@ -199,7 +208,7 @@ def test_restricted_marker_cannot_enter_agent_dispatch_of_workflow_app(mocker: p request_id="request", workflow_execution_mode="in_process", builder_execution=context(), - builder_execution_admit=cast(BuilderExecutionAdmission, _RefusalAdmission()), + builder_execution_admit=_RefusalAdmission(), ) agent.assert_not_called() diff --git a/api/tests/unit_tests/services/tools/test_api_tool_catalogue.py b/api/tests/unit_tests/services/tools/test_api_tool_catalogue.py index dbf160bc621..499284bc7db 100644 --- a/api/tests/unit_tests/services/tools/test_api_tool_catalogue.py +++ b/api/tests/unit_tests/services/tools/test_api_tool_catalogue.py @@ -8,7 +8,7 @@ the native output schema: discovery must not manufacture dynamic selectors. import json from collections.abc import Callable from pathlib import Path -from typing import NoReturn, cast +from typing import NoReturn from uuid import uuid4 import pytest @@ -111,7 +111,8 @@ def _add_provider( def _add_parsed_public_fixture(engine: Engine) -> None: """Persist the approved public fixture through the real native parser.""" fixture = Path(__file__).with_name("fixtures") / "qa_news_search_openapi.json" - schema = cast(dict[str, object], json.loads(fixture.read_text())) + schema = json.loads(fixture.read_text()) + assert isinstance(schema, dict) bundles = ApiBasedToolSchemaParser.parse_openapi_to_tool_bundle(schema) _add_provider(engine, tools=[bundle.model_dump(mode="json") for bundle in bundles]) @@ -232,7 +233,9 @@ def test_api_provider_label_routes_to_uuid_and_formatter_preserves_native_type( engine, _forbidden, _sql = public_inventory bundle = _bundle("lookup") bundle["summary"] = "Lookup" - cast(dict[str, object], bundle["openapi"])["description"] = "Fetch records." + openapi = bundle["openapi"] + assert isinstance(openapi, dict) + openapi["description"] = "Fetch records." _add_provider(engine, tools=[bundle]) selected = select_tool_candidates( @@ -295,9 +298,17 @@ def test_api_readiness_cannot_borrow_builtin_authorization( ) -> None: from services.dify_builder.agent import resources - entry = {"provider_name": PROVIDER_ID, "provider_type": "api", "needs_credentials": needs_credentials} + entry: ToolCatalogueEntry = { + "provider_name": PROVIDER_ID, + "provider_type": "api", + "plugin_id": "", + "tool_name": ACTION, + "tool_label": "QA News Search", + "description": "Search recent news headlines.", + "needs_credentials": needs_credentials, + } - readiness, note = resources._tool_readiness(cast(ToolCatalogueEntry, entry), authorized) + readiness, note = resources._tool_readiness(entry, authorized) assert readiness == "missing_config" assert "authorization" in note.lower() @@ -343,10 +354,18 @@ def test_native_api_catalogue_hydration_preserves_runtime_keyword_and_fixed_para "timespan": {"type": "constant", "value": "24h"}, "maxrecords": {"type": "constant", "value": 5}, } - graph = cast( - GraphDict, - {"nodes": [{"id": "search", "data": {"type": "tool", "tool_parameters": parameters}}], "edges": []}, - ) + graph: GraphDict = { + "nodes": [ + { + "id": "search", + "type": "custom", + "position": {"x": 0, "y": 0}, + "data": {"type": "tool", "tool_parameters": parameters}, + } + ], + "edges": [], + "viewport": {"x": 0, "y": 0, "zoom": 1}, + } WorkflowGenerator._hydrate_tool_nodes( graph=graph, plan_nodes=[{"id": "search", "node_type": "tool", "tool": {"provider_id": PROVIDER_ID, "tool_name": ACTION}}], diff --git a/api/tests/unit_tests/services/tools/test_builder_credential_reference.py b/api/tests/unit_tests/services/tools/test_builder_credential_reference.py index cd1e416ce9a..a6194ae081f 100644 --- a/api/tests/unit_tests/services/tools/test_builder_credential_reference.py +++ b/api/tests/unit_tests/services/tools/test_builder_credential_reference.py @@ -8,7 +8,7 @@ import json from collections.abc import Callable, Iterator from dataclasses import dataclass from datetime import datetime -from typing import cast +from operator import methodcaller from unittest.mock import Mock from uuid import uuid4 @@ -105,33 +105,33 @@ def test_default_flag_is_not_part_of_the_revision() -> None: def _client( *, - source: str = "tenant", + source: OAuthClientSource = "tenant", row_id: str | None = "client", encrypted_params: str | None = TENANT_CLIENT_CIPHERTEXT, enabled: bool = True, tenant_id: str = "tenant", plugin_id: str = PLUGIN, provider: str = "dify-gmail", - key: object = KEY, + key: bytes = KEY, ) -> str: return oauth_client_revision( - source=cast(OAuthClientSource, source), + source=source, row_id=row_id, encrypted_params=encrypted_params, enabled=enabled, tenant_id=tenant_id, plugin_id=plugin_id, provider=provider, - key=cast(bytes, key), + key=key, ) @pytest.mark.parametrize("key", [b"", "unit-key", bytearray(b"unit-key"), None]) def test_revisions_refuse_an_empty_or_untyped_key(key: object) -> None: with pytest.raises(BuilderExecutionPolicyError, match="invalid_revision_key"): - credential_revision(_row(), key=cast(bytes, key)) + methodcaller("__call__", _row(), key=key)(credential_revision) with pytest.raises(BuilderExecutionPolicyError, match="invalid_revision_key"): - _client(key=key) + methodcaller("__call__", key=key)(_client) def test_revisions_are_keyed() -> None: @@ -159,7 +159,7 @@ def test_every_client_snapshot_field_changes_client_revision(changed: Callable[[ def test_client_source_is_closed() -> None: assert _client(source="missing", row_id=None, encrypted_params=None, enabled=False) != _client() with pytest.raises(BuilderExecutionPolicyError, match="invalid_oauth_client_source"): - _client(source="debug") + methodcaller("__call__", source="debug")(_client) # --- owner selection on a real SQLite session ------------------------------------- @@ -259,8 +259,8 @@ def resolve( actor: Actor | None = None, provider_id: str = PROVIDER, credential_id: str | None = None, - verified_plugin: object = True, - revision_key: object = KEY, + verified_plugin: bool = True, + revision_key: bytes = KEY, ) -> CredentialReference: with world.factory() as db: return BuiltinToolManageService.resolve_builder_reference( @@ -268,9 +268,8 @@ def resolve( actor=actor or world.actor, provider_id=provider_id, credential_id=world.credential_id if credential_id is None else credential_id, - # Deliberately untyped inputs exercise the owner's own refusal. - verified_plugin=cast(bool, verified_plugin), - revision_key=cast(bytes, revision_key), + verified_plugin=verified_plugin, + revision_key=revision_key, ) @@ -285,14 +284,15 @@ def refused( revision_key: object = KEY, ) -> None: with pytest.raises(BuilderExecutionPolicyError) as raised: - resolve( + methodcaller( + "__call__", world, actor=actor, provider_id=provider_id, credential_id=credential_id, verified_plugin=verified_plugin, revision_key=revision_key, - ) + )(resolve) assert raised.value.reason_code == reason diff --git a/api/tests/unit_tests/services/tools/test_ordinary_plugin_credentials.py b/api/tests/unit_tests/services/tools/test_ordinary_plugin_credentials.py index b210254fc70..fcdeaf7a33a 100644 --- a/api/tests/unit_tests/services/tools/test_ordinary_plugin_credentials.py +++ b/api/tests/unit_tests/services/tools/test_ordinary_plugin_credentials.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Generator from contextlib import _GeneratorContextManager, contextmanager from dataclasses import dataclass from types import ModuleType -from typing import Never, cast, override +from typing import Never, override from unittest.mock import Mock import pytest @@ -554,8 +554,9 @@ class _RefreshSessionFactory(sessionmaker[Session]): @override @contextmanager def begin(self) -> Generator[Session]: - with self._writer.begin(): - yield cast(Session, self._writer) + with Session(self._engine) as session, self._writer.begin(): + self._writer.monkeypatch.setattr(session, "execute", self._writer.execute) + yield session @pytest.mark.parametrize("backend", ["postgresql", "mysql", "oceanbase", "seekdb"]) diff --git a/api/tests/unit_tests/services/workflow_generation/test_runner.py b/api/tests/unit_tests/services/workflow_generation/test_runner.py index 22b5be02f93..539f9ad8b7a 100644 --- a/api/tests/unit_tests/services/workflow_generation/test_runner.py +++ b/api/tests/unit_tests/services/workflow_generation/test_runner.py @@ -1622,7 +1622,9 @@ class TestPlanDeclaredNodeOutputs: assert prompt.replace(section, "") == plain[node_id] def test_a_garbage_declaration_is_indistinguishable_from_no_declaration(self): - garbage_result, garbage = self._generate(self._planner(node_outputs=cast(Any, "slides_json"))) + garbage_plan = json.loads(self._planner()) + garbage_plan["node_outputs"] = "slides_json" + garbage_result, garbage = self._generate(json.dumps(garbage_plan)) plain_result, plain = self._generate(self._planner()) assert garbage_result["error"] == plain_result["error"] == "" @@ -5881,12 +5883,19 @@ class TestKnownCodeToolOutputContract: return {"id": "node2", "data": data} @staticmethod - def _graph(producer, variables=("valid", "items")): + def _graph(producer, variables=("valid", "items")) -> GraphDict: return { "nodes": [ - {"id": "node1", "data": {"type": "start", "title": "Start", "variables": []}}, + { + "type": "custom", + "position": {"x": 0, "y": 0}, + "id": "node1", + "data": {"type": "start", "title": "Start", "variables": []}, + }, producer, { + "type": "custom", + "position": {"x": 0, "y": 0}, "id": "node3", "data": { "type": "if-else", @@ -5908,6 +5917,8 @@ class TestKnownCodeToolOutputContract: }, }, { + "type": "custom", + "position": {"x": 0, "y": 0}, "id": "node4", "data": { "type": "end", @@ -5919,17 +5930,17 @@ class TestKnownCodeToolOutputContract: }, ], "edges": [ - {"source": "node1", "target": "node2"}, - {"source": "node2", "target": "node3"}, - {"source": "node3", "sourceHandle": "valid", "target": "node4"}, - {"source": "node3", "sourceHandle": "invalid", "target": "node4"}, + {"type": "custom", "id": "edge0", "source": "node1", "target": "node2"}, + {"type": "custom", "id": "edge1", "source": "node2", "target": "node3"}, + {"type": "custom", "id": "edge2", "source": "node3", "sourceHandle": "valid", "target": "node4"}, + {"type": "custom", "id": "edge3", "source": "node3", "sourceHandle": "invalid", "target": "node4"}, ], "viewport": {"x": 0, "y": 0, "zoom": 0.7}, } @staticmethod - def _postprocess_and_validate(graph): - graph = WorkflowGenerator._postprocess_graph(graph=cast(GraphDict, graph), mode="workflow") + def _postprocess_and_validate(graph: GraphDict): + graph = WorkflowGenerator._postprocess_graph(graph=graph, mode="workflow") return graph, WorkflowGenerator._validate_structure(graph=graph, mode="workflow") @pytest.mark.parametrize( @@ -6282,7 +6293,7 @@ class TestLlmNodeDeclaresTheEngineOutputs: cls, selector: list[str], *, schema_properties: dict | None, enabled: bool | None ) -> GraphDict: graph = cls._graph_referencing("text", schema_properties=schema_properties) - nodes = cast(list[dict[str, Any]], graph["nodes"]) + nodes = graph["nodes"] llm_data = nodes[1]["data"] if enabled is None: llm_data.pop("structured_output_enabled", None) @@ -6295,35 +6306,32 @@ class TestLlmNodeDeclaresTheEngineOutputs: def _graph_referencing(var: str, *, schema_properties: dict | None) -> GraphDict: llm = _llm_node("node2", structured_output_enabled=True, schema_properties=schema_properties) llm.update({"type": "custom", "position": {"x": 0, "y": 0}}) - return cast( - GraphDict, - { - "nodes": [ - { - "id": "node1", - "type": "custom", - "position": {"x": 0, "y": 0}, - "data": {"type": "start", "title": "Start", "variables": []}, + return { + "nodes": [ + { + "id": "node1", + "type": "custom", + "position": {"x": 0, "y": 0}, + "data": {"type": "start", "title": "Start", "variables": []}, + }, + {"id": llm["id"], "type": "custom", "position": {"x": 0, "y": 0}, "data": llm["data"]}, + { + "id": "node3", + "type": "custom", + "position": {"x": 0, "y": 0}, + "data": { + "type": "end", + "title": "End", + "outputs": [{"variable": "out", "value_selector": ["node2", var]}], }, - llm, - { - "id": "node3", - "type": "custom", - "position": {"x": 0, "y": 0}, - "data": { - "type": "end", - "title": "End", - "outputs": [{"variable": "out", "value_selector": ["node2", var]}], - }, - }, - ], - "edges": [ - {"source": "node1", "target": "node2"}, - {"source": "node2", "target": "node3"}, - ], - "viewport": {"x": 0, "y": 0, "zoom": 0.7}, - }, - ) + }, + ], + "edges": [ + {"type": "custom", "id": "edge0", "source": "node1", "target": "node2"}, + {"type": "custom", "id": "edge1", "source": "node2", "target": "node3"}, + ], + "viewport": {"x": 0, "y": 0, "zoom": 0.7}, + } def test_a_three_segment_structured_output_selector_is_a_reference(self): """The builder head now teaches ``["node2","structured_output","f"]``, @@ -6400,11 +6408,13 @@ class TestLlmNodeDeclaresTheEngineOutputs: graph = self._graph_referencing_selector( ["node2", "structured_output", "answer"], schema_properties=self._SCHEMA, enabled=None ) - nodes = cast(list[dict[str, Any]], graph["nodes"]) + nodes = [dict(node) for node in graph["nodes"]] WorkflowGenerator._reconcile_variable_references(nodes=nodes, mode="workflow") - assert nodes[2]["data"]["outputs"][0]["value_selector"] == ["node2", "text"] + end_data = nodes[2]["data"] + assert isinstance(end_data, dict) + assert end_data["outputs"][0]["value_selector"] == ["node2", "text"] assert WorkflowGenerator._collect_unresolved_refs(nodes=nodes, mode="workflow") == [] def test_a_dotted_reference_to_the_start_node_is_never_injected_as_an_input(self): @@ -6932,13 +6942,13 @@ class TestPostprocessGraphNormalizers: _FIXTURES = __import__("pathlib").Path(__file__).resolve().parents[2] / "core/workflow/fixtures" - def _esq1_303(self) -> dict: + def _esq1_303(self) -> GraphDict: return json.loads((self._FIXTURES / "esq1_303_draft_graph.json").read_text(encoding="utf-8")) def test_postprocess_heals_the_esq1_303_draft(self): graph = self._esq1_303() - out = WorkflowGenerator._postprocess_graph(graph=cast(GraphDict, graph), mode="workflow") + out = WorkflowGenerator._postprocess_graph(graph=graph, mode="workflow") node2 = next(n for n in out["nodes"] if n["id"] == "node2") condition = node2["data"]["cases"][0]["conditions"][0] @@ -6953,7 +6963,8 @@ class TestPostprocessGraphNormalizers: } def test_postprocess_fills_the_esq1_302_body_item_types_but_does_not_collapse_items(self): - graph = { + graph: GraphDict = { + "viewport": {"x": 0, "y": 0, "zoom": 0.7}, "nodes": [ { "id": "node1", @@ -6983,10 +6994,10 @@ class TestPostprocessGraphNormalizers: }, }, ], - "edges": [{"source": "node1", "target": "node4"}], + "edges": [{"type": "custom", "id": "edge0", "source": "node1", "target": "node4"}], } - out = WorkflowGenerator._postprocess_graph(graph=cast(GraphDict, graph), mode="workflow") + out = WorkflowGenerator._postprocess_graph(graph=graph, mode="workflow") items = next(n for n in out["nodes"] if n["id"] == "node4")["data"]["body"]["data"] assert [i["type"] for i in items] == ["text", "text"] @@ -7018,7 +7029,7 @@ class TestValidateStructureBranchHandlesAndHttpBodies: @staticmethod def _graph(nodes, edges) -> GraphDict: - return cast(GraphDict, {"nodes": nodes, "edges": edges, "viewport": {"x": 0, "y": 0, "zoom": 0.7}}) + return {"nodes": nodes, "edges": edges, "viewport": {"x": 0, "y": 0, "zoom": 0.7}} _START = { "id": "node1", @@ -7227,47 +7238,51 @@ class TestNativeHttpStatusComparisonValidation: "url": "https://example.com", "body": {"type": "none", "data": []}, } - return cast( - GraphDict, - { - "nodes": [ - {"id": "start", "data": {"type": "start", "variables": []}}, - {"id": "http", "data": deepcopy(producer_data)}, - { - "id": "check-status", - "data": { - "type": "if-else", - "title": "Check status", - "cases": [ - { - "case_id": "true", - "logical_operator": "and", - "conditions": [ - { - "id": "status-condition", - "variable_selector": selector - if selector is not None - else ["http", "status_code"], - "varType": var_type, - "comparison_operator": comparison_operator, - "value": value, - } - ], - } - ], - }, + return { + "nodes": [ + { + "type": "custom", + "position": {"x": 0, "y": 0}, + "id": "start", + "data": {"type": "start", "variables": []}, + }, + {"type": "custom", "position": {"x": 0, "y": 0}, "id": "http", "data": deepcopy(producer_data)}, + { + "type": "custom", + "position": {"x": 0, "y": 0}, + "id": "check-status", + "data": { + "type": "if-else", + "title": "Check status", + "cases": [ + { + "case_id": "true", + "logical_operator": "and", + "conditions": [ + { + "id": "status-condition", + "variable_selector": selector + if selector is not None + else ["http", "status_code"], + "varType": var_type, + "comparison_operator": comparison_operator, + "value": value, + } + ], + } + ], }, - {"id": "end", "data": {"type": "end", "outputs": []}}, - ], - "edges": [ - {"source": "start", "target": "http"}, - {"source": "http", "target": "check-status"}, - {"source": "check-status", "target": "end", "sourceHandle": "true"}, - {"source": "check-status", "target": "end", "sourceHandle": "false"}, - ], - "viewport": {"x": 0, "y": 0, "zoom": 0.7}, - }, - ) + }, + {"type": "custom", "position": {"x": 0, "y": 0}, "id": "end", "data": {"type": "end", "outputs": []}}, + ], + "edges": [ + {"type": "custom", "id": "edge0", "source": "start", "target": "http"}, + {"type": "custom", "id": "edge1", "source": "http", "target": "check-status"}, + {"type": "custom", "id": "edge2", "source": "check-status", "target": "end", "sourceHandle": "true"}, + {"type": "custom", "id": "edge3", "source": "check-status", "target": "end", "sourceHandle": "false"}, + ], + "viewport": {"x": 0, "y": 0, "zoom": 0.7}, + } @pytest.mark.parametrize("comparison_operator", ["is", "is not"]) @pytest.mark.parametrize("var_type", ["number", "string"]) @@ -7452,41 +7467,38 @@ class TestAJsonSchemaArrayIsNotAValueSelector: } ) llm["data"]["prompt_template"] = [{"role": "user", "text": "Write slide {{#node1.topic#}}"}] - graph = cast( - GraphDict, - { - "nodes": [ - { - "id": "node1", - "type": "custom", - "position": {"x": 0, "y": 0}, - "data": { - "type": "start", - "title": "Start", - "variables": [{"variable": "topic", "label": "Topic", "type": "paragraph"}], - }, + graph: GraphDict = { + "nodes": [ + { + "id": "node1", + "type": "custom", + "position": {"x": 0, "y": 0}, + "data": { + "type": "start", + "title": "Start", + "variables": [{"variable": "topic", "label": "Topic", "type": "paragraph"}], }, - llm, - { - "id": "node9", - "type": "custom", - "position": {"x": 0, "y": 0}, - "data": { - "type": "end", - "title": "End", - "outputs": [ - {"variable": "r", "value_selector": ["node3", "structured_output", "bullet_content"]} - ], - }, + }, + {"id": llm["id"], "type": "custom", "position": {"x": 0, "y": 0}, "data": llm["data"]}, + { + "id": "node9", + "type": "custom", + "position": {"x": 0, "y": 0}, + "data": { + "type": "end", + "title": "End", + "outputs": [ + {"variable": "r", "value_selector": ["node3", "structured_output", "bullet_content"]} + ], }, - ], - "edges": [ - {"id": "e1", "source": "node1", "target": "node3"}, - {"id": "e2", "source": "node3", "target": "node9"}, - ], - "viewport": {"x": 0, "y": 0, "zoom": 1}, - }, - ) + }, + ], + "edges": [ + {"type": "custom", "id": "e1", "source": "node1", "target": "node3"}, + {"type": "custom", "id": "e2", "source": "node3", "target": "node9"}, + ], + "viewport": {"x": 0, "y": 0, "zoom": 1}, + } errors = WorkflowGenerator._validate_structure(graph=graph, mode="workflow") @@ -8332,20 +8344,33 @@ def test_generator_carries_failure_and_http_contract_to_real_code_builder(requir def test_inserted_retrieval_template_uses_existing_localized_titles( reply_language: str | None, expected_title: str ) -> None: - graph = cast( - GraphDict, - { - "nodes": [ - {"id": "first", "data": {"type": "knowledge-retrieval", "title": "检索产品"}}, - {"id": "second", "data": {"type": "knowledge-retrieval", "title": "检索政策"}}, - { - "id": "llm", - "data": {"type": "llm", "context": {"enabled": True, "variable_selector": ["first", "result"]}}, - }, - ], - "edges": [{"source": "first", "target": "llm"}, {"source": "second", "target": "llm"}], - }, - ) + graph: GraphDict = { + "viewport": {"x": 0, "y": 0, "zoom": 0.7}, + "nodes": [ + { + "type": "custom", + "position": {"x": 0, "y": 0}, + "id": "first", + "data": {"type": "knowledge-retrieval", "title": "检索产品"}, + }, + { + "type": "custom", + "position": {"x": 0, "y": 0}, + "id": "second", + "data": {"type": "knowledge-retrieval", "title": "检索政策"}, + }, + { + "type": "custom", + "position": {"x": 0, "y": 0}, + "id": "llm", + "data": {"type": "llm", "context": {"enabled": True, "variable_selector": ["first", "result"]}}, + }, + ], + "edges": [ + {"type": "custom", "id": "edge0", "source": "first", "target": "llm"}, + {"type": "custom", "id": "edge1", "source": "second", "target": "llm"}, + ], + } result = WorkflowGenerator._postprocess_graph(graph=graph, mode="workflow", reply_language=reply_language) template = next(node for node in result["nodes"] if node["data"]["type"] == "template-transform") assert template["data"]["title"] == expected_title diff --git a/api/tests/unit_tests/services/workflow_runtime/test_node_factory.py b/api/tests/unit_tests/services/workflow_runtime/test_node_factory.py index 9c7029b2054..15acd5ed942 100644 --- a/api/tests/unit_tests/services/workflow_runtime/test_node_factory.py +++ b/api/tests/unit_tests/services/workflow_runtime/test_node_factory.py @@ -12,7 +12,7 @@ from collections.abc import Callable, Mapping, Sequence from copy import deepcopy from pathlib import Path from types import SimpleNamespace -from typing import NoReturn, cast, override +from typing import NoReturn, override from unittest.mock import MagicMock, patch, sentinel from uuid import uuid4 @@ -323,7 +323,9 @@ class TestGetNodeTypeClassesMappingCases: def memory_session_maker(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> sessionmaker[Session]: """Bind node memory lookup to an explicit SQLite session factory.""" - TypeBase.metadata.create_all(sqlite_engine, tables=[cast(Table, Conversation.__table__)]) + conversation_table = Conversation.__table__ + assert isinstance(conversation_table, Table) + TypeBase.metadata.create_all(sqlite_engine, tables=[conversation_table]) session_maker = sessionmaker(sqlite_engine, expire_on_commit=False) monkeypatch.setattr(node_factory.session_factory, "create_session", session_maker) return session_maker @@ -1504,21 +1506,23 @@ class TestNodeConfigErrorsNameTheNode: } @pytest.fixture - def factory(self, mocker): - factory = object.__new__(node_factory.DifyNodeFactory) - factory.init_params = sentinel.init_params - factory._use_workflow_tool_containers = True - factory._native_runtime = None - factory.runtime_state = RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0.0) - mocker.patch.object(VariablePool, "get") - factory._dify_context = DifyRunContext( + def factory(self, monkeypatch: pytest.MonkeyPatch) -> DifyNodeFactory: + run_context = build_dify_run_context( tenant_id="tenant-id", app_id="app-id", user_id="user-id", user_from=UserFrom.ACCOUNT, invoke_from=InvokeFrom.DEBUGGER, - builder_execution=None, ) + factory = DifyNodeFactory( + init_params=InitParams(workflow_id="workflow", graph_config={}, run_context=run_context, call_depth=0), + runtime_state=RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0.0), + ) + + def unexpected_human_input_runtime(*_args: object, **_kwargs: object) -> NoReturn: + raise AssertionError("Delivery validation must precede human-input runtime setup") + + monkeypatch.setattr(factory, "_build_human_input_callback", unexpected_human_input_runtime) return factory def test_create_node_names_the_node_that_failed_validation(self, factory): @@ -1529,8 +1533,7 @@ class TestNodeConfigErrorsNameTheNode: assert "body.data.1.type" in str(excinfo.value) def test_create_node_rejects_human_input_delivery_uuid_before_runtime_setup(self, factory): - # The factory has no callback/runtime/repository dependencies installed. - # Delivery refusal must happen in shared validation before any are used. + # The callback rejects runtime setup, so delivery validation must refuse first. node_config = { "id": "review", "data": { @@ -1562,7 +1565,8 @@ class TestNodeConfigErrorsNameTheNode: def test_validate_node_config_accepts_the_same_node_once_the_value_is_a_string(self): healed = copy.deepcopy(self.ESQ1_303_IF_ELSE_NODE) - condition = cast(dict[str, object], healed["data"]["cases"][0]["conditions"][0]) + condition = healed["data"]["cases"][0]["conditions"][0] + assert isinstance(condition, dict) condition["value"] = "60" data = node_factory.validate_node_config(healed) @@ -1830,8 +1834,10 @@ class TestConfirmedToolFactory: _forbid_eager_effects(monkeypatch) factory, graph, _reserved, _run_id = _confirmed_factory(consent_owner) tool = copy.deepcopy(_tool_config(graph)) - data = cast(dict[str, object], tool["data"]) - parameters = cast(dict[str, object], data["tool_parameters"]) + data = tool["data"] + assert isinstance(data, dict) + parameters = data["tool_parameters"] + assert isinstance(parameters, dict) changed_data = { **data, "tool_parameters": {**parameters, "message_id": {"type": "constant", "value": "other_1"}}, diff --git a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py index b6da47ed7ca..e82ec15986e 100644 --- a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py +++ b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py @@ -82,7 +82,9 @@ def test_delete_workflow_agent_node_bindings_is_scoped_to_tenant_and_app(sqlite_ assert sqlite_session.get(WorkflowAgentNodeBinding, other_tenant_id) is not None -def test_app_cleanup_removes_agent_bindings_before_workflows(monkeypatch: pytest.MonkeyPatch, mocker) -> None: +def test_app_cleanup_removes_agent_bindings_before_workflows( + monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +) -> None: events: list[str] = [] apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) other_cleanup_names = ( @@ -114,10 +116,16 @@ def test_app_cleanup_removes_agent_bindings_before_workflows(monkeypatch: pytest "_delete_workflow_trigger_logs", ) for name in other_cleanup_names: - monkeypatch.setattr(remove_app_task_module, name, MagicMock()) + mocker.patch.object(remove_app_task_module, name) - delete_bindings = MagicMock(side_effect=lambda *_args: events.append("bindings")) - delete_workflows = MagicMock(side_effect=lambda *_args: events.append("workflows")) + delete_bindings = mocker.patch.object( + remove_app_task_module, + "_delete_workflow_agent_node_bindings", + side_effect=lambda *_args: events.append("bindings"), + ) + delete_workflows = mocker.patch.object( + remove_app_task_module, "_delete_app_workflows", side_effect=lambda *_args: events.append("workflows") + ) delete_execution_requests = mocker.patch.object( remove_app_task_module, "_delete_builder_execution_requests", @@ -128,8 +136,6 @@ def test_app_cleanup_removes_agent_bindings_before_workflows(monkeypatch: pytest "_delete_builder_real_call_consents", side_effect=lambda *_args: events.append("consents"), ) - monkeypatch.setattr(remove_app_task_module, "_delete_workflow_agent_node_bindings", delete_bindings) - monkeypatch.setattr(remove_app_task_module, "_delete_app_workflows", delete_workflows) monkeypatch.setattr( remove_app_task_module, "_delete_app_workflow_node_executions", lambda *_args: events.append("executions") ) diff --git a/dify-agent/src/dify_agent/adapters/shell/shellctl.py b/dify-agent/src/dify_agent/adapters/shell/shellctl.py index d97c3d0c691..b28f053ce80 100644 --- a/dify-agent/src/dify_agent/adapters/shell/shellctl.py +++ b/dify-agent/src/dify_agent/adapters/shell/shellctl.py @@ -34,18 +34,22 @@ _SHELLCTL_OUTPUT_LIMIT_BYTES = 16 * 1024 class ShellctlJobResult(Protocol): job_id: str - status: object + @property + def status(self) -> object: ... + done: bool output: str offset: int truncated: bool exit_code: int | None - output_path: str | None + @property + def output_path(self) -> str | None: ... class ShellctlJobStatus(Protocol): job_id: str - status: object + @property + def status(self) -> object: ... done: bool offset: int exit_code: int | None