from __future__ import annotations import json from copy import deepcopy from pathlib import Path from typing import Any import pytest from agent_framework.workflows import FileWorkflowRepository, WorkflowActionRegistry, WorkflowRuntime from agent_framework.workflows.models import WorkflowDefinition ROOT = Path(__file__).resolve().parents[2] CASES = ROOT / "tests" / "migration" / "data" / "workflow_yaml_cases" def _load_cases(): return [json.loads(p.read_text(encoding="utf-8")) for p in sorted(CASES.glob("*.json"))] def _contains_subset(expected: Any, observed: Any) -> bool: if isinstance(expected, dict): return isinstance(observed, dict) and all( key in observed and _contains_subset(value, observed[key]) for key, value in expected.items() ) if isinstance(expected, list): return isinstance(observed, list) and len(expected) <= len(observed) and all( _contains_subset(value, observed[index]) for index, value in enumerate(expected) ) return expected == observed class _DefinitionRepository: def __init__(self, definition): self.definition = definition def get_active(self, name): assert name == self.definition.name return self.definition def get_version(self, name, version): assert (name, version) == (self.definition.name, self.definition.version) return self.definition def _normalized_expected_nodes(case): nodes = list((case.get("expect") or {}).get("trace_nodes") or []) # Runtime antigo repetia a action imediatamente anterior ao pause. O runtime # novo deliberadamente não a repete para impedir side effects duplicados. normalized = [] for node in nodes: if normalized and normalized[-1] == node: continue normalized.append(node) return normalized @pytest.mark.asyncio @pytest.mark.parametrize("case", _load_cases(), ids=lambda c: c["id"]) async def test_original_workflow_case_on_framework_runtime(case): original = FileWorkflowRepository(ROOT / "workflows").get_version(case["workflow"], int(case["version"])) raw = original.model_dump(by_alias=True) registry = WorkflowActionRegistry() calls = [] # No teste cada node recebe uma action exclusiva. Isso permite reproduzir # exatamente os outputs configurados no caso JSON, mesmo quando dois nodes # compartilham a mesma action de produção. for node in raw["nodes"]: node_id = node["id"] real_action = node["action"] test_action = f"__case__{node_id}" node["action"] = test_action async def handler(params, state, *, _node=node_id, _action=real_action): cfgs = dict(case.get("actions") or {}) configured = cfgs.get(_node) if isinstance(cfgs.get(_node), dict) else cfgs.get(_action) configured = configured if isinstance(configured, dict) else {} calls.append({"node_id": _node, "action": _action, "params": deepcopy(params)}) if configured.get("success") is False: raise RuntimeError(str(configured.get("error") or "falha configurada")) return deepcopy(configured.get("output") or {"success": True}) registry.register(test_action, handler) definition = WorkflowDefinition.model_validate(raw) runtime = WorkflowRuntime(_DefinitionRepository(definition), actions=registry, checkpointer=None, allow_deterministic_fallback=True) first = await runtime.arun(definition.name, deepcopy(case.get("input") or {}), version=definition.version) start_expect = case.get("expect_start") if isinstance(start_expect, dict): if start_expect.get("status") == "WAITING_INPUT": assert first.status == "PAUSED" if "paused_at" in start_expect: assert first.pause.get("node") == start_expect["paused_at"] expected_input = first.pause.get("expected_input") or {} if "expected_input_key" in start_expect: assert expected_input.get("key") == start_expect["expected_input_key"] if "allowed_values" in start_expect: assert expected_input.get("allowed_values") == start_expect["allowed_values"] final = first resume = case.get("resume") if isinstance(resume, dict): final = await runtime.aresume(definition.name, first.execution_id, deepcopy(resume), version=definition.version) expect = dict(case.get("expect") or {}) assert final.status == expect.get("status") if "last_node" in expect: assert final.state.get("current_node") == expect["last_node"] # Data do runtime antigo era a união dos outputs. ``vars`` representa essa # informação no runtime novo e é a fonte para referências $.vars.* do YAML. flattened = {} for value in (final.state.get("vars") or {}).values(): if isinstance(value, dict): flattened.update(value) if "data_subset" in expect: assert _contains_subset(expect["data_subset"], flattened) observed_nodes = [item.get("node") for item in final.trace if item.get("action") != "pause_resume"] if "trace_nodes" in expect: active_node_ids = {node.id for node in definition.nodes} expected_nodes = [node for node in _normalized_expected_nodes(case) if node in active_node_ids] assert observed_nodes == expected_nodes for expected in expect.get("calls", []): found = [c for c in calls if c["node_id"] == expected.get("node_id") and ("action" not in expected or c["action"] == expected["action"])] assert found, expected subset = expected.get("params_subset") if subset is not None: assert any(_contains_subset(subset, c["params"]) for c in found)