mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 11:43:26 +08:00
645 lines
27 KiB
Python
645 lines
27 KiB
Python
"""Tests for parse_wave BFS + inflate uses (kind, canonical_name)
|
|
tuple-keyed visited tracking. A flat per-name visited set would silently
|
|
drop the second of two same-named different-kind nodes (Flow Foo + Apex Foo),
|
|
and would infinite-loop / depth-cap on self-cycles without annotation.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
|
|
from . import _bootstrap # noqa: F401
|
|
|
|
import parse_wave # type: ignore
|
|
|
|
|
|
class BfsStepTupleKeyingTests(unittest.TestCase):
|
|
"""Same-name, different-kind refs must not collide."""
|
|
|
|
def test_flow_and_apex_same_name_are_distinct(self):
|
|
pending = {k: set() for k in parse_wave._BFS_KINDS}
|
|
visited = {k: set() for k in parse_wave._BFS_KINDS}
|
|
new_refs = {"FLOW": {"Foo"}, "APEX": {"Foo"}}
|
|
|
|
merged, cycles = parse_wave.bfs_step(pending, visited, new_refs)
|
|
|
|
self.assertIn("Foo", merged["FLOW"])
|
|
self.assertIn("Foo", merged["APEX"])
|
|
self.assertEqual(cycles, [])
|
|
|
|
def test_already_visited_ref_skipped_and_recorded_as_cycle(self):
|
|
pending = {k: set() for k in parse_wave._BFS_KINDS}
|
|
visited = {k: set() for k in parse_wave._BFS_KINDS}
|
|
visited["FLOW"] = {"Foo"} # simulate prior wave visit
|
|
new_refs = {"FLOW": {"Foo", "Bar"}}
|
|
|
|
merged, cycles = parse_wave.bfs_step(pending, visited, new_refs)
|
|
|
|
self.assertNotIn("Foo", merged["FLOW"]) # filtered out
|
|
self.assertIn("Bar", merged["FLOW"])
|
|
self.assertIn(("FLOW", "Foo"), cycles)
|
|
|
|
def test_cross_type_cycle_is_distinct(self):
|
|
"""Flow Foo visited does not mask Apex Foo on the same wave."""
|
|
pending = {k: set() for k in parse_wave._BFS_KINDS}
|
|
visited = {k: set() for k in parse_wave._BFS_KINDS}
|
|
visited["FLOW"] = {"Foo"}
|
|
new_refs = {"FLOW": {"Foo"}, "APEX": {"Foo"}}
|
|
|
|
merged, cycles = parse_wave.bfs_step(pending, visited, new_refs)
|
|
|
|
self.assertEqual(merged["FLOW"], set()) # Foo is visited
|
|
self.assertEqual(merged["APEX"], {"Foo"}) # distinct kind → keeps
|
|
self.assertIn(("FLOW", "Foo"), cycles)
|
|
self.assertNotIn(("APEX", "Foo"), cycles)
|
|
|
|
def test_empty_initial_pending_terminates(self):
|
|
pending = {k: set() for k in parse_wave._BFS_KINDS}
|
|
visited = {k: set() for k in parse_wave._BFS_KINDS}
|
|
merged, cycles = parse_wave.bfs_step(pending, visited, {})
|
|
self.assertEqual(cycles, [])
|
|
for k in parse_wave._BFS_KINDS:
|
|
self.assertEqual(merged[k], set())
|
|
|
|
def test_unknown_kind_raises(self):
|
|
"""unknown BFS kinds raise ValueError — bfs_step is an
|
|
internal API and a typo in a caller is a programming error, not a
|
|
runtime condition we can silently paper over. Silent-drop produced
|
|
false confidence: the ref was never fetched and no log mentioned it.
|
|
"""
|
|
pending = {k: set() for k in parse_wave._BFS_KINDS}
|
|
visited = {k: set() for k in parse_wave._BFS_KINDS}
|
|
new_refs = {"NOT_A_KIND": {"X"}}
|
|
|
|
with self.assertRaises(ValueError) as ctx:
|
|
parse_wave.bfs_step(pending, visited, new_refs)
|
|
|
|
self.assertIn("unknown BFS kind", ctx.exception.args[0])
|
|
|
|
|
|
class InflateCycleDetectionTests(unittest.TestCase):
|
|
"""`inflate_flow_leaf` must terminate on self-cycles and annotate
|
|
`_cycle_back_to` on the repeat node."""
|
|
|
|
def test_self_cycle_flow_a_calls_subflow_a(self):
|
|
"""Flow A → subflow A — one real node + a stub child with cycle tag."""
|
|
flow_children = {
|
|
"A": [
|
|
{"kind": "FLOW", "element_name": "recurse", "api_name": "A"},
|
|
],
|
|
}
|
|
leaf = {"kind": "FLOW", "api_name": "A", "children": []}
|
|
|
|
parse_wave.inflate_flow_leaf(leaf, flow_children)
|
|
|
|
self.assertEqual(len(leaf["children"]), 1)
|
|
child = leaf["children"][0]
|
|
self.assertEqual(child["kind"], "FLOW")
|
|
self.assertEqual(child["api_name"], "A")
|
|
self.assertEqual(child.get("_cycle_back_to"), "FLOW:A")
|
|
# Must NOT have expanded children on the cycle stub.
|
|
self.assertEqual(child.get("children", []), [])
|
|
|
|
def test_cross_type_cycle_flow_a_to_apex_b_to_flow_a(self):
|
|
"""Flow A → Apex B (leaf) → nothing (Apex doesn't recurse in inflate).
|
|
|
|
The inflate layer only descends through FLOW→FLOW. A cycle through
|
|
Apex would be caught at the BFS layer (bfs_step), not here. This
|
|
test confirms the inflate layer doesn't misclassify a FLOW→APEX
|
|
edge as a cycle and doesn't over-recurse on it.
|
|
"""
|
|
flow_children = {
|
|
"A": [
|
|
{"kind": "APEX", "element_name": "callApex", "api_name": "B"},
|
|
],
|
|
}
|
|
leaf = {"kind": "FLOW", "api_name": "A", "children": []}
|
|
|
|
parse_wave.inflate_flow_leaf(leaf, flow_children)
|
|
|
|
self.assertEqual(len(leaf["children"]), 1)
|
|
self.assertEqual(leaf["children"][0]["kind"], "APEX")
|
|
self.assertNotIn("_cycle_back_to", leaf["children"][0])
|
|
|
|
def test_three_layer_linear_graph_fully_expanded(self):
|
|
"""A → B → C — each Flow visited once, no false cycle tags."""
|
|
flow_children = {
|
|
"A": [{"kind": "FLOW", "element_name": "callB", "api_name": "B"}],
|
|
"B": [{"kind": "FLOW", "element_name": "callC", "api_name": "C"}],
|
|
"C": [],
|
|
}
|
|
leaf = {"kind": "FLOW", "api_name": "A", "children": []}
|
|
|
|
parse_wave.inflate_flow_leaf(leaf, flow_children)
|
|
|
|
# A has one child: B
|
|
self.assertEqual(len(leaf["children"]), 1)
|
|
b = leaf["children"][0]
|
|
self.assertEqual(b["api_name"], "B")
|
|
self.assertNotIn("_cycle_back_to", b)
|
|
# B has one child: C
|
|
self.assertEqual(len(b["children"]), 1)
|
|
c = b["children"][0]
|
|
self.assertEqual(c["api_name"], "C")
|
|
self.assertNotIn("_cycle_back_to", c)
|
|
# C has no children (empty list in flow_children)
|
|
self.assertEqual(c.get("children", []), [])
|
|
|
|
def test_sibling_flows_sharing_target_not_cross_contaminated(self):
|
|
"""If Flow X is called by two different siblings, both sibling
|
|
branches must fully expand X — not prune the second as a cycle.
|
|
|
|
Frozenset path-visited semantics protect this: siblings don't see
|
|
each other's descent. A mutable shared set would cause the second
|
|
sibling to treat X as a cycle.
|
|
"""
|
|
flow_children = {
|
|
"Root": [
|
|
{"kind": "FLOW", "element_name": "a", "api_name": "Sib1"},
|
|
{"kind": "FLOW", "element_name": "b", "api_name": "Sib2"},
|
|
],
|
|
"Sib1": [{"kind": "FLOW", "element_name": "shared", "api_name": "Shared"}],
|
|
"Sib2": [{"kind": "FLOW", "element_name": "shared", "api_name": "Shared"}],
|
|
"Shared": [],
|
|
}
|
|
leaf = {"kind": "FLOW", "api_name": "Root", "children": []}
|
|
|
|
parse_wave.inflate_flow_leaf(leaf, flow_children)
|
|
|
|
self.assertEqual(len(leaf["children"]), 2)
|
|
for sib in leaf["children"]:
|
|
# Each sibling must have Shared as a fully-expanded child —
|
|
# NOT a cycle stub.
|
|
self.assertEqual(len(sib["children"]), 1)
|
|
shared = sib["children"][0]
|
|
self.assertEqual(shared["api_name"], "Shared")
|
|
self.assertNotIn("_cycle_back_to", shared)
|
|
|
|
def test_empty_flow_children_returns_immediately(self):
|
|
"""No data for this leaf → preserve existing children (no-op)."""
|
|
leaf = {"kind": "FLOW", "api_name": "X", "children": [{"kind": "APEX", "api_name": "Y"}]}
|
|
parse_wave.inflate_flow_leaf(leaf, {}) # empty map
|
|
# Preserved unchanged
|
|
self.assertEqual(leaf["children"], [{"kind": "APEX", "api_name": "Y"}])
|
|
|
|
def test_non_flow_leaf_ignored(self):
|
|
"""Only FLOW kind is inflated."""
|
|
leaf = {"kind": "APEX", "api_name": "X"}
|
|
parse_wave.inflate_flow_leaf(leaf, {"X": [{"kind": "FLOW", "api_name": "Z"}]})
|
|
self.assertNotIn("children", leaf)
|
|
|
|
|
|
class CycleKeyTests(unittest.TestCase):
|
|
"""Unit test for the tuple-key helper — safety net on schema drift."""
|
|
|
|
def test_tuple_shape(self):
|
|
self.assertEqual(
|
|
parse_wave._cycle_key({"kind": "FLOW", "api_name": "Foo"}),
|
|
("FLOW", "Foo"),
|
|
)
|
|
|
|
def test_missing_kind_or_name_safe(self):
|
|
self.assertEqual(parse_wave._cycle_key({}), ("", ""))
|
|
|
|
|
|
def _linear_flow_chain(names: list[str]) -> dict:
|
|
"""Build a flow_children graph: FLOW[i] → FLOW[i+1], last flow has no kids.
|
|
|
|
Returns the `flow_children` mapping that inflate_flow_leaf consumes.
|
|
"""
|
|
graph: dict = {}
|
|
for i, n in enumerate(names):
|
|
if i + 1 < len(names):
|
|
graph[n] = [
|
|
{"kind": "FLOW", "element_name": f"call_{names[i+1]}",
|
|
"api_name": names[i+1]},
|
|
]
|
|
else:
|
|
graph[n] = []
|
|
return graph
|
|
|
|
|
|
class DepthCapPartialTests(unittest.TestCase):
|
|
"""`MAX_BFS_DEPTH` is a defensive guard,
|
|
not a functional chain-depth limit. Fixtures here push past the cap
|
|
to prove it still terminates and still populates `pending_out` when
|
|
it trips. Cycle semantics live in per-branch ancestor tracking and
|
|
are covered by `test_per_branch_visited.py`.
|
|
"""
|
|
|
|
def _deep_linear_names(self, n: int) -> list[str]:
|
|
return [f"F{i}" for i in range(1, n + 1)]
|
|
|
|
def test_chain_under_cap_fully_expanded(self):
|
|
"""A linear chain shorter than `MAX_BFS_DEPTH` fully expands with
|
|
no pending. Prior to the revision this used a 5-flow chain; with
|
|
a defensive cap of 20, 5 is trivially under-cap but the shape of
|
|
the assertion is unchanged.
|
|
"""
|
|
names = ["F1", "F2", "F3", "F4", "F5"]
|
|
graph = _linear_flow_chain(names)
|
|
leaf = {"kind": "FLOW", "api_name": "F1", "children": []}
|
|
pending: dict[str, set[str]] = {k: set() for k in parse_wave._BFS_KINDS}
|
|
|
|
parse_wave.inflate_flow_leaf(leaf, graph, pending_out=pending)
|
|
|
|
node = leaf
|
|
for expected in names[1:]:
|
|
kids = node.get("children", [])
|
|
self.assertEqual(len(kids), 1, f"expected one child under {node['api_name']}")
|
|
self.assertEqual(kids[0]["api_name"], expected)
|
|
node = kids[0]
|
|
self.assertEqual(node.get("children", []), [])
|
|
|
|
for k in parse_wave._BFS_KINDS:
|
|
self.assertEqual(pending[k], set())
|
|
|
|
def test_chain_at_exact_cap_trips_and_records_pending(self):
|
|
"""A linear chain of MAX_BFS_DEPTH+1 unique flows trips the
|
|
defensive cap: the last flow must NOT be expanded AND must land
|
|
in `pending_out["FLOW"]`. This is the only functional invariant
|
|
of the cap after the revision.
|
|
"""
|
|
n = parse_wave.MAX_BFS_DEPTH + 1
|
|
names = self._deep_linear_names(n)
|
|
graph = _linear_flow_chain(names)
|
|
leaf = {"kind": "FLOW", "api_name": names[0], "children": []}
|
|
pending: dict[str, set[str]] = {k: set() for k in parse_wave._BFS_KINDS}
|
|
|
|
parse_wave.inflate_flow_leaf(leaf, graph, pending_out=pending)
|
|
|
|
# Walk down to the flow at `MAX_BFS_DEPTH - 1` index (last one
|
|
# before the cap trips on its child). All prior flows must have
|
|
# been fully expanded.
|
|
node = leaf
|
|
for expected in names[1:parse_wave.MAX_BFS_DEPTH]:
|
|
kids = node.get("children", [])
|
|
self.assertEqual(len(kids), 1)
|
|
self.assertEqual(kids[0]["api_name"], expected)
|
|
node = kids[0]
|
|
|
|
# The last flow in the chain (the (MAX_BFS_DEPTH+1)-th) must be
|
|
# in pending_out.
|
|
self.assertIn(names[-1], pending["FLOW"])
|
|
|
|
def test_deep_chain_with_tail_self_loop_terminates_via_cycle_detection(self):
|
|
"""Long chain with a self-loop at the tail terminates via
|
|
per-branch cycle detection (the revised primitive), NOT the
|
|
defensive cap. The self-loop flow appears once with children
|
|
expanded, its self-reference annotated `_cycle_back_to`.
|
|
"""
|
|
# Chain of 7 unique flows, each pointing to the next, then the
|
|
# last one points at itself — 7 is well under the cap of 20 so
|
|
# this proves cycle detection, not depth, terminates the walk.
|
|
names = ["F1", "F2", "F3", "F4", "F5", "F6", "F7"]
|
|
graph = _linear_flow_chain(names)
|
|
# Rewrite F7 to self-loop instead of empty.
|
|
graph["F7"] = [{"kind": "FLOW", "api_name": "F7", "element_name": "self"}]
|
|
|
|
leaf = {"kind": "FLOW", "api_name": "F1", "children": []}
|
|
pending: dict[str, set[str]] = {k: set() for k in parse_wave._BFS_KINDS}
|
|
|
|
# Must not hang / recurse infinitely.
|
|
parse_wave.inflate_flow_leaf(leaf, graph, pending_out=pending)
|
|
|
|
# Walk to F7.
|
|
node = leaf
|
|
for expected in names[1:]:
|
|
node = node["children"][0]
|
|
self.assertEqual(node["api_name"], expected)
|
|
|
|
# F7 has ONE child — the cycle-annotated self-reference.
|
|
f7_kids = node.get("children", [])
|
|
self.assertEqual(len(f7_kids), 1)
|
|
self.assertEqual(f7_kids[0]["api_name"], "F7")
|
|
self.assertEqual(f7_kids[0]["_cycle_back_to"], "FLOW:F7")
|
|
self.assertEqual(
|
|
f7_kids[0]["_truncated"],
|
|
{"reason": "cycle", "target": "FLOW:F7"},
|
|
)
|
|
|
|
# Nothing in pending — cycle is annotated, not pended.
|
|
self.assertEqual(pending["FLOW"], set())
|
|
|
|
def test_chain_under_cap_with_legacy_pending_none(self):
|
|
"""Callers pre-dating pass no `pending_out`. An under-cap
|
|
chain still expands fully — no crash on `None`, no spurious
|
|
truncation. The pre-revision form of this test asserted cap=5
|
|
behavior; now it asserts that a short chain expands cleanly when
|
|
the cap plays no role.
|
|
"""
|
|
names = ["F1", "F2", "F3", "F4", "F5", "F6"]
|
|
graph = _linear_flow_chain(names)
|
|
leaf = {"kind": "FLOW", "api_name": "F1", "children": []}
|
|
|
|
# No pending_out — legacy call shape. Must not crash.
|
|
parse_wave.inflate_flow_leaf(leaf, graph)
|
|
|
|
# All flows expanded to the terminal leaf.
|
|
node = leaf
|
|
for expected in names[1:]:
|
|
kids = node.get("children", [])
|
|
self.assertEqual(len(kids), 1, f"{node['api_name']} should have one child")
|
|
self.assertEqual(kids[0]["api_name"], expected)
|
|
node = kids[0]
|
|
self.assertEqual(node.get("children", []), [])
|
|
|
|
def test_partial_and_unresolved_coexist(self):
|
|
"""_unresolved + _partial-reason must both be reported.
|
|
|
|
Simulate: finalize_cap drains some pending into _unresolved, but
|
|
depth-cap already set _partial_reason=max-depth-cap. Finalize must
|
|
NOT clobber the depth-cap reason.
|
|
"""
|
|
tree = {
|
|
"_partial": True,
|
|
"_partial_reason": "max-depth-cap",
|
|
"_pending_fetches": {
|
|
"FLOW": ["LateFlow"],
|
|
"APEX": [],
|
|
"PROMPT_TEMPLATE": [],
|
|
"STANDARD_ACTION": [],
|
|
},
|
|
"_unresolved": [{"kind": "APEX", "api_name": "PriorUnresolved",
|
|
"reason": "resolve_invocation_target failed"}],
|
|
}
|
|
|
|
parse_wave.finalize_cap(tree)
|
|
|
|
# Pending drained into unresolved.
|
|
self.assertEqual(tree["_pending_fetches"]["FLOW"], [])
|
|
self.assertEqual(len(tree["_unresolved"]), 2)
|
|
# Depth-cap reason preserved (NOT overwritten by max-wave-depth).
|
|
self.assertEqual(tree["_partial_reason"], "max-depth-cap")
|
|
self.assertTrue(tree["_partial"])
|
|
|
|
|
|
class MaxBfsDepthConstantTests(unittest.TestCase):
|
|
"""`MAX_BFS_DEPTH` is a defensive guard,
|
|
not a functional chain-depth limit. The constant must match
|
|
`scripts/config.py::MAX_BFS_DEPTH` exactly (duplicated intentionally
|
|
— see comment there on the "no intra-skill imports" convention).
|
|
"""
|
|
|
|
def test_max_bfs_depth_matches_config(self):
|
|
"""Cross-module consistency. If someone bumps one literal they
|
|
must bump the other."""
|
|
import config # type: ignore
|
|
self.assertEqual(parse_wave.MAX_BFS_DEPTH, config.MAX_BFS_DEPTH)
|
|
|
|
def test_max_bfs_depth_is_defensive_not_functional(self):
|
|
"""The cap should be comfortably larger than any realistic
|
|
production flow-chain depth, so that per-branch cycle detection
|
|
— not the cap — terminates the walk in practice. `>= 15` is a
|
|
loose sanity bound; if someone quietly tightens it below 15
|
|
they're probably reintroducing the shared-utility-flow bug."""
|
|
self.assertGreaterEqual(parse_wave.MAX_BFS_DEPTH, 15)
|
|
|
|
def test_legacy_alias_tracks_new_constant(self):
|
|
"""MAX_INFLATE_DEPTH is retained as an alias."""
|
|
self.assertEqual(parse_wave.MAX_INFLATE_DEPTH, parse_wave.MAX_BFS_DEPTH)
|
|
|
|
|
|
class PublicSymbolPromotionTests(unittest.TestCase):
|
|
"""`_BFS_KINDS` and `_empty_kind_sets` were
|
|
promoted to public names (`BFS_KINDS`, `empty_kind_sets`) so the
|
|
in-process `main.py` orchestrator doesn't have to reach across the
|
|
leading-underscore boundary that closed for `redact_text`.
|
|
The underscore forms remain as deprecated aliases for one more minor
|
|
version.
|
|
"""
|
|
|
|
def test_public_bfs_kinds_importable(self):
|
|
"""`parse_wave.BFS_KINDS` exists, is a tuple, and carries the
|
|
expected four tokens in the same order as before promotion.
|
|
"""
|
|
self.assertTrue(hasattr(parse_wave, "BFS_KINDS"))
|
|
self.assertIsInstance(parse_wave.BFS_KINDS, tuple)
|
|
self.assertEqual(
|
|
parse_wave.BFS_KINDS,
|
|
("FLOW", "APEX", "PROMPT_TEMPLATE", "STANDARD_ACTION"),
|
|
)
|
|
|
|
def test_public_empty_kind_sets_importable_and_callable(self):
|
|
"""`parse_wave.empty_kind_sets()` returns a fresh {kind: set()}
|
|
mapping keyed by every BFS_KINDS entry. Each call yields an
|
|
independent dict — callers must not share buckets across waves.
|
|
"""
|
|
self.assertTrue(hasattr(parse_wave, "empty_kind_sets"))
|
|
d1 = parse_wave.empty_kind_sets()
|
|
d2 = parse_wave.empty_kind_sets()
|
|
self.assertEqual(set(d1.keys()), set(parse_wave.BFS_KINDS))
|
|
for kind in parse_wave.BFS_KINDS:
|
|
self.assertEqual(d1[kind], set())
|
|
# Independence: mutating one must not affect the other.
|
|
d1["FLOW"].add("X")
|
|
self.assertEqual(d2["FLOW"], set())
|
|
|
|
def test_underscore_aliases_still_work_for_backcompat(self):
|
|
"""Legacy `_BFS_KINDS` / `_empty_kind_sets` imports continue to
|
|
resolve. They are deprecated but not yet removed — existing
|
|
tests and any external callers must keep working through the
|
|
migration window.
|
|
"""
|
|
self.assertTrue(hasattr(parse_wave, "_BFS_KINDS"))
|
|
self.assertTrue(hasattr(parse_wave, "_empty_kind_sets"))
|
|
# Same object (alias, not a copy) so equality holds structurally.
|
|
self.assertIs(parse_wave._BFS_KINDS, parse_wave.BFS_KINDS)
|
|
self.assertIs(parse_wave._empty_kind_sets, parse_wave.empty_kind_sets)
|
|
|
|
|
|
class FetchableKindsTests(unittest.TestCase):
|
|
"""STANDARD_ACTION is declared-only, never fetched.
|
|
Must stay out of `_pending_fetches` even though it remains a BFS kind
|
|
for tree counts + visited dedup."""
|
|
|
|
def test_fetchable_kinds_excludes_standard_action(self):
|
|
"""FETCHABLE_KINDS = (FLOW, APEX, PROMPT_TEMPLATE). STANDARD_ACTION
|
|
must be in BFS_KINDS (tree bookkeeping) but NOT in
|
|
FETCHABLE_KINDS (body-fetch eligibility)."""
|
|
self.assertIn("STANDARD_ACTION", parse_wave.BFS_KINDS)
|
|
self.assertNotIn("STANDARD_ACTION", parse_wave.FETCHABLE_KINDS)
|
|
self.assertEqual(
|
|
parse_wave.FETCHABLE_KINDS,
|
|
("FLOW", "APEX", "PROMPT_TEMPLATE"),
|
|
)
|
|
|
|
def test_build_root_children_skips_standard_action_refs(self):
|
|
"""A GenAiFunction whose unwrap is STANDARD_ACTION must NOT land
|
|
in new_refs. Before this fix, `streamKnowledgeSearch` landed in
|
|
`new_refs["STANDARD_ACTION"]` → merged into pending → never
|
|
visited → surfaced in _pending_fetches (pollution)."""
|
|
bundle = {
|
|
"topics": [{
|
|
"name": "T",
|
|
"actions": [{
|
|
"name": "A1",
|
|
"invocationTarget": "streamKnowledgeSearch",
|
|
"invocationTargetType": "standardInvocableAction",
|
|
}],
|
|
}],
|
|
"plannerActions": [],
|
|
}
|
|
visited = parse_wave.empty_kind_sets()
|
|
aux_visited: set = set()
|
|
children, new_refs = parse_wave.build_root_children(
|
|
bundle, visited, aux_visited,
|
|
)
|
|
# Children list is built normally — tree still has the leaf.
|
|
self.assertEqual(len(children), 1)
|
|
# But new_refs["STANDARD_ACTION"] is empty → nothing to pend on.
|
|
self.assertEqual(new_refs["STANDARD_ACTION"], set())
|
|
self.assertEqual(new_refs["FLOW"], set())
|
|
self.assertEqual(new_refs["APEX"], set())
|
|
self.assertEqual(new_refs["PROMPT_TEMPLATE"], set())
|
|
|
|
def test_build_root_children_still_captures_flow_apex_prompt_refs(self):
|
|
"""Positive control — fetchable kinds still accumulate into
|
|
new_refs. The FOLLOWUP-2 gate is on STANDARD_ACTION only."""
|
|
bundle = {
|
|
"topics": [{
|
|
"name": "T",
|
|
"actions": [
|
|
{"name": "F1", "invocationTarget": "FlowA",
|
|
"invocationTargetType": "flow"},
|
|
{"name": "A1", "invocationTarget": "ApexA",
|
|
"invocationTargetType": "apex"},
|
|
{"name": "P1", "invocationTarget": "PromptA",
|
|
"invocationTargetType": "generatePromptResponse"},
|
|
],
|
|
}],
|
|
"plannerActions": [],
|
|
}
|
|
visited = parse_wave.empty_kind_sets()
|
|
aux_visited: set = set()
|
|
_, new_refs = parse_wave.build_root_children(
|
|
bundle, visited, aux_visited,
|
|
)
|
|
self.assertIn("FlowA", new_refs["FLOW"])
|
|
self.assertIn("ApexA", new_refs["APEX"])
|
|
self.assertIn("PromptA", new_refs["PROMPT_TEMPLATE"])
|
|
self.assertEqual(new_refs["STANDARD_ACTION"], set())
|
|
|
|
|
|
class UnifiedTruncationAnnotationTests(unittest.TestCase):
|
|
"""both cycle and max-depth code paths annotate truncated
|
|
nodes with the unified `_truncated = {reason, target}` sub-object.
|
|
`_cycle_back_to` is preserved as a deprecated alias (backcompat)."""
|
|
|
|
def test_truncation_constants_are_exposed(self):
|
|
"""Public constants let consumers match on strings without
|
|
duplicating the literal values."""
|
|
self.assertEqual(parse_wave.TRUNCATION_CYCLE, "cycle")
|
|
self.assertEqual(parse_wave.TRUNCATION_MAX_DEPTH, "max-depth")
|
|
self.assertIn(
|
|
parse_wave.TRUNCATION_CYCLE, parse_wave.TRUNCATION_REASONS,
|
|
)
|
|
self.assertIn(
|
|
parse_wave.TRUNCATION_MAX_DEPTH, parse_wave.TRUNCATION_REASONS,
|
|
)
|
|
|
|
def test_cycle_emits_unified_truncated_and_legacy_alias(self):
|
|
"""Cycle annotations carry BOTH the unified `_truncated` and the
|
|
legacy `_cycle_back_to` string (same target). Consumers that
|
|
haven't migrated still work; new consumers read one field."""
|
|
flow_children = {
|
|
# A simple A → A self-cycle inside one expansion.
|
|
"A": [{"kind": "FLOW", "api_name": "A", "element_name": "loop"}],
|
|
}
|
|
leaf = {"kind": "FLOW", "api_name": "A", "children": []}
|
|
parse_wave.inflate_flow_leaf(leaf, flow_children)
|
|
|
|
# Find the cycle-annotated child (second encounter of A).
|
|
kids = leaf.get("children") or []
|
|
self.assertEqual(len(kids), 1)
|
|
cycle_node = kids[0]
|
|
|
|
# Unified annotation present.
|
|
self.assertIn("_truncated", cycle_node)
|
|
self.assertEqual(
|
|
cycle_node["_truncated"],
|
|
{"reason": "cycle", "target": "FLOW:A"},
|
|
)
|
|
|
|
# Legacy annotation still present for backcompat.
|
|
self.assertEqual(cycle_node["_cycle_back_to"], "FLOW:A")
|
|
|
|
def test_depth_cap_emits_unified_truncated_on_leaf(self):
|
|
"""When MAX_BFS_DEPTH trips, the truncated FLOW leaf picks up
|
|
`_truncated = {reason: 'max-depth', target: 'FLOW:<name>'}`.
|
|
Before the leaf carried no per-node signal at all — only
|
|
the tree-level `_partial_reason` and the `pending_out`
|
|
accumulator identified it.
|
|
|
|
2026-05-03: since the cap was bumped from 5 to 20 (defensive
|
|
guard, not functional limit), the chain here is built
|
|
programmatically at `MAX_BFS_DEPTH + 1` unique flows so the
|
|
test tracks the constant instead of hard-coding chain length.
|
|
"""
|
|
n = parse_wave.MAX_BFS_DEPTH + 1 # one past the cap
|
|
names = [f"F{i}" for i in range(n)]
|
|
flow_children: dict[str, list[dict]] = {}
|
|
for i, name in enumerate(names):
|
|
if i + 1 < n:
|
|
flow_children[name] = [{
|
|
"kind": "FLOW",
|
|
"api_name": names[i + 1],
|
|
"element_name": "sub",
|
|
}]
|
|
else:
|
|
flow_children[name] = []
|
|
|
|
root = {"kind": "FLOW", "api_name": names[0], "children": []}
|
|
pending_out = parse_wave.empty_kind_sets()
|
|
parse_wave.inflate_flow_leaf(root, flow_children, pending_out=pending_out)
|
|
|
|
# Walk down the chain to the terminal (unreached) flow.
|
|
def walk(n, name):
|
|
if n.get("api_name") == name:
|
|
return n
|
|
for c in n.get("children") or []:
|
|
found = walk(c, name)
|
|
if found:
|
|
return found
|
|
return None
|
|
|
|
tail = walk(root, names[-1])
|
|
self.assertIsNotNone(tail, f"{names[-1]} should appear as a leaf in the tree")
|
|
|
|
# Unified annotation on the depth-capped leaf.
|
|
self.assertIn("_truncated", tail)
|
|
self.assertEqual(
|
|
tail["_truncated"],
|
|
{"reason": "max-depth", "target": f"FLOW:{names[-1]}"},
|
|
)
|
|
|
|
# The capped leaf should NOT also carry _cycle_back_to — this
|
|
# is a depth-cap, not a cycle.
|
|
self.assertNotIn("_cycle_back_to", tail)
|
|
|
|
# pending_out still picks up the unreached name — existing
|
|
# contract preserved.
|
|
self.assertIn(names[-1], pending_out["FLOW"])
|
|
|
|
def test_non_flow_leaf_depth_cap_no_annotation(self):
|
|
"""The cap is scoped to FLOW leaves. Non-FLOW leaves at
|
|
cap depth are just no-op returns — no spurious `_truncated`."""
|
|
flow_children = {
|
|
"X": [{"kind": "APEX", "api_name": "SomeApex"}],
|
|
}
|
|
# Build an APEX leaf that we try to "inflate" at depth = MAX.
|
|
# inflate_flow_leaf with a non-FLOW leaf at depth >= MAX does
|
|
# nothing and emits no annotation.
|
|
leaf = {"kind": "APEX", "api_name": "SomeApex"}
|
|
pending_out = parse_wave.empty_kind_sets()
|
|
parse_wave.inflate_flow_leaf(
|
|
leaf, flow_children,
|
|
depth=parse_wave.MAX_BFS_DEPTH,
|
|
pending_out=pending_out,
|
|
)
|
|
self.assertNotIn("_truncated", leaf)
|
|
self.assertEqual(pending_out["FLOW"], set())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|