mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 11:43:26 +08:00
811 lines
33 KiB
Python
811 lines
33 KiB
Python
"""Tests for render_architecture.render + load_mermaid.
|
|
|
|
P2.2-1: exercise every per-generation branch (classic ReAct, classic
|
|
Sequential, NGA ConcurrentMultiAgent, search/BYOP placeholder), the
|
|
_partial / _unresolved / cycle surfaces, and the node-cap behaviour.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
from . import _bootstrap # noqa: F401 — sys.path setup
|
|
|
|
import render_architecture # type: ignore
|
|
|
|
# SKILL_ROOT is now file-relative (config.py uses
|
|
# Path(__file__).resolve().parent.parent), so config.MERMAID_DIR auto-
|
|
# resolves to the repo's assets/mermaid/ under test. We still construct
|
|
# _REPO_MERMAID_DIR explicitly for tests that compare paths or pass them
|
|
# as args; render_architecture's own MERMAID_DIR captures the right path
|
|
# at module import time.
|
|
_REPO_MERMAID_DIR = (
|
|
Path(__file__).resolve().parent.parent.parent / "assets" / "mermaid"
|
|
)
|
|
|
|
|
|
def _classic_react_tree() -> dict:
|
|
"""MyAgent v5 shape — classic ReAct, 2 topics, a handful of
|
|
children to keep the fixture inline-readable."""
|
|
return {
|
|
"_schema_version": "3.0",
|
|
"agent": {
|
|
"api_name": "MyAgent",
|
|
"version": "v5",
|
|
"master_label": "Xero support AI",
|
|
"description": "External-facing service agent.",
|
|
"agent_type": "EinsteinAgentKind",
|
|
"type": "ExternalCopilot",
|
|
"agent_template": "SvcCopilotTmpl__EinsteinAgentKind",
|
|
"bot_source": "None",
|
|
"generation": "classic",
|
|
"planner_name": "MyAgent_v2_v3_v4_v5",
|
|
"planner_type": "AiCopilot__ReAct",
|
|
"bot_id": "0XxXx00000000FdKAI",
|
|
},
|
|
"root": {
|
|
"kind": "BOT_DEFINITION",
|
|
"api_name": "MyAgent",
|
|
"children": [
|
|
{
|
|
"kind": "TOPIC",
|
|
"api_name": "Customer_Q_A",
|
|
"children": [
|
|
{
|
|
"kind": "GEN_AI_FUNCTION",
|
|
"api_name": "Find_Articles",
|
|
"unwraps_to": {"kind": "FLOW",
|
|
"api_name": "Find_Public_Articles"},
|
|
"children": [
|
|
{
|
|
"kind": "FLOW",
|
|
"api_name": "Find_Public_Articles",
|
|
"children": [
|
|
{"kind": "APEX",
|
|
"api_name": "KnowledgeRetriever"},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"kind": "TOPIC",
|
|
"api_name": "Escalation",
|
|
"children": [
|
|
{
|
|
"kind": "GEN_AI_FUNCTION",
|
|
"api_name": "Escalate",
|
|
"unwraps_to": {"kind": "APEX",
|
|
"api_name": "EscalateAction"},
|
|
"children": [
|
|
{"kind": "APEX", "api_name": "EscalateAction"},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
"node_count": 8,
|
|
"depth": 4,
|
|
"_partial": False,
|
|
"_pending_fetches": {"Flow": [], "ApexClass": [], "GenAiPromptTemplate": []},
|
|
"_unresolved": [],
|
|
"_kind_counts": {
|
|
"BOT_DEFINITION": 1,
|
|
"TOPIC": 2,
|
|
"GEN_AI_FUNCTION": 2,
|
|
"FLOW": 1,
|
|
"APEX": 2,
|
|
},
|
|
}
|
|
|
|
|
|
def _nga_tree() -> dict:
|
|
t = _classic_react_tree()
|
|
t["agent"]["generation"] = "nga"
|
|
t["agent"]["planner_name"] = "Atlas__ConcurrentMultiAgentOrchestration"
|
|
t["agent"]["planner_type"] = "Atlas__ConcurrentMultiAgentOrchestration"
|
|
return t
|
|
|
|
|
|
def _sequential_tree() -> dict:
|
|
"""Zero-topic Sequential planner (classic)."""
|
|
return {
|
|
"_schema_version": "3.0",
|
|
"agent": {
|
|
"api_name": "KAMAgent",
|
|
"version": "v5",
|
|
"generation": "classic",
|
|
"planner_name": "KAM_Planner",
|
|
"planner_type": "AiCopilot__SequentialPlannerIntentClassifier",
|
|
},
|
|
"root": {
|
|
"kind": "BOT_DEFINITION",
|
|
"api_name": "KAMAgent",
|
|
"children": [
|
|
{
|
|
"kind": "GEN_AI_FUNCTION",
|
|
"api_name": "SalesPlay",
|
|
"children": [],
|
|
},
|
|
],
|
|
},
|
|
"node_count": 2,
|
|
"depth": 1,
|
|
"_partial": False,
|
|
"_pending_fetches": {},
|
|
"_unresolved": [],
|
|
"_kind_counts": {"BOT_DEFINITION": 1, "GEN_AI_FUNCTION": 1},
|
|
}
|
|
|
|
|
|
def _write_tree(tmp: Path, tree: dict) -> Path:
|
|
p = tmp / "metadata_tree.json"
|
|
p.write_text(json.dumps(tree))
|
|
return p
|
|
|
|
|
|
class _RenderTestBase(unittest.TestCase):
|
|
"""Shared setup: patch MERMAID_DIR at the renderer binding + tmp dir.
|
|
|
|
Every test that invokes `render_architecture.render` or
|
|
`render_architecture.load_mermaid` patches MERMAID_DIR to point at the
|
|
repo's `assets/mermaid/`. With file-relative SKILL_ROOT this is now
|
|
coincident with the natural resolution, but the explicit patch keeps
|
|
each TestCase deterministic and isolated from any sys.path drift.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
self._patch = mock.patch.object(
|
|
render_architecture, "MERMAID_DIR", _REPO_MERMAID_DIR,
|
|
)
|
|
self._patch.start()
|
|
self.addCleanup(self._patch.stop)
|
|
self._tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self._tmp.cleanup)
|
|
self.tmp = Path(self._tmp.name)
|
|
|
|
|
|
class RenderClassicReActTests(_RenderTestBase):
|
|
def test_all_eight_sections_present(self):
|
|
tree_path = _write_tree(self.tmp, _classic_react_tree())
|
|
out = self.tmp / "architecture.md"
|
|
render_architecture.render(tree_path, out)
|
|
text = out.read_text()
|
|
# Section headers 2..8 all present; H1 covers section 1.
|
|
# Invocation sequence (formerly section 3) was removed from the
|
|
# default pipeline in 2026-05 — the heuristic was not trusted.
|
|
# Planner state machine (formerly section 6) was also removed in
|
|
# 2026-05. Both `_render_invocation_sequence` and
|
|
# `_render_planner_state` stay callable for a future re-enable;
|
|
# see `test_nga_invocation_sequence_has_orchestrator_lane` and
|
|
# `test_nga_state_diagram_has_par_and_block`.
|
|
self.assertIn("# Architecture", text)
|
|
for heading in (
|
|
"## 2. Anatomy summary",
|
|
"## 3. Action tree",
|
|
"## 4. Topic anatomy",
|
|
"## 5. Action catalog",
|
|
"## 6. Data flow / context propagation",
|
|
"## 7. Flow / Apex / Prompt catalogs",
|
|
"## 8. Unresolved refs + artifact pointers",
|
|
):
|
|
self.assertIn(heading, text)
|
|
# Explicit regression guard: the retired sections must NOT appear.
|
|
self.assertNotIn("## 3. Invocation sequence", text)
|
|
self.assertNotIn("Invocation sequence", text)
|
|
self.assertNotIn("Planner state machine", text)
|
|
|
|
def test_two_mermaid_diagrams_rendered(self):
|
|
tree_path = _write_tree(self.tmp, _classic_react_tree())
|
|
out = self.tmp / "architecture.md"
|
|
render_architecture.render(tree_path, out)
|
|
text = out.read_text()
|
|
# each diagram-kind keyword must appear as a bare
|
|
# non-comment line inside exactly one fenced block. Keywords in
|
|
# prose inside `%%` comments are now harmless (Mermaid parses
|
|
# them as comments at render time), so we don't policework the
|
|
# comment contents — we only require a real keyword line.
|
|
# sequenceDiagram was removed from the default pipeline in 2026-05
|
|
# along with the Invocation sequence section; stateDiagram-v2
|
|
# was removed the same cycle along with Planner state machine.
|
|
import re
|
|
blocks = re.findall(r"```mermaid\n(.*?)\n```", text, re.DOTALL)
|
|
keywords = {"flowchart TB", "flowchart LR"}
|
|
seen_keywords: set[str] = set()
|
|
for b in blocks:
|
|
for ln in b.splitlines():
|
|
stripped = ln.strip()
|
|
if stripped.startswith("%%"):
|
|
continue
|
|
if stripped in keywords:
|
|
seen_keywords.add(stripped)
|
|
self.assertEqual(seen_keywords, keywords)
|
|
# Regression: sequenceDiagram and stateDiagram-v2 must NOT appear
|
|
# in the default render.
|
|
for b in blocks:
|
|
for ln in b.splitlines():
|
|
stripped = ln.strip()
|
|
if stripped.startswith("%%"):
|
|
continue
|
|
self.assertNotEqual(stripped, "sequenceDiagram")
|
|
self.assertNotEqual(stripped, "stateDiagram-v2")
|
|
# Every block must be fully substituted — no stray `{{PARAM}}`
|
|
# tokens left behind. removed the `{{...}}` from
|
|
# comment headers precisely to make this invariant hold.
|
|
for b in blocks:
|
|
self.assertNotIn("{{", b)
|
|
self.assertNotIn("}}", b)
|
|
# No dependency graph when _unresolved is empty.
|
|
self.assertNotIn("## Dependency graph", text)
|
|
|
|
def test_react_state_machine_not_in_default_render(self):
|
|
# Planner state machine was retired from the default pipeline in
|
|
# 2026-05. Thought/Action/Observation states are emitted by
|
|
# `_render_planner_state` which is still tested directly but not
|
|
# wired into `render`.
|
|
tree_path = _write_tree(self.tmp, _classic_react_tree())
|
|
out = self.tmp / "architecture.md"
|
|
render_architecture.render(tree_path, out)
|
|
text = out.read_text()
|
|
self.assertNotIn("Thought", text)
|
|
self.assertNotIn("Action --> Observation", text)
|
|
|
|
|
|
class RenderNgaTests(_RenderTestBase):
|
|
def test_nga_state_diagram_has_par_and_block(self):
|
|
# `_render_planner_state` was removed from the default render
|
|
# pipeline in 2026-05, but the function + mermaid template are
|
|
# retained so the feature can be re-enabled by uncommenting one
|
|
# line in `render`. This test exercises the function directly to
|
|
# keep the NGA par/and-block behaviour covered.
|
|
tree = _nga_tree()
|
|
rendered = render_architecture._render_planner_state(
|
|
tree["agent"], "nga",
|
|
dict(render_architecture.DEFAULT_MAX_MERMAID_NODES),
|
|
)
|
|
# par/and block marker is the `--` separator inside an `Orchestration`
|
|
# state nest. Also confirm the lanes differ from ReAct.
|
|
self.assertIn("state Orchestration", rendered)
|
|
self.assertIn("--", rendered)
|
|
self.assertIn("SubAgentA", rendered)
|
|
self.assertIn("SubAgentB", rendered)
|
|
# ReAct-specific states must NOT appear.
|
|
self.assertNotIn("Thought", rendered)
|
|
|
|
def test_nga_invocation_sequence_has_orchestrator_lane(self):
|
|
# `_render_invocation_sequence` was removed from the default
|
|
# render pipeline in 2026-05 (heuristic distrusted), but the
|
|
# function + mermaid template are retained so the feature can
|
|
# be re-enabled by uncommenting one line in `render`. This test
|
|
# exercises the function directly to keep the behaviour covered.
|
|
tree = _nga_tree()
|
|
walker = render_architecture._TreeWalker(tree)
|
|
walker.walk()
|
|
rendered = render_architecture._render_invocation_sequence(
|
|
tree, tree["agent"], walker,
|
|
dict(render_architecture.DEFAULT_MAX_MERMAID_NODES),
|
|
)
|
|
self.assertIn("participant Orchestrator", rendered)
|
|
self.assertIn("participant SubAgent", rendered)
|
|
|
|
|
|
class RenderSequentialTests(_RenderTestBase):
|
|
def test_sequential_zero_topic_produces_empty_topic_section(self):
|
|
tree_path = _write_tree(self.tmp, _sequential_tree())
|
|
out = self.tmp / "architecture.md"
|
|
render_architecture.render(tree_path, out)
|
|
text = out.read_text()
|
|
self.assertIn("_No topics defined", text)
|
|
# Sequential state machine uses Classify -> Execute, but the
|
|
# Planner state machine section was retired from the default
|
|
# pipeline in 2026-05 — exercise the helper directly instead.
|
|
rendered = render_architecture._render_planner_state(
|
|
_sequential_tree()["agent"], "classic",
|
|
dict(render_architecture.DEFAULT_MAX_MERMAID_NODES),
|
|
)
|
|
self.assertIn("Classify --> Execute", rendered)
|
|
|
|
def test_sequential_does_not_emit_react_states(self):
|
|
tree_path = _write_tree(self.tmp, _sequential_tree())
|
|
out = self.tmp / "architecture.md"
|
|
render_architecture.render(tree_path, out)
|
|
text = out.read_text()
|
|
self.assertNotIn("Thought", text)
|
|
self.assertNotIn("Orchestration", text)
|
|
|
|
|
|
class RenderPartialTreeTests(_RenderTestBase):
|
|
def test_partial_true_emits_health_callout(self):
|
|
tree = _classic_react_tree()
|
|
tree["_partial"] = True
|
|
tree["_partial_reason"] = "flow-metadata-timeout"
|
|
tree["_pending_fetches"] = {"FLOW": ["Foo", "Bar"], "APEX": []}
|
|
tree_path = _write_tree(self.tmp, tree)
|
|
out = self.tmp / "architecture.md"
|
|
render_architecture.render(tree_path, out)
|
|
text = out.read_text()
|
|
self.assertIn("**Health: PARTIAL.**", text)
|
|
self.assertIn("flow-metadata-timeout", text)
|
|
self.assertIn("Pending fetches: 2", text)
|
|
|
|
def test_missing_planner_name_emits_warn(self):
|
|
tree = _classic_react_tree()
|
|
tree["agent"]["planner_name"] = None
|
|
tree_path = _write_tree(self.tmp, tree)
|
|
out = self.tmp / "architecture.md"
|
|
render_architecture.render(tree_path, out)
|
|
text = out.read_text()
|
|
self.assertIn("`planner_name` missing", text)
|
|
|
|
|
|
class RenderUnresolvedAndCyclesTests(_RenderTestBase):
|
|
def test_unresolved_renders_dependency_graph(self):
|
|
tree = _classic_react_tree()
|
|
tree["_unresolved"] = [
|
|
{"kind": "FLOW", "api_name": "Missing_Flow",
|
|
"reason": "not-in-org"},
|
|
]
|
|
tree_path = _write_tree(self.tmp, tree)
|
|
out = self.tmp / "architecture.md"
|
|
render_architecture.render(tree_path, out)
|
|
text = out.read_text()
|
|
self.assertIn("## Dependency graph", text)
|
|
self.assertIn("Missing_Flow", text)
|
|
# Section 8 renders the unresolved row in its table.
|
|
self.assertIn("not-in-org", text)
|
|
|
|
def test_cycle_annotation_renders_dotted_back_edge(self):
|
|
tree = _classic_react_tree()
|
|
# Inject a _cycle_back_to annotation on the Find_Public_Articles flow.
|
|
tree["root"]["children"][0]["children"][0]["children"][0][
|
|
"_cycle_back_to"] = "Find_Public_Articles"
|
|
tree_path = _write_tree(self.tmp, tree)
|
|
out = self.tmp / "architecture.md"
|
|
render_architecture.render(tree_path, out)
|
|
text = out.read_text()
|
|
# Dotted back-edge syntax `-.->` plus the `cycle_back_to:` label.
|
|
self.assertIn("-.->", text)
|
|
self.assertIn("cycle_back_to", text)
|
|
|
|
|
|
class RenderNodeCapTests(_RenderTestBase):
|
|
def _large_tree(self, n_actions: int) -> dict:
|
|
tree = _classic_react_tree()
|
|
# Overwrite the second topic with a fan-out of n_actions children.
|
|
big_topic = {
|
|
"kind": "TOPIC",
|
|
"api_name": "Mega_Topic",
|
|
"children": [
|
|
{
|
|
"kind": "GEN_AI_FUNCTION",
|
|
"api_name": f"Action_{i:03d}",
|
|
"children": [
|
|
{"kind": "APEX", "api_name": f"ApexClass_{i:03d}"},
|
|
],
|
|
}
|
|
for i in range(n_actions)
|
|
],
|
|
}
|
|
tree["root"]["children"] = [big_topic]
|
|
tree["_kind_counts"] = {
|
|
"BOT_DEFINITION": 1,
|
|
"TOPIC": 1,
|
|
"GEN_AI_FUNCTION": n_actions,
|
|
"APEX": n_actions,
|
|
}
|
|
return tree
|
|
|
|
def test_flowchart_cap_exceeded_emits_placeholder(self):
|
|
# 250 actions + 250 apex + 1 topic + 1 bot = 502 nodes > 200 cap.
|
|
tree = self._large_tree(250)
|
|
tree_path = _write_tree(self.tmp, tree)
|
|
out = self.tmp / "architecture.md"
|
|
render_architecture.render(tree_path, out)
|
|
text = out.read_text()
|
|
self.assertIn("[diagram truncated: flowchart", text)
|
|
self.assertIn("exceed cap of 200", text)
|
|
# Top-5 fan-out line present.
|
|
self.assertIn("Top 5 nodes by fan-out", text)
|
|
# Mega_Topic shows up as a top fan-out node.
|
|
self.assertIn("Mega_Topic", text)
|
|
|
|
def test_react_30_topics_does_not_false_trip_sequence_cap(self):
|
|
# pre-fix, msg_count was computed as
|
|
# `2 * len(topics) + len(actions) + 2`, which for 30 topics
|
|
# yields 62+ > cap=60. But ReAct only samples topics[:5], so
|
|
# the rendered sequence has ~14 messages — nowhere near the
|
|
# cap. Assert the diagram renders, no truncation placeholder.
|
|
# 2026-05: `_render_invocation_sequence` is no longer wired
|
|
# into `render`; invoke it directly so the cap math stays
|
|
# covered.
|
|
tree = _classic_react_tree()
|
|
topics = [
|
|
{
|
|
"kind": "TOPIC",
|
|
"api_name": f"Topic_{i:02d}",
|
|
"children": [
|
|
{"kind": "GEN_AI_FUNCTION",
|
|
"api_name": f"Action_{i:02d}"},
|
|
],
|
|
}
|
|
for i in range(30)
|
|
]
|
|
tree["root"]["children"] = topics
|
|
tree["_kind_counts"] = {
|
|
"BOT_DEFINITION": 1, "TOPIC": 30, "GEN_AI_FUNCTION": 30,
|
|
}
|
|
walker = render_architecture._TreeWalker(tree)
|
|
walker.walk()
|
|
rendered = render_architecture._render_invocation_sequence(
|
|
tree, tree["agent"], walker,
|
|
dict(render_architecture.DEFAULT_MAX_MERMAID_NODES),
|
|
)
|
|
self.assertNotIn("[diagram truncated: sequenceDiagram", rendered)
|
|
self.assertIn("sequenceDiagram", rendered)
|
|
# Sampling at :5 means exactly 5 topics show up in the messages.
|
|
# Topic_00..Topic_04 must appear; Topic_05 must not.
|
|
self.assertIn("Topic_04", rendered)
|
|
self.assertNotIn("Topic_05", rendered)
|
|
|
|
def test_sequence_cap_trips_on_actually_rendered_overflow(self):
|
|
# construct a scenario where the rendered message
|
|
# list truly exceeds the cap. We use a tiny cap (5) rather than
|
|
# fabricating 60+ NGA messages — the test's intent is "cap math
|
|
# is evaluated against the rendered list", not "cap=60 is
|
|
# specifically correct".
|
|
# 2026-05: invoke `_render_invocation_sequence` directly since
|
|
# the section was retired from the default pipeline.
|
|
tree = _classic_react_tree()
|
|
walker = render_architecture._TreeWalker(tree)
|
|
walker.walk()
|
|
caps = dict(render_architecture.DEFAULT_MAX_MERMAID_NODES)
|
|
caps["sequenceDiagram"] = 3
|
|
rendered = render_architecture._render_invocation_sequence(
|
|
tree, tree["agent"], walker, caps,
|
|
)
|
|
self.assertIn("[diagram truncated: sequenceDiagram", rendered)
|
|
self.assertIn("exceed cap of 3", rendered)
|
|
|
|
|
|
class RenderLoadMermaidTests(_RenderTestBase):
|
|
def test_nested_placeholder_in_value_logs_warning(self):
|
|
with self.assertLogs(render_architecture.logger, level="WARNING") as cm:
|
|
out = render_architecture.load_mermaid(
|
|
"invocation_sequence",
|
|
PARTICIPANTS="participant {{NESTED}}",
|
|
MESSAGES="User->>+Planner: x",
|
|
)
|
|
self.assertTrue(any("nested placeholder" in m for m in cm.output))
|
|
# Still renders — the nested token is left as-is (no second pass).
|
|
self.assertIn("{{NESTED}}", out)
|
|
|
|
def test_missing_template_raises_filenotfounderror_without_path(self):
|
|
with self.assertRaises(FileNotFoundError) as ctx:
|
|
render_architecture.load_mermaid("nonexistent_template_zzz")
|
|
msg = str(ctx.exception)
|
|
self.assertIn("nonexistent_template_zzz", msg)
|
|
# Hygiene: absolute SKILL_ROOT path must not bleed through.
|
|
self.assertNotIn("/assets/mermaid", msg)
|
|
|
|
def test_traversal_name_rejected_without_absolute_path_leak(self):
|
|
# The hygiene contract is that the absolute MERMAID_DIR path
|
|
# must not appear in the error text — echoing the caller's
|
|
# own input back (which here happens to contain `/etc/passwd`)
|
|
# is fine; that's information the caller already had.
|
|
with self.assertRaises(FileNotFoundError) as ctx:
|
|
render_architecture.load_mermaid("../../../etc/passwd")
|
|
msg = str(ctx.exception)
|
|
self.assertNotIn(str(_REPO_MERMAID_DIR), msg)
|
|
# Also must not leak the absolute install path. Asserting the user's
|
|
# home directory doesn't appear is a stricter, runtime-agnostic check
|
|
# than naming any specific install root (.claude / .vibe /
|
|
# plugin-specific layouts).
|
|
self.assertNotIn(str(Path.home()), msg)
|
|
|
|
def test_non_string_param_raises_typeerror(self):
|
|
with self.assertRaises(TypeError):
|
|
render_architecture.load_mermaid(
|
|
"invocation_sequence",
|
|
PARTICIPANTS=None, # type: ignore[arg-type]
|
|
MESSAGES="x",
|
|
)
|
|
|
|
def test_leading_comment_block_preserved_but_not_substituted(self):
|
|
# the `%%` header comments in the shipped templates
|
|
# are kept in rendered output (Mermaid ignores `%%` lines at
|
|
# render time). The prior _strip_leading_comment_block workaround
|
|
# was a misdiagnosis — the real bug was templates documenting
|
|
# placeholders as `{{NAME}}` inside `%%` comments, causing
|
|
# load_mermaid's str.replace to substitute them.
|
|
out = render_architecture.load_mermaid(
|
|
"action_tree", SUBGRAPHS="SG_INJECTED", EDGES="EDGES_INJECTED",
|
|
)
|
|
# Header comments survive.
|
|
self.assertTrue(out.startswith("%%"))
|
|
# `%%` lines must not have been corrupted by substitution.
|
|
for ln in out.splitlines():
|
|
if ln.startswith("%%"):
|
|
self.assertNotIn("SG_INJECTED", ln)
|
|
self.assertNotIn("EDGES_INJECTED", ln)
|
|
# Diagram-kind keyword still present on its own bare line.
|
|
self.assertIn("\nflowchart TB", "\n" + out)
|
|
# Placeholders in the body were substituted exactly once.
|
|
self.assertIn("SG_INJECTED", out)
|
|
self.assertIn("EDGES_INJECTED", out)
|
|
|
|
|
|
class RenderEmptyAndSearchTests(_RenderTestBase):
|
|
def test_empty_bot_definition_renders_without_crash(self):
|
|
tree = {
|
|
"_schema_version": "3.0",
|
|
"agent": {
|
|
"api_name": "Empty",
|
|
"version": "v1",
|
|
"generation": "classic",
|
|
"planner_type": "AiCopilot__ReAct",
|
|
"planner_name": "Empty_Planner",
|
|
},
|
|
"root": {"kind": "BOT_DEFINITION", "api_name": "Empty",
|
|
"children": []},
|
|
"node_count": 1,
|
|
"depth": 0,
|
|
"_partial": False,
|
|
"_unresolved": [],
|
|
"_kind_counts": {"BOT_DEFINITION": 1},
|
|
}
|
|
tree_path = _write_tree(self.tmp, tree)
|
|
out = self.tmp / "architecture.md"
|
|
render_architecture.render(tree_path, out)
|
|
text = out.read_text()
|
|
self.assertIn("_No topics defined", text)
|
|
self.assertIn("_No actions declared._", text)
|
|
self.assertIn("_No backing artifacts in tree._", text)
|
|
|
|
def test_search_generation_skips_state_diagram(self):
|
|
# Planner state machine was retired from the default render in
|
|
# 2026-05. Exercise `_render_planner_state` directly to keep the
|
|
# search-generation prose-placeholder branch covered.
|
|
tree = _classic_react_tree()
|
|
tree["agent"]["generation"] = "search"
|
|
tree["agent"]["planner_type"] = "custom_search_Apex"
|
|
rendered = render_architecture._render_planner_state(
|
|
tree["agent"], "search",
|
|
dict(render_architecture.DEFAULT_MAX_MERMAID_NODES),
|
|
)
|
|
self.assertIn("Custom planner", rendered)
|
|
self.assertNotIn("stateDiagram-v2", rendered)
|
|
|
|
def test_byop_generation_skips_state_diagram(self):
|
|
# Same 2026-05 retirement — exercise the helper directly.
|
|
tree = _classic_react_tree()
|
|
tree["agent"]["generation"] = "byop"
|
|
rendered = render_architecture._render_planner_state(
|
|
tree["agent"], "byop",
|
|
dict(render_architecture.DEFAULT_MAX_MERMAID_NODES),
|
|
)
|
|
self.assertIn("Custom planner", rendered)
|
|
|
|
|
|
class RenderPromptCatalogTests(_RenderTestBase):
|
|
"""Section-7 'Prompt templates' sub-section shape.
|
|
|
|
Flows and Apex classes each get an H4 per entry with a fenced
|
|
signature block. Prompts were historically rendered as a bare bullet
|
|
list (just names) which left the reader with no indication of what
|
|
each prompt does. We now mirror the flow/apex shape: H4 heading,
|
|
optional `Type:` line, optional signature fence, `_Details not
|
|
captured._` fallback when the walker has no metadata on the node.
|
|
Wave B doesn't yet stamp prompt signatures — the renderer just has
|
|
to be ready for when it does."""
|
|
|
|
def _tree_with_prompts(self, prompt_nodes: list[dict]) -> dict:
|
|
"""Wrap two prompt nodes under a TOPIC -> GEN_AI_FUNCTION so the
|
|
walker indexes them into `walker.prompts`."""
|
|
tree = _classic_react_tree()
|
|
tree["root"]["children"].append({
|
|
"kind": "TOPIC",
|
|
"api_name": "PromptTopic",
|
|
"children": [
|
|
{
|
|
"kind": "GEN_AI_FUNCTION",
|
|
"api_name": "UsePrompts",
|
|
"children": prompt_nodes,
|
|
},
|
|
],
|
|
})
|
|
return tree
|
|
|
|
def test_prompt_h4_per_entry_with_type_and_fallback(self):
|
|
r"""Two prompts: one with `prompt_type` set, one without. Both
|
|
get H4 headings. The typed one shows ``Type: `flex```; the
|
|
bare one shows `_Details not captured._`."""
|
|
tree = self._tree_with_prompts([
|
|
{
|
|
"kind": "PROMPT_TEMPLATE",
|
|
"api_name": "DraftReply",
|
|
"prompt_type": "flex",
|
|
"children": [],
|
|
},
|
|
{
|
|
"kind": "PROMPT_TEMPLATE",
|
|
"api_name": "SummarizeThread",
|
|
"children": [],
|
|
},
|
|
])
|
|
tree_path = _write_tree(self.tmp, tree)
|
|
out = self.tmp / "architecture.md"
|
|
render_architecture.render(tree_path, out)
|
|
text = out.read_text()
|
|
|
|
self.assertIn("### Prompt templates", text)
|
|
# Both prompts render as H4, not as bare bullets.
|
|
self.assertIn("#### `DraftReply`", text)
|
|
self.assertIn("#### `SummarizeThread`", text)
|
|
self.assertNotIn("- `DraftReply`", text)
|
|
self.assertNotIn("- `SummarizeThread`", text)
|
|
# Typed prompt surfaces its Type line.
|
|
self.assertIn("- Type: `flex`", text)
|
|
# Untyped prompt (no signature either) falls back honestly.
|
|
# Locate the SummarizeThread block and confirm its body.
|
|
import re
|
|
m = re.search(
|
|
r"#### `SummarizeThread`\n\n(.*?)(?:\n#### |\n### |\n## |\Z)",
|
|
text, re.DOTALL,
|
|
)
|
|
self.assertIsNotNone(m, "SummarizeThread prompt block not found")
|
|
self.assertIn("_Details not captured._", m.group(1))
|
|
|
|
def test_prompt_with_signature_renders_fenced_block(self):
|
|
"""When a prompt node carries a `signature` (future Wave B
|
|
stamp), the renderer emits it inside a fenced code block, same
|
|
shape used for flows/apex."""
|
|
tree = self._tree_with_prompts([
|
|
{
|
|
"kind": "PROMPT_TEMPLATE",
|
|
"api_name": "ClassifyIntent",
|
|
"prompt_type": "genAiPromptTemplate",
|
|
"signature": "in: userUtterance: String | out: intent: String",
|
|
"children": [],
|
|
},
|
|
])
|
|
tree_path = _write_tree(self.tmp, tree)
|
|
out = self.tmp / "architecture.md"
|
|
render_architecture.render(tree_path, out)
|
|
text = out.read_text()
|
|
|
|
self.assertIn("#### `ClassifyIntent`", text)
|
|
self.assertIn("- Type: `genAiPromptTemplate`", text)
|
|
# Fenced signature block present.
|
|
self.assertIn(
|
|
"```\nin: userUtterance: String | out: intent: String\n```",
|
|
text,
|
|
)
|
|
# No fallback when we have either type or sig.
|
|
import re
|
|
m = re.search(
|
|
r"#### `ClassifyIntent`\n\n(.*?)(?:\n#### |\n### |\n## |\Z)",
|
|
text, re.DOTALL,
|
|
)
|
|
self.assertIsNotNone(m)
|
|
self.assertNotIn("_Details not captured._", m.group(1))
|
|
|
|
|
|
class RenderPromptTemplateBodyTests(_RenderTestBase):
|
|
"""Gap C (2026-05-05): prompt template bodies retrieved via
|
|
`retrieve_prompt_templates` are stamped onto PROMPT_TEMPLATE leaves
|
|
as `master_label`, `content`, `inputs`, `_body_available`. The
|
|
renderer emits:
|
|
- `_Label: <master_label>_` blurb
|
|
- `**Inputs**:` bulleted list
|
|
- fenced `text` code block for the content
|
|
- `_Body not retrieved._` when `_body_available` is False and no
|
|
other details are available
|
|
"""
|
|
|
|
def _tree_with_prompts(self, prompt_nodes: list[dict]) -> dict:
|
|
tree = _classic_react_tree()
|
|
tree["root"]["children"].append({
|
|
"kind": "TOPIC",
|
|
"api_name": "PromptTopic",
|
|
"children": [
|
|
{
|
|
"kind": "GEN_AI_FUNCTION",
|
|
"api_name": "UsePrompts",
|
|
"children": prompt_nodes,
|
|
},
|
|
],
|
|
})
|
|
return tree
|
|
|
|
def test_body_rendered_with_label_inputs_and_fenced_content(self):
|
|
tree = self._tree_with_prompts([
|
|
{
|
|
"kind": "PROMPT_TEMPLATE",
|
|
"api_name": "AGNT_US_Q_A_with_Site_Scraping",
|
|
"master_label": "AGNT - US Q&A with Site Scraping",
|
|
"content": "# ROLE & OBJECTIVE\nAnswer the customer question.",
|
|
"inputs": [
|
|
{"name": "Query", "dataType": "String"},
|
|
{"name": "Site", "dataType": "String"},
|
|
],
|
|
"_body_available": True,
|
|
"children": [],
|
|
},
|
|
])
|
|
tree_path = _write_tree(self.tmp, tree)
|
|
out = self.tmp / "architecture.md"
|
|
render_architecture.render(tree_path, out)
|
|
text = out.read_text()
|
|
|
|
self.assertIn(
|
|
"#### `AGNT_US_Q_A_with_Site_Scraping`", text,
|
|
)
|
|
self.assertIn("_Label: AGNT - US Q&A with Site Scraping_", text)
|
|
self.assertIn("**Inputs**:", text)
|
|
self.assertIn("- `Query`: `String`", text)
|
|
self.assertIn("- `Site`: `String`", text)
|
|
# Fenced content block uses `text` language hint.
|
|
self.assertIn(
|
|
"```text\n# ROLE & OBJECTIVE\nAnswer the customer question.\n```",
|
|
text,
|
|
)
|
|
self.assertNotIn("_Details not captured._", text)
|
|
self.assertNotIn("_Body not retrieved._", text)
|
|
|
|
def test_body_unavailable_falls_back_honestly(self):
|
|
tree = self._tree_with_prompts([
|
|
{
|
|
"kind": "PROMPT_TEMPLATE",
|
|
"api_name": "MissingTpl",
|
|
"_body_available": False,
|
|
"children": [],
|
|
},
|
|
])
|
|
tree_path = _write_tree(self.tmp, tree)
|
|
out = self.tmp / "architecture.md"
|
|
render_architecture.render(tree_path, out)
|
|
text = out.read_text()
|
|
|
|
self.assertIn("#### `MissingTpl`", text)
|
|
import re
|
|
m = re.search(
|
|
r"#### `MissingTpl`\n\n(.*?)(?:\n#### |\n### |\n## |\Z)",
|
|
text, re.DOTALL,
|
|
)
|
|
self.assertIsNotNone(m)
|
|
block = m.group(1)
|
|
self.assertIn("_Body not retrieved._", block)
|
|
# No content fence; no Inputs block.
|
|
self.assertNotIn("```text", block)
|
|
self.assertNotIn("**Inputs**:", block)
|
|
|
|
def test_inputs_without_datatype_still_render(self):
|
|
"""Older templates may omit <dataType> on <inputs>. Render the
|
|
name alone rather than crashing or dropping the input."""
|
|
tree = self._tree_with_prompts([
|
|
{
|
|
"kind": "PROMPT_TEMPLATE",
|
|
"api_name": "LegacyTpl",
|
|
"content": "body",
|
|
"inputs": [{"name": "Query"}],
|
|
"_body_available": True,
|
|
"children": [],
|
|
},
|
|
])
|
|
tree_path = _write_tree(self.tmp, tree)
|
|
out = self.tmp / "architecture.md"
|
|
render_architecture.render(tree_path, out)
|
|
text = out.read_text()
|
|
|
|
self.assertIn("**Inputs**:", text)
|
|
self.assertIn("- `Query`", text)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|