mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-31 12:08:03 +08:00
583 lines
26 KiB
Python
583 lines
26 KiB
Python
"""Tests for `main._build_flow_children` + end-to-end FLOW-child inflation.
|
|
|
|
Closes the Batch-1 placeholder that left `flow_children = {}` unconditionally —
|
|
every FLOW leaf shipped with `children: []` regardless of the subflow /
|
|
actionCall content present in the fetched `Flow.Metadata`.
|
|
|
|
Assertions focus on observable behavior:
|
|
* actionCalls are classified into the correct `kind` + carry `element_name`
|
|
(the flow-XML `<name>` that identifies WHICH Flow element invokes the
|
|
target — load-bearing for the rendered tree view).
|
|
* subflows become `{"kind": "FLOW", ...}` child refs with correct target.
|
|
* The output dict is keyed by FlowDefinition.DeveloperName (what
|
|
`walk_and_inflate` looks up), NOT by ActiveVersionId.
|
|
* Defensive skips on missing ActiveVersionId / missing `Metadata` payload.
|
|
* End-to-end: a mocked Wave B with populated Flow.Metadata produces a tree
|
|
where the FLOW node actually has descendants (the load-bearing integration
|
|
test).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
from . import _bootstrap # noqa: F401
|
|
|
|
import config # type: ignore
|
|
import soql_loader # type: ignore
|
|
import main # type: ignore
|
|
from tests.fixtures import genai_payloads as fx # type: ignore
|
|
|
|
# Re-point SOQL lookup into the repo source (same recipe as test_main_pipeline).
|
|
_REPO_SOQL_DIR = Path(__file__).resolve().parent.parent.parent / "assets" / "soql"
|
|
config.SOQL_DIR = _REPO_SOQL_DIR
|
|
soql_loader.SOQL_DIR = _REPO_SOQL_DIR
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unit tests — _build_flow_children
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class BuildFlowChildrenTests(unittest.TestCase):
|
|
def test_actioncalls_classified_correctly(self):
|
|
"""Each actionType (apex / generatePromptResponse / other / empty)
|
|
must route through `classify_action_call` and produce the correct
|
|
`kind` + preserve `element_name` + `api_name`."""
|
|
flow_metadata = {
|
|
"301VF001": {
|
|
"Id": "301VF001", "FullName": "MixedFlow-1",
|
|
"Metadata": {
|
|
"actionCalls": [
|
|
{"name": "Call_Apex",
|
|
"actionType": "apex",
|
|
"actionName": "MyApexHandler"},
|
|
{"name": "Generate_Output",
|
|
"actionType": "generatePromptResponse",
|
|
"actionName": "MyPromptTemplate"},
|
|
{"name": "Send_Email",
|
|
"actionType": "emailSimple",
|
|
"actionName": "SendSimpleEmail"},
|
|
{"name": "Unknown_Element",
|
|
"actionType": "",
|
|
"actionName": ""},
|
|
],
|
|
},
|
|
},
|
|
}
|
|
flow_def_rows = [
|
|
{"Id": "300VF001", "DeveloperName": "MixedFlow",
|
|
"ActiveVersionId": "301VF001"},
|
|
]
|
|
|
|
out = main._build_flow_children(flow_metadata, flow_def_rows)
|
|
self.assertIn("MixedFlow", out)
|
|
kids = out["MixedFlow"]
|
|
self.assertEqual(len(kids), 4)
|
|
|
|
by_element = {k["element_name"]: k for k in kids}
|
|
|
|
self.assertEqual(by_element["Call_Apex"]["kind"], "APEX")
|
|
self.assertEqual(by_element["Call_Apex"]["api_name"], "MyApexHandler")
|
|
|
|
self.assertEqual(by_element["Generate_Output"]["kind"], "PROMPT_TEMPLATE")
|
|
self.assertEqual(by_element["Generate_Output"]["api_name"], "MyPromptTemplate")
|
|
|
|
self.assertEqual(by_element["Send_Email"]["kind"], "STANDARD_ACTION")
|
|
self.assertEqual(by_element["Send_Email"]["api_name"], "SendSimpleEmail")
|
|
# `invocation_type` (schema 3.1 canonical name) is preserved for
|
|
# STANDARD_ACTION so render layers can surface the actionType
|
|
# qualifier (emailSimple, etc.) — e.g. `SendSimpleEmail (emailSimple)`
|
|
# in the rendered tree.
|
|
self.assertEqual(by_element["Send_Email"]["invocation_type"], "emailSimple")
|
|
|
|
# Empty actionType + empty actionName → UNKNOWN. `classify_action_call`
|
|
# substitutes "?" for the api_name when both are blank.
|
|
self.assertEqual(by_element["Unknown_Element"]["kind"], "UNKNOWN")
|
|
|
|
def test_subflows_become_flow_children(self):
|
|
"""Two subflow entries → two `{"kind": "FLOW", ...}` child refs with
|
|
their `<subflows><name>` as `element_name` and `<flowName>` as
|
|
`api_name`. These are the load-bearing fields for the recursive
|
|
inflation step."""
|
|
flow_metadata = {
|
|
"301VF001": {
|
|
"Id": "301VF001", "FullName": "ParentFlow-1",
|
|
"Metadata": {
|
|
"subflows": [
|
|
{"name": "Handle_Fault",
|
|
"flowName": "handleFlowFault"},
|
|
{"name": "Run_Subroutine",
|
|
"flowName": "mySubroutineFlow"},
|
|
],
|
|
},
|
|
},
|
|
}
|
|
flow_def_rows = [
|
|
{"Id": "300VF001", "DeveloperName": "ParentFlow",
|
|
"ActiveVersionId": "301VF001"},
|
|
]
|
|
|
|
out = main._build_flow_children(flow_metadata, flow_def_rows)
|
|
kids = out["ParentFlow"]
|
|
self.assertEqual(len(kids), 2)
|
|
self.assertTrue(all(k["kind"] == "FLOW" for k in kids))
|
|
|
|
by_element = {k["element_name"]: k for k in kids}
|
|
self.assertEqual(by_element["Handle_Fault"]["api_name"], "handleFlowFault")
|
|
self.assertEqual(by_element["Run_Subroutine"]["api_name"], "mySubroutineFlow")
|
|
|
|
def test_keyed_by_developer_name_not_version_id(self):
|
|
"""The dict MUST be keyed by FlowDefinition.DeveloperName — that's
|
|
what `walk_and_inflate` looks up via `flow_children[leaf.api_name]`.
|
|
Keying by ActiveVersionId would silently return `{}` and the tree
|
|
would stay flat."""
|
|
flow_metadata = {
|
|
"301VF_A": {"Id": "301VF_A", "FullName": "FlowA-1",
|
|
"Metadata": {"actionCalls": [
|
|
{"name": "ac1", "actionType": "apex",
|
|
"actionName": "ApexA"},
|
|
]}},
|
|
"301VF_B": {"Id": "301VF_B", "FullName": "FlowB-1",
|
|
"Metadata": {"actionCalls": [
|
|
{"name": "ac1", "actionType": "apex",
|
|
"actionName": "ApexB"},
|
|
]}},
|
|
}
|
|
flow_def_rows = [
|
|
{"Id": "300VF_A", "DeveloperName": "FlowAlpha",
|
|
"ActiveVersionId": "301VF_A"},
|
|
{"Id": "300VF_B", "DeveloperName": "FlowBeta",
|
|
"ActiveVersionId": "301VF_B"},
|
|
]
|
|
out = main._build_flow_children(flow_metadata, flow_def_rows)
|
|
self.assertEqual(set(out.keys()), {"FlowAlpha", "FlowBeta"})
|
|
# Not the version IDs.
|
|
self.assertNotIn("301VF_A", out)
|
|
self.assertNotIn("301VF_B", out)
|
|
|
|
def test_missing_active_version_id_skipped(self):
|
|
"""Inactive flows have no ActiveVersionId. They should be silently
|
|
skipped — not raise KeyError, not pollute `flow_children`."""
|
|
flow_metadata = {
|
|
"301VF001": {"Id": "301VF001", "FullName": "ActiveFlow-1",
|
|
"Metadata": {"actionCalls": []}},
|
|
}
|
|
flow_def_rows = [
|
|
# Active flow: normal row.
|
|
{"Id": "300VF001", "DeveloperName": "ActiveFlow",
|
|
"ActiveVersionId": "301VF001"},
|
|
# Inactive flow: no ActiveVersionId. Must not raise.
|
|
{"Id": "300VF002", "DeveloperName": "InactiveFlow",
|
|
"ActiveVersionId": None},
|
|
]
|
|
out = main._build_flow_children(flow_metadata, flow_def_rows)
|
|
self.assertIn("ActiveFlow", out)
|
|
self.assertNotIn("InactiveFlow", out)
|
|
|
|
def test_empty_wave_b_produces_empty_dict(self):
|
|
"""Zero flows → empty dict, no crash."""
|
|
self.assertEqual(main._build_flow_children({}, []), {})
|
|
self.assertEqual(main._build_flow_children(None or {}, None or []), {})
|
|
|
|
def test_missing_metadata_field_tolerated(self):
|
|
"""A Flow record that for some reason lacks `Metadata` (empty dict,
|
|
None, or the key omitted entirely) must degrade to an empty child
|
|
list — never raise. `walk_and_inflate` treats an empty list and
|
|
"no key" identically, so we can be permissive and emit empty lists
|
|
in all three cases; the invariant is "does not raise + does not
|
|
fabricate children"."""
|
|
flow_metadata = {
|
|
"301VF001": {"Id": "301VF001", "FullName": "Empty-1", "Metadata": {}},
|
|
"301VF002": {"Id": "301VF002", "FullName": "None-1", "Metadata": None},
|
|
"301VF003": {"Id": "301VF003", "FullName": "NoKey-1"},
|
|
}
|
|
flow_def_rows = [
|
|
{"Id": "300VF001", "DeveloperName": "EmptyFlow",
|
|
"ActiveVersionId": "301VF001"},
|
|
{"Id": "300VF002", "DeveloperName": "NoneFlow",
|
|
"ActiveVersionId": "301VF002"},
|
|
{"Id": "300VF003", "DeveloperName": "NoKeyFlow",
|
|
"ActiveVersionId": "301VF003"},
|
|
]
|
|
out = main._build_flow_children(flow_metadata, flow_def_rows)
|
|
for name in ("EmptyFlow", "NoneFlow", "NoKeyFlow"):
|
|
self.assertIn(name, out)
|
|
self.assertEqual(out[name], [])
|
|
|
|
def test_subflow_missing_flow_name_dropped(self):
|
|
"""A subflow entry without `flowName` has no api_name to descend
|
|
into — drop it silently rather than emit a broken child ref."""
|
|
flow_metadata = {
|
|
"301VF001": {"Id": "301VF001", "FullName": "Parent-1",
|
|
"Metadata": {"subflows": [
|
|
{"name": "BadSubflow"}, # missing flowName
|
|
{"name": "GoodSubflow", "flowName": "TargetFlow"},
|
|
]}},
|
|
}
|
|
flow_def_rows = [
|
|
{"Id": "300VF001", "DeveloperName": "ParentFlow",
|
|
"ActiveVersionId": "301VF001"},
|
|
]
|
|
out = main._build_flow_children(flow_metadata, flow_def_rows)
|
|
self.assertEqual(len(out["ParentFlow"]), 1)
|
|
self.assertEqual(out["ParentFlow"][0]["element_name"], "GoodSubflow")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration test — end-to-end tree inflation through _run_parse_wave
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _mock_auth_probe():
|
|
org_display_payload = {
|
|
"status": 0,
|
|
"result": {
|
|
"instanceUrl": "https://example.my.salesforce.com",
|
|
"accessToken": "00Dxx0000000000!AQ_fake_token_value",
|
|
"id": "00Dxx0000000000AAA",
|
|
"apiVersion": "60.0",
|
|
},
|
|
}
|
|
return [
|
|
mock.patch.object(main, "run_sf", return_value=org_display_payload),
|
|
mock.patch.object(main, "probe_channels", return_value=fx.probe_ok_payload()),
|
|
]
|
|
|
|
|
|
def _mock_bot_resolution():
|
|
return [
|
|
mock.patch.object(
|
|
main, "fetch_bot_versions",
|
|
return_value=fx.make_bot_versions("MyAgent", ("v5",), "v5"),
|
|
),
|
|
mock.patch.object(main, "fetch_bot_definition_details",
|
|
return_value=fx.BOT_DEFINITION_DETAIL_CLASSIC),
|
|
]
|
|
|
|
|
|
class EndToEndInflationTests(unittest.TestCase):
|
|
"""Load-bearing integration test.
|
|
|
|
Runs the full pipeline with a synthetic Flow.Metadata that has one
|
|
actionCall (apex) + one subflow. Asserts the resulting
|
|
`declared_action_tree.json` has a FLOW node whose `children` list
|
|
is NON-empty and carries the expected `element_name`. This is the
|
|
behavior the bug fix restores.
|
|
"""
|
|
|
|
def test_end_to_end_inflation_produces_nested_tree(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
work_dir = Path(tmp) / "work"
|
|
data_root = Path(tmp) / "data"
|
|
cache_root = Path(tmp) / "cache"
|
|
|
|
# Single classic bot with one topic + one flow action. The
|
|
# flow's Metadata carries one actionCall (apex) + one subflow.
|
|
planner = dict(fx.CLASSIC_PLANNER)
|
|
plugins = [{
|
|
"Id": "1VyVF000000TOP1",
|
|
"DeveloperName": "OrderTopic",
|
|
"MasterLabel": "Order Topic",
|
|
"Description": None,
|
|
"PluginType": "Custom",
|
|
"Scope": "Order management",
|
|
"IsLocal": True,
|
|
"CanEscalate": False,
|
|
"Source": "Declarative",
|
|
"ParentId": planner["Id"],
|
|
"LocalDeveloperName": "OrderTopic",
|
|
}]
|
|
functions = [{
|
|
"Id": "1VuVF0000000F01",
|
|
"DeveloperName": "LookupOrderFn",
|
|
"MasterLabel": "LookupOrderFn",
|
|
"Description": None,
|
|
"InvocationTargetType": "flow",
|
|
"InvocationTarget": "LookupOrder",
|
|
"IsLocal": True,
|
|
"IsConfirmationRequired": False,
|
|
"IsIncludeInProgressIndicator": False,
|
|
"ProgressIndicatorMessage": None,
|
|
"Source": "Declarative",
|
|
"PluginId": plugins[0]["Id"],
|
|
"PlannerId": None,
|
|
"ParentId": None,
|
|
"LocalDeveloperName": "LookupOrderFn",
|
|
}]
|
|
plugin_fn_join = [{
|
|
"Id": "pfj1", "PluginId": plugins[0]["Id"],
|
|
"Function": functions[0]["Id"],
|
|
}]
|
|
flow_defs = [{
|
|
"Id": "300VF001", "DeveloperName": "LookupOrder",
|
|
"ActiveVersionId": "301VF001",
|
|
}]
|
|
flow_metadata_record = {
|
|
"Id": "301VF001", "FullName": "LookupOrder-1",
|
|
"Metadata": {
|
|
"actionCalls": [{
|
|
"name": "Call_The_Apex_Helper",
|
|
"actionType": "apex",
|
|
"actionName": "OrderLookupApex",
|
|
}],
|
|
"subflows": [{
|
|
"name": "Run_Subflow",
|
|
"flowName": "OrderLookupSubflow",
|
|
}],
|
|
},
|
|
}
|
|
|
|
patches = [
|
|
*_mock_auth_probe(),
|
|
*_mock_bot_resolution(),
|
|
mock.patch.object(main, "fetch_planner_definition", return_value=planner),
|
|
mock.patch.object(main, "fetch_plugins_by_planner", return_value=plugins),
|
|
mock.patch.object(main, "fetch_planner_bundle_functions", return_value=[]),
|
|
mock.patch.object(main, "fetch_functions_by_plugins",
|
|
return_value=functions),
|
|
mock.patch.object(main, "fetch_plugin_instructions", return_value=[]),
|
|
mock.patch.object(main, "fetch_plugin_functions",
|
|
return_value=plugin_fn_join),
|
|
mock.patch.object(main, "fetch_planner_attrs", return_value=[]),
|
|
mock.patch.object(main, "fetch_apex_bodies_by_names", return_value=[]),
|
|
mock.patch.object(main, "fetch_apex_bodies_by_ids", return_value=[]),
|
|
mock.patch.object(main, "fetch_flow_definition_ids_by_names",
|
|
return_value=flow_defs),
|
|
mock.patch.object(main, "fetch_flow_definition_by_ids", return_value=[]),
|
|
mock.patch.object(
|
|
main, "fetch_flow_metadata",
|
|
side_effect=lambda vid, *a, **kw: (
|
|
flow_metadata_record if vid == "301VF001" else None
|
|
),
|
|
),
|
|
mock.patch.object(main, "build_agent_data_dir",
|
|
side_effect=lambda o, a, v: data_root / o / f"{a}__{v}"),
|
|
mock.patch.object(main, "build_agent_cache_dir",
|
|
side_effect=lambda o, a, v: cache_root / o / f"{a}__{v}"),
|
|
]
|
|
for p in patches:
|
|
p.start()
|
|
try:
|
|
rc = main.main([
|
|
"--org-alias", "test-org",
|
|
"--agent", "MyAgent",
|
|
"--work-dir", str(work_dir),
|
|
"--parallelism", "2",
|
|
])
|
|
finally:
|
|
for p in patches:
|
|
p.stop()
|
|
|
|
self.assertEqual(rc, 0)
|
|
tree = json.loads((work_dir / "declared_action_tree.json").read_text())
|
|
|
|
# Locate the FLOW leaf under the topic's GEN_AI_FUNCTION.
|
|
topic = tree["root"]["children"][0]
|
|
gen_ai_fn = topic["children"][0]
|
|
flow_leaf = gen_ai_fn["children"][0]
|
|
self.assertEqual(flow_leaf["kind"], "FLOW")
|
|
self.assertEqual(flow_leaf["api_name"], "LookupOrder")
|
|
|
|
# BUG FIX ASSERTION: the FLOW node now has non-empty children.
|
|
# Before the fix, `flow_children = {}` meant every FLOW leaf
|
|
# shipped with `children: []` regardless of actionCalls /
|
|
# subflows content.
|
|
self.assertEqual(len(flow_leaf["children"]), 2)
|
|
|
|
element_names = {c["element_name"] for c in flow_leaf["children"]}
|
|
self.assertIn("Call_The_Apex_Helper", element_names)
|
|
self.assertIn("Run_Subflow", element_names)
|
|
|
|
# Kinds are correctly classified — APEX for the actionCall,
|
|
# FLOW for the subflow.
|
|
by_element = {c["element_name"]: c for c in flow_leaf["children"]}
|
|
self.assertEqual(by_element["Call_The_Apex_Helper"]["kind"], "APEX")
|
|
self.assertEqual(
|
|
by_element["Call_The_Apex_Helper"]["api_name"],
|
|
"OrderLookupApex",
|
|
)
|
|
self.assertEqual(by_element["Run_Subflow"]["kind"], "FLOW")
|
|
self.assertEqual(
|
|
by_element["Run_Subflow"]["api_name"],
|
|
"OrderLookupSubflow",
|
|
)
|
|
|
|
def test_nested_subflow_subtree_populated_via_iteration(self):
|
|
"""iterative Wave B must expand shared
|
|
utility-flow leaves.
|
|
|
|
Before the iteration pass: the top-level flow `LookupOrder`
|
|
referenced a subflow `handleFlowFault`, which landed as an empty
|
|
FLOW leaf because its body was never queried. This test pushes
|
|
end-to-end through `main()` and asserts the `handleFlowFault`
|
|
node in the tree has CHILDREN — specifically the Apex class
|
|
`XCSF_FlowFaultMessage` referenced by its own body.
|
|
"""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
work_dir = Path(tmp) / "work"
|
|
data_root = Path(tmp) / "data"
|
|
cache_root = Path(tmp) / "cache"
|
|
|
|
planner = dict(fx.CLASSIC_PLANNER)
|
|
plugins = [{
|
|
"Id": "1VyVF000000TOP1",
|
|
"DeveloperName": "OrderTopic",
|
|
"MasterLabel": "Order Topic",
|
|
"Description": None,
|
|
"PluginType": "Custom",
|
|
"Scope": "Order management",
|
|
"IsLocal": True,
|
|
"CanEscalate": False,
|
|
"Source": "Declarative",
|
|
"ParentId": planner["Id"],
|
|
"LocalDeveloperName": "OrderTopic",
|
|
}]
|
|
functions = [{
|
|
"Id": "1VuVF0000000F01",
|
|
"DeveloperName": "LookupOrderFn",
|
|
"MasterLabel": "LookupOrderFn",
|
|
"Description": None,
|
|
"InvocationTargetType": "flow",
|
|
"InvocationTarget": "LookupOrder",
|
|
"IsLocal": True,
|
|
"IsConfirmationRequired": False,
|
|
"IsIncludeInProgressIndicator": False,
|
|
"ProgressIndicatorMessage": None,
|
|
"Source": "Declarative",
|
|
"PluginId": plugins[0]["Id"],
|
|
"PlannerId": None,
|
|
"ParentId": None,
|
|
"LocalDeveloperName": "LookupOrderFn",
|
|
}]
|
|
plugin_fn_join = [{
|
|
"Id": "pfj1", "PluginId": plugins[0]["Id"],
|
|
"Function": functions[0]["Id"],
|
|
}]
|
|
|
|
# Flow registry keyed by DeveloperName → (FlowDefinition row,
|
|
# Flow.Metadata record). The mock fetcher consults this on
|
|
# each iteration round.
|
|
flow_registry = {
|
|
"LookupOrder": (
|
|
{"Id": "300VF001", "DeveloperName": "LookupOrder",
|
|
"ActiveVersionId": "301VF001"},
|
|
{"Id": "301VF001", "FullName": "LookupOrder-1",
|
|
"Metadata": {
|
|
"actionCalls": [],
|
|
"subflows": [{
|
|
"name": "Handle_Flow_Fault",
|
|
"flowName": "handleFlowFault",
|
|
}],
|
|
}},
|
|
),
|
|
"handleFlowFault": (
|
|
{"Id": "300VF002", "DeveloperName": "handleFlowFault",
|
|
"ActiveVersionId": "301VF002"},
|
|
{"Id": "301VF002", "FullName": "handleFlowFault-1",
|
|
"Metadata": {
|
|
"actionCalls": [{
|
|
"name": "Parse_and_log_fault",
|
|
"actionType": "apex",
|
|
"actionName": "XCSF_FlowFaultMessage",
|
|
}],
|
|
"subflows": [],
|
|
}},
|
|
),
|
|
}
|
|
|
|
def fake_flow_def_by_names(names, *a, **kw):
|
|
return [
|
|
flow_registry[n][0] for n in names if n in flow_registry
|
|
]
|
|
|
|
def fake_flow_metadata(vid, *a, **kw):
|
|
for _name, (fd, fm) in flow_registry.items():
|
|
if fd["ActiveVersionId"] == vid:
|
|
return fm
|
|
return None
|
|
|
|
# Apex bodies: XCSF_FlowFaultMessage is discovered via the
|
|
# subflow's actionCall in iteration round 2.
|
|
def fake_apex_by_names(names, *a, **kw):
|
|
apex_registry = {
|
|
"XCSF_FlowFaultMessage": {
|
|
"Id": "01pVF001", "Name": "XCSF_FlowFaultMessage",
|
|
"Body": "public class XCSF_FlowFaultMessage { }",
|
|
},
|
|
}
|
|
return [apex_registry[n] for n in names if n in apex_registry]
|
|
|
|
patches = [
|
|
*_mock_auth_probe(),
|
|
*_mock_bot_resolution(),
|
|
mock.patch.object(main, "fetch_planner_definition", return_value=planner),
|
|
mock.patch.object(main, "fetch_plugins_by_planner", return_value=plugins),
|
|
mock.patch.object(main, "fetch_planner_bundle_functions", return_value=[]),
|
|
mock.patch.object(main, "fetch_functions_by_plugins",
|
|
return_value=functions),
|
|
mock.patch.object(main, "fetch_plugin_instructions", return_value=[]),
|
|
mock.patch.object(main, "fetch_plugin_functions",
|
|
return_value=plugin_fn_join),
|
|
mock.patch.object(main, "fetch_planner_attrs", return_value=[]),
|
|
mock.patch.object(main, "fetch_apex_bodies_by_names",
|
|
side_effect=fake_apex_by_names),
|
|
mock.patch.object(main, "fetch_apex_bodies_by_ids", return_value=[]),
|
|
mock.patch.object(main, "fetch_flow_definition_ids_by_names",
|
|
side_effect=fake_flow_def_by_names),
|
|
mock.patch.object(main, "fetch_flow_definition_by_ids", return_value=[]),
|
|
mock.patch.object(main, "fetch_flow_metadata",
|
|
side_effect=fake_flow_metadata),
|
|
mock.patch.object(main, "build_agent_data_dir",
|
|
side_effect=lambda o, a, v: data_root / o / f"{a}__{v}"),
|
|
mock.patch.object(main, "build_agent_cache_dir",
|
|
side_effect=lambda o, a, v: cache_root / o / f"{a}__{v}"),
|
|
]
|
|
for p in patches:
|
|
p.start()
|
|
try:
|
|
rc = main.main([
|
|
"--org-alias", "test-org",
|
|
"--agent", "MyAgent",
|
|
"--work-dir", str(work_dir),
|
|
"--parallelism", "2",
|
|
])
|
|
finally:
|
|
for p in patches:
|
|
p.stop()
|
|
|
|
self.assertEqual(rc, 0)
|
|
tree = json.loads((work_dir / "declared_action_tree.json").read_text())
|
|
|
|
topic = tree["root"]["children"][0]
|
|
gen_ai_fn = topic["children"][0]
|
|
top_flow = gen_ai_fn["children"][0]
|
|
self.assertEqual(top_flow["api_name"], "LookupOrder")
|
|
|
|
# LookupOrder → Handle_Flow_Fault (FLOW leaf, pre-iteration
|
|
# this was empty). Post-iteration, it must carry children.
|
|
handle_flow_fault = top_flow["children"][0]
|
|
self.assertEqual(handle_flow_fault["kind"], "FLOW")
|
|
self.assertEqual(handle_flow_fault["api_name"], "handleFlowFault")
|
|
|
|
# Core assertion: the shared utility flow's subtree is now
|
|
# populated via iterative Wave B fetching its body.
|
|
self.assertTrue(
|
|
handle_flow_fault["children"],
|
|
"handleFlowFault subtree is empty — iterative Wave B "
|
|
"did not fetch its body. This is the bug.",
|
|
)
|
|
apex_kids = [
|
|
c for c in handle_flow_fault["children"]
|
|
if c["kind"] == "APEX" and c["api_name"] == "XCSF_FlowFaultMessage"
|
|
]
|
|
self.assertEqual(len(apex_kids), 1)
|
|
self.assertEqual(apex_kids[0]["element_name"], "Parse_and_log_fault")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|