@salesforce/afv-skills 1.47.0 → 1.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/education-cloud-domain-configure/SKILL.md +234 -0
- package/skills/education-cloud-domain-configure/references/dependent-features.md +198 -0
- package/skills/education-cloud-domain-configure/references/domain-features/academic-operations.md +386 -0
- package/skills/education-cloud-domain-configure/references/domain-features/alumni-relations.md +155 -0
- package/skills/education-cloud-domain-configure/references/domain-features/cross-domain-dependencies.md +18 -0
- package/skills/education-cloud-domain-configure/references/domain-features/industriessettings-toggle-reference.md +43 -0
- package/skills/education-cloud-domain-configure/references/domain-features/mentoring.md +198 -0
- package/skills/education-cloud-domain-configure/references/domain-features/recruitment-admissions.md +315 -0
- package/skills/education-cloud-domain-configure/references/domain-features/student-management.md +155 -0
- package/skills/education-cloud-domain-configure/references/domain-features/student-success.md +249 -0
- package/skills/education-cloud-domain-configure/references/domain-features.md +27 -0
- package/skills/education-cloud-domain-configure/references/gotchas-extended.md +23 -0
- package/skills/education-cloud-domain-configure/references/toggle-write-path.md +67 -0
- package/skills/education-cloud-domain-configure/scripts/check_license_provisioned.py +62 -0
- package/skills/education-cloud-domain-configure/scripts/check_org_edition.py +44 -0
- package/skills/education-cloud-domain-configure/scripts/check_permission_assigned.py +39 -0
- package/skills/education-cloud-multi-campus-configure/SKILL.md +7 -0
- package/skills/education-cloud-multi-campus-configure/references/foundation_prerequisites.md +4 -3
- package/skills/service-agentforce-channel-configure/SKILL.md +33 -28
- package/skills/service-agentforce-human-escalation-configure/SKILL.md +205 -0
- package/skills/service-agentforce-human-escalation-configure/assets/escalation-thresholds.instructions.md +37 -0
- package/skills/service-agentforce-human-escalation-configure/assets/force-app/main/default/flows/Human_Escalation_Outbound_Flow.flow-meta.xml +173 -0
- package/skills/service-agentforce-human-escalation-configure/assets/package.xml +8 -0
- package/skills/service-agentforce-human-escalation-configure/assets/sfdx-project.json +5 -0
- package/skills/service-agentforce-human-escalation-configure/references/classifier-contracts.md +103 -0
- package/skills/service-agentforce-human-escalation-configure/references/escalation-mechanism.md +63 -0
- package/skills/service-agentforce-human-escalation-configure/references/failure-policy.md +44 -0
- package/skills/service-agentforce-human-escalation-configure/references/nga-escalation.md +57 -0
- package/skills/service-agentforce-human-escalation-configure/references/runtime-verification.md +72 -0
- package/skills/service-agentforce-human-escalation-configure/scripts/classify-agent-active.mjs +49 -0
- package/skills/service-agentforce-human-escalation-configure/scripts/classify-nga-escalation.mjs +166 -0
- package/skills/service-agentforce-human-escalation-configure/scripts/extract-outbound-route.mjs +67 -0
- package/skills/service-agentforce-human-escalation-configure/scripts/patch-escalation-surfaces.mjs +131 -0
- package/skills/service-agentforce-human-escalation-configure/scripts/tests/__init__.py +0 -0
- package/skills/service-agentforce-human-escalation-configure/scripts/tests/_bootstrap.py +249 -0
- package/skills/service-agentforce-human-escalation-configure/scripts/tests/test_escalation_contracts.py +899 -0
- package/skills/service-agentforce-human-escalation-configure/scripts/verify-and-configure.sh +705 -0
- package/skills/service-agentforce-human-escalation-configure/scripts/verify-escalation-config.mjs +89 -0
- package/skills/service-de-channel-routing-configure/SKILL.md +13 -5
- package/skills/service-de-channel-routing-configure/scripts/tests/test_skill_contract.py +25 -0
- package/skills/service-itsm-agentic-setup-agentforce-coordinate/SKILL.md +23 -10
- package/skills/service-itsm-agentic-setup-agentforce-coordinate/examples/output-templates.md +63 -48
- package/skills/service-itsm-agentic-setup-agentforce-coordinate/scripts/verify-child-verdict.mjs +6 -2
- package/skills/service-omni-agent-users-create/SKILL.md +96 -0
- package/skills/service-omni-agent-users-create/assets/create-users.apex.template +80 -0
- package/skills/service-omni-agent-users-create/references/apex-patterns.md +135 -0
- package/skills/service-omni-agent-users-create/references/apex-template-notes.md +40 -0
- package/skills/service-omni-agent-users-create/scripts/detect-and-create.sh +206 -0
- package/skills/service-omni-agent-users-create/scripts/detect-existing.sh +122 -0
- package/skills/service-omni-agent-users-create/scripts/run-create.sh +298 -0
- package/skills/service-omni-agent-users-create/scripts/tests/test_user_create_security.py +171 -0
- package/skills/service-omni-agent-work-sharing-configure/SKILL.md +78 -0
- package/skills/service-omni-agent-work-sharing-configure/references/api-notes.md +31 -0
- package/skills/service-omni-agent-work-sharing-configure/scripts/configure-and-report.sh +186 -0
- package/skills/service-omni-agent-work-sharing-configure/scripts/tests/_bootstrap.py +120 -0
- package/skills/service-omni-agent-work-sharing-configure/scripts/tests/test_agent_work_sharing_contracts.py +93 -0
- package/skills/service-omni-base-settings-configure/SKILL.md +77 -0
- package/skills/service-omni-base-settings-configure/assets/force-app/main/default/settings/OmniChannel.settings-meta.xml +8 -0
- package/skills/service-omni-base-settings-configure/assets/package.xml +8 -0
- package/skills/service-omni-base-settings-configure/assets/sfdx-project.json +12 -0
- package/skills/service-omni-base-settings-configure/references/api-notes.md +121 -0
- package/skills/service-omni-base-settings-configure/scripts/configure-and-report.sh +164 -0
- package/skills/service-omni-base-settings-configure/scripts/detect-existing.sh +69 -0
- package/skills/service-omni-channel-setup-coordinate/SKILL.md +168 -0
- package/skills/service-omni-channel-setup-coordinate/examples/output-templates.md +161 -0
- package/skills/service-omni-channel-setup-coordinate/references/gap-catalog.md +99 -0
- package/skills/service-omni-channel-setup-coordinate/scripts/integration-driver.sh +1542 -0
- package/skills/service-omni-channel-setup-coordinate/scripts/tests/__init__.py +0 -0
- package/skills/service-omni-channel-setup-coordinate/scripts/tests/_bootstrap.py +223 -0
- package/skills/service-omni-channel-setup-coordinate/scripts/tests/test_omni_contracts.py +804 -0
- package/skills/service-omni-command-center-analyze/SKILL.md +84 -0
- package/skills/service-omni-command-center-analyze/references/api-notes.md +54 -0
- package/skills/service-omni-command-center-analyze/scripts/analyze.sh +198 -0
- package/skills/service-omni-command-center-analyze/scripts/tests/_bootstrap.py +115 -0
- package/skills/service-omni-command-center-analyze/scripts/tests/test_command_center_analyze_contracts.py +165 -0
- package/skills/service-omni-permission-set-assign/SKILL.md +88 -0
- package/skills/service-omni-permission-set-assign/assets/package.xml +8 -0
- package/skills/service-omni-permission-set-assign/references/api-notes.md +143 -0
- package/skills/service-omni-permission-set-assign/scripts/verify-and-assign.sh +397 -0
- package/skills/service-omni-presence-status-deploy/SKILL.md +87 -0
- package/skills/service-omni-presence-status-deploy/assets/package.xml +12 -0
- package/skills/service-omni-presence-status-deploy/references/api-notes.md +115 -0
- package/skills/service-omni-presence-status-deploy/scripts/deploy-and-report.sh +422 -0
- package/skills/service-omni-presence-user-config-deploy/SKILL.md +80 -0
- package/skills/service-omni-presence-user-config-deploy/references/api-notes.md +60 -0
- package/skills/service-omni-presence-user-config-deploy/scripts/deploy-and-report.sh +261 -0
- package/skills/service-omni-presence-user-config-deploy/scripts/tests/_bootstrap.py +106 -0
- package/skills/service-omni-presence-user-config-deploy/scripts/tests/test_presence_user_config_contracts.py +142 -0
- package/skills/service-omni-queue-deploy/SKILL.md +91 -0
- package/skills/service-omni-queue-deploy/references/api-notes.md +140 -0
- package/skills/service-omni-queue-deploy/scripts/verify-and-align.sh +470 -0
- package/skills/service-omni-queue-members-assign/SKILL.md +93 -0
- package/skills/service-omni-queue-members-assign/references/api-notes.md +144 -0
- package/skills/service-omni-queue-members-assign/scripts/tests/_bootstrap.py +67 -0
- package/skills/service-omni-queue-members-assign/scripts/tests/test_queue_members_contracts.py +73 -0
- package/skills/service-omni-queue-members-assign/scripts/verify-and-bind.sh +362 -0
- package/skills/service-omni-queue-routing-config-deploy/SKILL.md +101 -0
- package/skills/service-omni-queue-routing-config-deploy/references/api-notes.md +127 -0
- package/skills/service-omni-queue-routing-config-deploy/scripts/tests/_bootstrap.py +90 -0
- package/skills/service-omni-queue-routing-config-deploy/scripts/tests/test_queue_routing_config_contracts.py +70 -0
- package/skills/service-omni-queue-routing-config-deploy/scripts/upsert-and-report.sh +538 -0
- package/skills/service-omni-routing-flow-deploy/SKILL.md +111 -0
- package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_Case_Trigger.SkillsBased.flow-meta.xml +94 -0
- package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_Case_Trigger.flow-meta.xml +88 -0
- package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_Cases.flow-meta.xml +144 -0
- package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_VoiceCall_Trigger.SkillsBased.flow-meta.xml +94 -0
- package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_VoiceCall_Trigger.flow-meta.xml +88 -0
- package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_VoiceCalls.flow-meta.xml +144 -0
- package/skills/service-omni-routing-flow-deploy/assets/package.xml +11 -0
- package/skills/service-omni-routing-flow-deploy/assets/sfdx-project.json +11 -0
- package/skills/service-omni-routing-flow-deploy/references/api-notes.md +265 -0
- package/skills/service-omni-routing-flow-deploy/scripts/deploy-and-report.sh +684 -0
- package/skills/service-omni-service-channel-configure/SKILL.md +83 -0
- package/skills/service-omni-service-channel-configure/assets/force-app/main/default/serviceChannels/Cases.serviceChannel-meta.xml +9 -0
- package/skills/service-omni-service-channel-configure/assets/force-app/main/default/serviceChannels/Incidents.serviceChannel-meta.xml +9 -0
- package/skills/service-omni-service-channel-configure/assets/force-app/main/default/serviceChannels/MessagingSessions.serviceChannel-meta.xml +9 -0
- package/skills/service-omni-service-channel-configure/assets/force-app/main/default/serviceChannels/VoiceCalls.serviceChannel-meta.xml +9 -0
- package/skills/service-omni-service-channel-configure/assets/package.xml +11 -0
- package/skills/service-omni-service-channel-configure/assets/sfdx-project.json +12 -0
- package/skills/service-omni-service-channel-configure/references/api-notes.md +84 -0
- package/skills/service-omni-service-channel-configure/scripts/deploy-and-report.sh +383 -0
- package/skills/service-omni-sidebar-configure/SKILL.md +77 -0
- package/skills/service-omni-sidebar-configure/references/api-notes.md +51 -0
- package/skills/service-omni-sidebar-configure/scripts/enable-and-report.sh +315 -0
- package/skills/service-omni-sidebar-configure/scripts/tests/_bootstrap.py +142 -0
- package/skills/service-omni-sidebar-configure/scripts/tests/test_sidebar_contracts.py +139 -0
- package/skills/service-omni-skills-based-routing-configure/SKILL.md +81 -0
- package/skills/service-omni-skills-based-routing-configure/references/api-notes.md +50 -0
- package/skills/service-omni-skills-based-routing-configure/scripts/configure-and-report.sh +270 -0
- package/skills/service-omni-skills-based-routing-configure/scripts/tests/_bootstrap.py +120 -0
- package/skills/service-omni-skills-based-routing-configure/scripts/tests/test_sbr_contracts.py +129 -0
- package/skills/service-omni-supervisor-config-deploy/SKILL.md +97 -0
- package/skills/service-omni-supervisor-config-deploy/assets/force-app/main/default/omniSupervisorConfigs/Omni_Supervisor.omniSupervisorConfig-meta.xml +9 -0
- package/skills/service-omni-supervisor-config-deploy/assets/package.xml +8 -0
- package/skills/service-omni-supervisor-config-deploy/assets/sfdx-project.json +12 -0
- package/skills/service-omni-supervisor-config-deploy/references/api-notes.md +205 -0
- package/skills/service-omni-supervisor-config-deploy/scripts/deploy-and-report.sh +586 -0
- package/skills/service-omni-supervisor-config-deploy/scripts/tests/_bootstrap.py +153 -0
- package/skills/service-omni-supervisor-config-deploy/scripts/tests/test_supervisor_config_contracts.py +116 -0
- package/skills/service-omni-supervisor-permset-assign/SKILL.md +88 -0
- package/skills/service-omni-supervisor-permset-assign/references/api-notes.md +48 -0
- package/skills/service-omni-supervisor-permset-assign/scripts/verify-and-assign.sh +316 -0
- package/skills/service-omni-supervisor-surface-deploy/SKILL.md +85 -0
- package/skills/service-omni-supervisor-surface-deploy/references/api-notes.md +47 -0
- package/skills/service-omni-supervisor-surface-deploy/scripts/deploy-and-report.sh +216 -0
- package/skills/service-omni-supervisor-surface-deploy/scripts/tests/_bootstrap.py +95 -0
- package/skills/service-omni-supervisor-surface-deploy/scripts/tests/test_supervisor_surface_contracts.py +154 -0
- package/skills/service-omni-supervisor-users-create/SKILL.md +92 -0
- package/skills/service-omni-supervisor-users-create/assets/create-supervisors.apex.template +72 -0
- package/skills/service-omni-supervisor-users-create/references/apex-patterns.md +73 -0
- package/skills/service-omni-supervisor-users-create/references/apex-template-notes.md +41 -0
- package/skills/service-omni-supervisor-users-create/scripts/detect-and-create.sh +197 -0
- package/skills/service-omni-supervisor-users-create/scripts/detect-existing.sh +112 -0
- package/skills/service-omni-supervisor-users-create/scripts/run-create.sh +296 -0
- package/skills/service-omni-work-skill-routing-configure/SKILL.md +80 -0
- package/skills/service-omni-work-skill-routing-configure/references/api-notes.md +67 -0
- package/skills/service-omni-work-skill-routing-configure/scripts/configure-and-report.sh +262 -0
- package/skills/service-omni-work-skill-routing-configure/scripts/tests/_bootstrap.py +121 -0
- package/skills/service-omni-work-skill-routing-configure/scripts/tests/test_work_skill_routing_contracts.py +161 -0
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
"""Org-free contract tests for service-agentforce-human-escalation-configure.
|
|
2
|
+
|
|
3
|
+
Run standalone: python3 skills/service-agentforce-human-escalation-configure/scripts/tests/test_escalation_contracts.py
|
|
4
|
+
Or via pytest / unittest discovery.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import unittest
|
|
13
|
+
import xml.dom.minidom
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# Allow standalone execution (no package context) as well as discovery.
|
|
17
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
18
|
+
from _bootstrap import ( # noqa: E402
|
|
19
|
+
SKILLS_ROOT,
|
|
20
|
+
SKILL_DIR,
|
|
21
|
+
SCRIPT,
|
|
22
|
+
VERDICT_SCRIPT,
|
|
23
|
+
FakeSf,
|
|
24
|
+
_FAKE_SF_PROD,
|
|
25
|
+
_FAKE_SF_SANDBOX,
|
|
26
|
+
run,
|
|
27
|
+
run_node,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _last_json(text):
|
|
32
|
+
"""Extract the last JSON object printed to stdout (the skill's verdict)."""
|
|
33
|
+
depth = 0
|
|
34
|
+
start = None
|
|
35
|
+
obj = None
|
|
36
|
+
for i, ch in enumerate(text):
|
|
37
|
+
if ch == "{":
|
|
38
|
+
if depth == 0:
|
|
39
|
+
start = i
|
|
40
|
+
depth += 1
|
|
41
|
+
elif ch == "}":
|
|
42
|
+
depth -= 1
|
|
43
|
+
if depth == 0 and start is not None:
|
|
44
|
+
try:
|
|
45
|
+
obj = json.loads(text[start:i + 1])
|
|
46
|
+
except json.JSONDecodeError:
|
|
47
|
+
pass
|
|
48
|
+
start = None
|
|
49
|
+
return obj
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SyntaxTests(unittest.TestCase):
|
|
53
|
+
def test_bash_syntax_valid(self):
|
|
54
|
+
proc = subprocess.run(["bash", "-n", str(SKILLS_ROOT / SCRIPT)],
|
|
55
|
+
capture_output=True, text=True)
|
|
56
|
+
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
57
|
+
|
|
58
|
+
def test_node_helpers_parse(self):
|
|
59
|
+
for rel in (
|
|
60
|
+
f"{SKILL_DIR}/scripts/classify-agent-active.mjs",
|
|
61
|
+
f"{SKILL_DIR}/scripts/verify-escalation-config.mjs",
|
|
62
|
+
f"{SKILL_DIR}/scripts/extract-outbound-route.mjs",
|
|
63
|
+
f"{SKILL_DIR}/scripts/patch-escalation-surfaces.mjs",
|
|
64
|
+
f"{SKILL_DIR}/scripts/classify-nga-escalation.mjs",
|
|
65
|
+
):
|
|
66
|
+
proc = subprocess.run(["node", "--check", str(SKILLS_ROOT / rel)],
|
|
67
|
+
capture_output=True, text=True)
|
|
68
|
+
self.assertEqual(proc.returncode, 0, f"{rel}: {proc.stderr}")
|
|
69
|
+
|
|
70
|
+
def test_verify_only_retrieves_use_unzip(self):
|
|
71
|
+
# Regression (blocker 1): every `sf project retrieve start --target-metadata-dir` - the
|
|
72
|
+
# authoring AND the verify-only paths for GenAiPlugin / GenAiPlannerBundle / AiAuthoringBundle -
|
|
73
|
+
# must pass --unzip. Without it --target-metadata-dir yields a ZIP and the grep/find/classifier
|
|
74
|
+
# scans binary, so a correctly configured org is misreported as incomplete.
|
|
75
|
+
src = (SKILLS_ROOT / SCRIPT).read_text()
|
|
76
|
+
offending = [ln.strip() for ln in src.splitlines()
|
|
77
|
+
if "--target-metadata-dir" in ln and "--unzip" not in ln]
|
|
78
|
+
self.assertEqual(offending, [], f"retrieve without --unzip: {offending}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class InputContractTests(unittest.TestCase):
|
|
82
|
+
"""All must reject BEFORE the first sf call (PATH has no sf)."""
|
|
83
|
+
|
|
84
|
+
def test_missing_org_rejected(self):
|
|
85
|
+
rc, out = run([], scrub_sf=True)
|
|
86
|
+
self.assertEqual(rc, 1, out)
|
|
87
|
+
self.assertIn("Usage", out)
|
|
88
|
+
|
|
89
|
+
def test_injection_agent_name_rejected(self):
|
|
90
|
+
rc, out = run(["myorg", "bad;name"], scrub_sf=True)
|
|
91
|
+
self.assertEqual(rc, 1, out)
|
|
92
|
+
self.assertIn("agent_developer_name", out)
|
|
93
|
+
|
|
94
|
+
def test_injection_queue_name_rejected(self):
|
|
95
|
+
rc, out = run(["myorg", "Support_Agent", "bad queue"], scrub_sf=True)
|
|
96
|
+
self.assertEqual(rc, 1, out)
|
|
97
|
+
self.assertIn("queue_developer_name", out)
|
|
98
|
+
|
|
99
|
+
def test_injection_topic_name_rejected(self):
|
|
100
|
+
rc, out = run(
|
|
101
|
+
["myorg", "Support_Agent", "Human_Support_Queue", "bad topic'"],
|
|
102
|
+
scrub_sf=True,
|
|
103
|
+
)
|
|
104
|
+
self.assertEqual(rc, 1, out)
|
|
105
|
+
self.assertIn("escalation_topic_api_name", out)
|
|
106
|
+
|
|
107
|
+
def test_injection_bundle_name_rejected(self):
|
|
108
|
+
rc, out = run(
|
|
109
|
+
["myorg", "Support_Agent", "Human_Support_Queue", "Escalate_Topic", "bad;bundle"],
|
|
110
|
+
scrub_sf=True,
|
|
111
|
+
)
|
|
112
|
+
self.assertEqual(rc, 1, out)
|
|
113
|
+
self.assertIn("planner_bundle_api_name", out)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ProductionGuardTests(unittest.TestCase):
|
|
117
|
+
def test_production_org_refused(self):
|
|
118
|
+
with FakeSf(_FAKE_SF_PROD) as fake:
|
|
119
|
+
rc, out = run(["prodorg"], path_prefix=fake)
|
|
120
|
+
self.assertEqual(rc, 1, out)
|
|
121
|
+
verdict = _last_json(out)
|
|
122
|
+
self.assertIsNotNone(verdict, out)
|
|
123
|
+
self.assertEqual(verdict["status"], "BLOCKED")
|
|
124
|
+
self.assertNotIn("FAKE_SF_DEPLOY_SHOULD_NOT_BE_REACHED", out)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class PreconditionTests(unittest.TestCase):
|
|
128
|
+
def test_inactive_agent_blocks(self):
|
|
129
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
130
|
+
rc, out = run(["sbx"], path_prefix=fake, extra_env={"FAKE_AGENT_ACTIVE": "0"})
|
|
131
|
+
self.assertEqual(rc, 1, out)
|
|
132
|
+
verdict = _last_json(out)
|
|
133
|
+
self.assertEqual(verdict["status"], "BLOCKED")
|
|
134
|
+
self.assertIn("precondition", verdict["blocking_issue"].lower())
|
|
135
|
+
|
|
136
|
+
def test_missing_messaging_channel_blocks(self):
|
|
137
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
138
|
+
rc, out = run(["sbx"], path_prefix=fake, extra_env={"FAKE_MC_PRESENT": "0"})
|
|
139
|
+
self.assertEqual(rc, 1, out)
|
|
140
|
+
verdict = _last_json(out)
|
|
141
|
+
self.assertEqual(verdict["status"], "BLOCKED")
|
|
142
|
+
self.assertIn("MessagingChannel", verdict["blocking_issue"])
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class VerdictTests(unittest.TestCase):
|
|
146
|
+
def test_incomplete_without_topic_and_bundle(self):
|
|
147
|
+
# Deterministic infra (queue/flow/agent) resolves, but canEscalate +
|
|
148
|
+
# outboundRouteConfigs are unverified without the topic/bundle names.
|
|
149
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
150
|
+
rc, out = run(["sbx"], path_prefix=fake)
|
|
151
|
+
self.assertEqual(rc, 0, out)
|
|
152
|
+
verdict = _last_json(out)
|
|
153
|
+
self.assertEqual(verdict["status"], "INCOMPLETE")
|
|
154
|
+
missing = verdict["config_verification"]["missing"]
|
|
155
|
+
self.assertIn("canEscalate", missing)
|
|
156
|
+
self.assertIn("outboundRouteConfigs", missing)
|
|
157
|
+
# The surfaces the script owns must already pass.
|
|
158
|
+
self.assertNotIn("outboundFlowActive", missing)
|
|
159
|
+
self.assertNotIn("humanQueue", missing)
|
|
160
|
+
self.assertNotIn("agentActive", missing)
|
|
161
|
+
|
|
162
|
+
def test_configured_with_topic_bundle_and_threshold(self):
|
|
163
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
164
|
+
rc, out = run(
|
|
165
|
+
["sbx", "Support_Agent", "Human_Support_Queue",
|
|
166
|
+
"Escalate_To_Human", "Support_Agent"],
|
|
167
|
+
path_prefix=fake,
|
|
168
|
+
extra_env={"THRESHOLD_AUTHORED": "1"},
|
|
169
|
+
)
|
|
170
|
+
self.assertEqual(rc, 0, out)
|
|
171
|
+
verdict = _last_json(out)
|
|
172
|
+
self.assertEqual(verdict["status"], "CONFIGURED")
|
|
173
|
+
self.assertTrue(verdict["config_verification"]["deterministicPass"])
|
|
174
|
+
self.assertTrue(verdict["config_verification"]["directivePass"])
|
|
175
|
+
|
|
176
|
+
def test_plan_only_stops_before_writes(self):
|
|
177
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
178
|
+
rc, out = run(["sbx"], path_prefix=fake, extra_env={"PLAN_ONLY": "1"})
|
|
179
|
+
self.assertEqual(rc, 0, out)
|
|
180
|
+
verdict = _last_json(out)
|
|
181
|
+
self.assertEqual(verdict["status"], "PLAN")
|
|
182
|
+
self.assertTrue(verdict["plan_mode"])
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class ClassifierTests(unittest.TestCase):
|
|
186
|
+
def _write(self, tmp, name, payload):
|
|
187
|
+
p = Path(tmp) / name
|
|
188
|
+
p.write_text(json.dumps(payload))
|
|
189
|
+
return str(p.relative_to(SKILLS_ROOT)) if False else p
|
|
190
|
+
|
|
191
|
+
def test_agent_active_classifier(self):
|
|
192
|
+
import tempfile
|
|
193
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
194
|
+
active = Path(tmp) / "bot.json"
|
|
195
|
+
active.write_text(json.dumps({"status": 0, "result": {"records": [
|
|
196
|
+
{"DeveloperName": "Support_Agent", "Id": "0Xx",
|
|
197
|
+
"BotVersions": {"records": [{"Id": "1", "Status": "Active"}]}}]}}))
|
|
198
|
+
rc, out = run_node(f"{SKILL_DIR}/scripts/classify-agent-active.mjs",
|
|
199
|
+
[str(active), "Support_Agent"])
|
|
200
|
+
self.assertEqual(rc, 0, out)
|
|
201
|
+
self.assertTrue(json.loads(out)["ready"])
|
|
202
|
+
|
|
203
|
+
inactive = Path(tmp) / "bot2.json"
|
|
204
|
+
inactive.write_text(json.dumps({"status": 0, "result": {"records": [
|
|
205
|
+
{"DeveloperName": "Support_Agent", "Id": "0Xx",
|
|
206
|
+
"BotVersions": {"records": [{"Id": "1", "Status": "Inactive"}]}}]}}))
|
|
207
|
+
rc, out = run_node(f"{SKILL_DIR}/scripts/classify-agent-active.mjs",
|
|
208
|
+
[str(inactive), "Support_Agent"])
|
|
209
|
+
self.assertEqual(rc, 0, out)
|
|
210
|
+
v = json.loads(out)
|
|
211
|
+
self.assertFalse(v["ready"])
|
|
212
|
+
self.assertEqual(v["reason"], "agent-inactive")
|
|
213
|
+
|
|
214
|
+
def test_config_verifier_pass_and_fail(self):
|
|
215
|
+
import tempfile
|
|
216
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
217
|
+
base_full = {
|
|
218
|
+
"canEscalate": True,
|
|
219
|
+
"outboundRouteName": "Human_Escalation_Outbound_Flow",
|
|
220
|
+
"outboundRouteType": "OmniChannelFlow",
|
|
221
|
+
"outboundRouteSameBlock": True,
|
|
222
|
+
"outboundRouteMessagingSurface": True,
|
|
223
|
+
"expectedFlowName": "Human_Escalation_Outbound_Flow",
|
|
224
|
+
"flowActiveVersionId": "301", "queueId": "00G",
|
|
225
|
+
"queueSobjectPresent": True, "queueHasActiveDirectUserMember": True,
|
|
226
|
+
"queueRoutingConfigPresent": True, "queueRoutingConfigBound": True,
|
|
227
|
+
"agentActive": True, "thresholdAuthored": True,
|
|
228
|
+
}
|
|
229
|
+
full = Path(tmp) / "ev.json"
|
|
230
|
+
full.write_text(json.dumps(base_full))
|
|
231
|
+
rc, out = run_node(f"{SKILL_DIR}/scripts/verify-escalation-config.mjs", [str(full)])
|
|
232
|
+
self.assertEqual(rc, 0, out)
|
|
233
|
+
self.assertEqual(json.loads(out)["verdict"], "CONFIGURED")
|
|
234
|
+
|
|
235
|
+
# Routing + surface complete but the failure-threshold directive is NOT authored: must
|
|
236
|
+
# report ROUTING_CONFIGURED_POLICY_PENDING (never CONFIGURED) so a missing escalation
|
|
237
|
+
# policy cannot hide behind a green verdict.
|
|
238
|
+
policy_pending = Path(tmp) / "ev_policy.json"
|
|
239
|
+
policy_pending.write_text(json.dumps({**base_full, "thresholdAuthored": False}))
|
|
240
|
+
rc, out = run_node(f"{SKILL_DIR}/scripts/verify-escalation-config.mjs", [str(policy_pending)])
|
|
241
|
+
self.assertEqual(rc, 0, out)
|
|
242
|
+
v = json.loads(out)
|
|
243
|
+
self.assertEqual(v["verdict"], "ROUTING_CONFIGURED_POLICY_PENDING")
|
|
244
|
+
self.assertTrue(v["deterministicPass"])
|
|
245
|
+
self.assertFalse(v["directivePass"])
|
|
246
|
+
self.assertIn("thresholdDirective", v["missing"])
|
|
247
|
+
|
|
248
|
+
# An unbound QRC (Group.QueueRoutingConfigId not set) must NOT be CONFIGURED.
|
|
249
|
+
unbound = Path(tmp) / "ev3.json"
|
|
250
|
+
unbound.write_text(json.dumps({**base_full, "queueRoutingConfigBound": False}))
|
|
251
|
+
rc, out = run_node(f"{SKILL_DIR}/scripts/verify-escalation-config.mjs", [str(unbound)])
|
|
252
|
+
self.assertEqual(rc, 0, out)
|
|
253
|
+
v = json.loads(out)
|
|
254
|
+
self.assertEqual(v["verdict"], "INCOMPLETE")
|
|
255
|
+
self.assertIn("queueRoutingConfigBound", v["missing"])
|
|
256
|
+
|
|
257
|
+
# A queue with zero active human members must NOT be CONFIGURED.
|
|
258
|
+
nomembers = Path(tmp) / "ev4.json"
|
|
259
|
+
nomembers.write_text(json.dumps({**base_full, "queueHasActiveDirectUserMember": False}))
|
|
260
|
+
rc, out = run_node(f"{SKILL_DIR}/scripts/verify-escalation-config.mjs", [str(nomembers)])
|
|
261
|
+
self.assertEqual(rc, 0, out)
|
|
262
|
+
v = json.loads(out)
|
|
263
|
+
self.assertEqual(v["verdict"], "INCOMPLETE")
|
|
264
|
+
self.assertIn("queueHasActiveDirectUserMember", v["missing"])
|
|
265
|
+
|
|
266
|
+
# Route name matches but type is not OmniChannelFlow → outboundRouteConfigs fails.
|
|
267
|
+
badtype = Path(tmp) / "ev5.json"
|
|
268
|
+
badtype.write_text(json.dumps({**base_full, "outboundRouteType": "Copilot"}))
|
|
269
|
+
rc, out = run_node(f"{SKILL_DIR}/scripts/verify-escalation-config.mjs", [str(badtype)])
|
|
270
|
+
self.assertEqual(rc, 0, out)
|
|
271
|
+
v = json.loads(out)
|
|
272
|
+
self.assertEqual(v["verdict"], "INCOMPLETE")
|
|
273
|
+
self.assertIn("outboundRouteConfigs", v["missing"])
|
|
274
|
+
|
|
275
|
+
# Route name + type present but not on a Messaging surface → outboundRouteConfigs fails.
|
|
276
|
+
badsurface = Path(tmp) / "ev6.json"
|
|
277
|
+
badsurface.write_text(json.dumps({**base_full, "outboundRouteMessagingSurface": False}))
|
|
278
|
+
rc, out = run_node(f"{SKILL_DIR}/scripts/verify-escalation-config.mjs", [str(badsurface)])
|
|
279
|
+
self.assertEqual(rc, 0, out)
|
|
280
|
+
v = json.loads(out)
|
|
281
|
+
self.assertEqual(v["verdict"], "INCOMPLETE")
|
|
282
|
+
self.assertIn("outboundRouteConfigs", v["missing"])
|
|
283
|
+
|
|
284
|
+
partial = Path(tmp) / "ev2.json"
|
|
285
|
+
partial.write_text(json.dumps({
|
|
286
|
+
"canEscalate": False, "flowActiveVersionId": "301", "queueId": "00G",
|
|
287
|
+
"queueSobjectPresent": True, "queueRoutingConfigPresent": True,
|
|
288
|
+
"agentActive": True, "thresholdAuthored": False}))
|
|
289
|
+
rc, out = run_node(f"{SKILL_DIR}/scripts/verify-escalation-config.mjs", [str(partial)])
|
|
290
|
+
self.assertEqual(rc, 0, out)
|
|
291
|
+
v = json.loads(out)
|
|
292
|
+
self.assertEqual(v["verdict"], "INCOMPLETE")
|
|
293
|
+
self.assertIn("canEscalate", v["missing"])
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class QueueContractTests(unittest.TestCase):
|
|
297
|
+
"""The skill must actually ENSURE the queue/QueueSobject/QRC + bind, not just query them."""
|
|
298
|
+
|
|
299
|
+
_FULL_ARGS = ["sbx", "Support_Agent", "Human_Support_Queue",
|
|
300
|
+
"Escalate_To_Human", "Support_Agent"]
|
|
301
|
+
|
|
302
|
+
def test_creates_qrc_when_absent_and_reaches_configured(self):
|
|
303
|
+
# QRC absent → script must POST-create it; Group already reports it bound (stateless fake).
|
|
304
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
305
|
+
rc, out = run(self._FULL_ARGS, path_prefix=fake,
|
|
306
|
+
extra_env={"THRESHOLD_AUTHORED": "1", "FAKE_QRC_PRESENT": "0"})
|
|
307
|
+
self.assertEqual(rc, 0, out)
|
|
308
|
+
verdict = _last_json(out)
|
|
309
|
+
self.assertEqual(verdict["status"], "CONFIGURED", out)
|
|
310
|
+
self.assertNotIn("queueRoutingConfig", verdict["config_verification"]["missing"])
|
|
311
|
+
self.assertNotIn("queueRoutingConfigBound", verdict["config_verification"]["missing"])
|
|
312
|
+
|
|
313
|
+
def test_binds_messaging_session_queue_sobject_when_absent(self):
|
|
314
|
+
# QueueSobject absent → script must POST-bind MessagingSession, then still reach CONFIGURED.
|
|
315
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
316
|
+
rc, out = run(self._FULL_ARGS, path_prefix=fake,
|
|
317
|
+
extra_env={"THRESHOLD_AUTHORED": "1", "FAKE_QS_PRESENT": "0"})
|
|
318
|
+
self.assertEqual(rc, 0, out)
|
|
319
|
+
verdict = _last_json(out)
|
|
320
|
+
self.assertEqual(verdict["status"], "CONFIGURED", out)
|
|
321
|
+
self.assertNotIn("queueSobject", verdict["config_verification"]["missing"])
|
|
322
|
+
|
|
323
|
+
def test_blocks_when_queue_binding_cannot_persist(self):
|
|
324
|
+
# Group.QueueRoutingConfigId never persists → the skill must BLOCK, not falsely proceed.
|
|
325
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
326
|
+
rc, out = run(self._FULL_ARGS, path_prefix=fake,
|
|
327
|
+
extra_env={"THRESHOLD_AUTHORED": "1", "FAKE_QUEUE_BOUND": "0"})
|
|
328
|
+
self.assertEqual(rc, 1, out)
|
|
329
|
+
verdict = _last_json(out)
|
|
330
|
+
self.assertEqual(verdict["status"], "BLOCKED", out)
|
|
331
|
+
self.assertIn("bind", verdict["blocking_issue"].lower())
|
|
332
|
+
|
|
333
|
+
def test_incomplete_when_queue_has_no_members(self):
|
|
334
|
+
# A queue with zero active human members must NOT reach CONFIGURED (empty-queue guard).
|
|
335
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
336
|
+
rc, out = run(self._FULL_ARGS, path_prefix=fake,
|
|
337
|
+
extra_env={"THRESHOLD_AUTHORED": "1", "FAKE_QUEUE_MEMBERS": "0"})
|
|
338
|
+
self.assertEqual(rc, 0, out)
|
|
339
|
+
verdict = _last_json(out)
|
|
340
|
+
self.assertEqual(verdict["status"], "INCOMPLETE", out)
|
|
341
|
+
self.assertIn("queueHasActiveDirectUserMember", verdict["config_verification"]["missing"])
|
|
342
|
+
self.assertEqual(verdict["queue"]["active_direct_user_member_count"], 0)
|
|
343
|
+
|
|
344
|
+
def test_assigns_members_from_explicit_usernames_reaches_configured(self):
|
|
345
|
+
# Zero members + an explicit allowlist → the skill POSTs GroupMember, then reaches CONFIGURED.
|
|
346
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
347
|
+
rc, out = run(self._FULL_ARGS, path_prefix=fake,
|
|
348
|
+
extra_env={"THRESHOLD_AUTHORED": "1", "FAKE_QUEUE_MEMBERS": "0",
|
|
349
|
+
"QUEUE_MEMBER_USERNAMES": "agent1@example.com"})
|
|
350
|
+
self.assertEqual(rc, 0, out)
|
|
351
|
+
verdict = _last_json(out)
|
|
352
|
+
self.assertEqual(verdict["status"], "CONFIGURED", out)
|
|
353
|
+
self.assertNotIn("queueHasActiveDirectUserMember", verdict["config_verification"]["missing"])
|
|
354
|
+
|
|
355
|
+
def test_blocks_on_injection_shaped_member_username(self):
|
|
356
|
+
# An injection-shaped username must be rejected in the input-contract section, BEFORE any
|
|
357
|
+
# sf call - so the org is never mutated. That path emits the plain {"error":...} shape and
|
|
358
|
+
# exits 1 (same contract as the other input-validation guards), not a late BLOCKED verdict.
|
|
359
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
360
|
+
rc, out = run(self._FULL_ARGS, path_prefix=fake,
|
|
361
|
+
extra_env={"THRESHOLD_AUTHORED": "1", "FAKE_QUEUE_MEMBERS": "0",
|
|
362
|
+
"QUEUE_MEMBER_USERNAMES": "bad'name"})
|
|
363
|
+
self.assertEqual(rc, 1, out)
|
|
364
|
+
self.assertIn("QUEUE_MEMBER_USERNAMES", out)
|
|
365
|
+
|
|
366
|
+
def test_incomplete_when_route_type_not_omnichannelflow(self):
|
|
367
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
368
|
+
rc, out = run(self._FULL_ARGS, path_prefix=fake,
|
|
369
|
+
extra_env={"THRESHOLD_AUTHORED": "1", "FAKE_ROUTE_TYPE": "Copilot"})
|
|
370
|
+
self.assertEqual(rc, 0, out)
|
|
371
|
+
verdict = _last_json(out)
|
|
372
|
+
self.assertEqual(verdict["status"], "INCOMPLETE", out)
|
|
373
|
+
self.assertIn("outboundRouteConfigs", verdict["config_verification"]["missing"])
|
|
374
|
+
|
|
375
|
+
def test_incomplete_when_route_not_on_messaging_surface(self):
|
|
376
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
377
|
+
rc, out = run(self._FULL_ARGS, path_prefix=fake,
|
|
378
|
+
extra_env={"THRESHOLD_AUTHORED": "1", "FAKE_ROUTE_SURFACE": "InternalCopilot"})
|
|
379
|
+
self.assertEqual(rc, 0, out)
|
|
380
|
+
verdict = _last_json(out)
|
|
381
|
+
self.assertEqual(verdict["status"], "INCOMPLETE", out)
|
|
382
|
+
self.assertIn("outboundRouteConfigs", verdict["config_verification"]["missing"])
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class QueueMetadataShapeTests(unittest.TestCase):
|
|
386
|
+
"""The rendered Queue metadata must follow the canonical Metadata API element sequence."""
|
|
387
|
+
|
|
388
|
+
def test_queue_xml_uses_canonical_element_order(self):
|
|
389
|
+
src = (SKILLS_ROOT / SCRIPT).read_text()
|
|
390
|
+
# doesSendEmailToMembers MUST precede name in the Queue heredoc (Metadata API sequence).
|
|
391
|
+
i_dse = src.find("<doesSendEmailToMembers>")
|
|
392
|
+
i_name = src.find("<name>${QUEUE_DN}</name>")
|
|
393
|
+
self.assertGreater(i_dse, -1, "Queue heredoc missing <doesSendEmailToMembers>")
|
|
394
|
+
self.assertGreater(i_name, -1, "Queue heredoc missing <name>${QUEUE_DN}</name>")
|
|
395
|
+
self.assertLess(i_dse, i_name,
|
|
396
|
+
"Queue XML violates canonical order: <doesSendEmailToMembers> must precede <name>")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class IdempotencyTests(unittest.TestCase):
|
|
400
|
+
"""A re-run against an org where every surface already exists must ADOPT, never re-create:
|
|
401
|
+
no duplicate QueueRoutingConfig, QueueSobject, or membership is written. Proven by asserting
|
|
402
|
+
the fake `sf` call log contains none of the create POSTs while the verdict still reaches
|
|
403
|
+
CONFIGURED."""
|
|
404
|
+
|
|
405
|
+
_FULL_ARGS = ["sbx", "Support_Agent", "Human_Support_Queue",
|
|
406
|
+
"Escalate_To_Human", "Support_Agent"]
|
|
407
|
+
|
|
408
|
+
def test_rerun_all_present_creates_nothing(self):
|
|
409
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
410
|
+
calllog = Path(tmp) / "calls.log"
|
|
411
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
412
|
+
rc, out = run(self._FULL_ARGS, path_prefix=fake,
|
|
413
|
+
extra_env={"THRESHOLD_AUTHORED": "1",
|
|
414
|
+
"FAKE_SF_CALLLOG": str(calllog)})
|
|
415
|
+
self.assertEqual(rc, 0, out)
|
|
416
|
+
verdict = _last_json(out)
|
|
417
|
+
self.assertEqual(verdict["status"], "CONFIGURED", out)
|
|
418
|
+
log = calllog.read_text() if calllog.exists() else ""
|
|
419
|
+
self.assertNotIn("sobjects/QueueRoutingConfig", log,
|
|
420
|
+
"QRC already present - a re-run must not POST a new one")
|
|
421
|
+
self.assertNotIn("sobjects/QueueSobject", log,
|
|
422
|
+
"QueueSobject already present - a re-run must not POST a new bind")
|
|
423
|
+
self.assertNotIn("sobjects/GroupMember", log,
|
|
424
|
+
"members already present - a re-run must not POST a new member")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class ContextPreservationTests(unittest.TestCase):
|
|
428
|
+
"""Context preservation (the human inherits the SAME MessagingSession + transcript) is a
|
|
429
|
+
runtime fact, but its deterministic preconditions are verifiable: the routed context object
|
|
430
|
+
must be MessagingSession (QueueSobject bound) AND the hand-off must stay inside Omni via an
|
|
431
|
+
OmniChannelFlow outbound route on the Messaging surface. Missing either => not CONFIGURED."""
|
|
432
|
+
|
|
433
|
+
_BASE = {
|
|
434
|
+
"canEscalate": True,
|
|
435
|
+
"outboundRouteName": "Human_Escalation_Outbound_Flow",
|
|
436
|
+
"outboundRouteType": "OmniChannelFlow",
|
|
437
|
+
"outboundRouteSameBlock": True,
|
|
438
|
+
"outboundRouteMessagingSurface": True,
|
|
439
|
+
"expectedFlowName": "Human_Escalation_Outbound_Flow",
|
|
440
|
+
"flowActiveVersionId": "301", "queueId": "00G",
|
|
441
|
+
"queueSobjectPresent": True, "queueHasActiveDirectUserMember": True,
|
|
442
|
+
"queueRoutingConfigPresent": True, "queueRoutingConfigBound": True,
|
|
443
|
+
"agentActive": True, "thresholdAuthored": True,
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
def _verdict(self, payload):
|
|
447
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
448
|
+
p = Path(tmp) / "ev.json"
|
|
449
|
+
p.write_text(json.dumps(payload))
|
|
450
|
+
rc, out = run_node(f"{SKILL_DIR}/scripts/verify-escalation-config.mjs", [str(p)])
|
|
451
|
+
self.assertEqual(rc, 0, out)
|
|
452
|
+
return json.loads(out)
|
|
453
|
+
|
|
454
|
+
def test_configured_preserves_context(self):
|
|
455
|
+
self.assertEqual(self._verdict(self._BASE)["verdict"], "CONFIGURED")
|
|
456
|
+
|
|
457
|
+
def test_missing_messaging_session_sobject_breaks_preservation(self):
|
|
458
|
+
v = self._verdict({**self._BASE, "queueSobjectPresent": False})
|
|
459
|
+
self.assertEqual(v["verdict"], "INCOMPLETE")
|
|
460
|
+
self.assertIn("queueSobject", v["missing"])
|
|
461
|
+
|
|
462
|
+
def test_non_omnichannel_route_breaks_preservation(self):
|
|
463
|
+
v = self._verdict({**self._BASE, "outboundRouteType": "Copilot"})
|
|
464
|
+
self.assertEqual(v["verdict"], "INCOMPLETE")
|
|
465
|
+
self.assertIn("outboundRouteConfigs", v["missing"])
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class ThresholdInputTests(unittest.TestCase):
|
|
469
|
+
"""The failure threshold must be a REAL numeric input: validated, rendered into the directive,
|
|
470
|
+
and echoed in the structured verdict (not just a boolean 'authored' flag)."""
|
|
471
|
+
|
|
472
|
+
_FULL_ARGS = ["sbx", "Support_Agent", "Human_Support_Queue",
|
|
473
|
+
"Escalate_To_Human", "Support_Agent"]
|
|
474
|
+
|
|
475
|
+
def test_default_threshold_is_two(self):
|
|
476
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
477
|
+
rc, out = run(self._FULL_ARGS, path_prefix=fake,
|
|
478
|
+
extra_env={"THRESHOLD_AUTHORED": "1"})
|
|
479
|
+
self.assertEqual(rc, 0, out)
|
|
480
|
+
verdict = _last_json(out)
|
|
481
|
+
self.assertEqual(verdict["threshold"]["default_failures"], 2, out)
|
|
482
|
+
self.assertTrue(verdict["threshold"]["directive_rendered"], out)
|
|
483
|
+
|
|
484
|
+
def test_three_attempt_value_is_rendered_and_reported(self):
|
|
485
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
486
|
+
rc, out = run(self._FULL_ARGS, path_prefix=fake,
|
|
487
|
+
extra_env={"THRESHOLD_AUTHORED": "1",
|
|
488
|
+
"DEFAULT_FAILURE_THRESHOLD": "3"})
|
|
489
|
+
self.assertEqual(rc, 0, out)
|
|
490
|
+
verdict = _last_json(out)
|
|
491
|
+
self.assertEqual(verdict["threshold"]["default_failures"], 3, out)
|
|
492
|
+
self.assertEqual(verdict["threshold"]["authored"], True, out)
|
|
493
|
+
self.assertEqual(verdict["config_verification"]["defaultFailureThreshold"], 3, out)
|
|
494
|
+
|
|
495
|
+
def test_non_numeric_threshold_rejected_before_any_sf_call(self):
|
|
496
|
+
rc, out = run(["myorg"], scrub_sf=True,
|
|
497
|
+
extra_env={"DEFAULT_FAILURE_THRESHOLD": "three"})
|
|
498
|
+
self.assertEqual(rc, 1, out)
|
|
499
|
+
self.assertIn("DEFAULT_FAILURE_THRESHOLD", out)
|
|
500
|
+
|
|
501
|
+
def test_zero_threshold_rejected(self):
|
|
502
|
+
rc, out = run(["myorg"], scrub_sf=True,
|
|
503
|
+
extra_env={"DEFAULT_FAILURE_THRESHOLD": "0"})
|
|
504
|
+
self.assertEqual(rc, 1, out)
|
|
505
|
+
self.assertIn("DEFAULT_FAILURE_THRESHOLD", out)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
class GenAiPreflightTests(unittest.TestCase):
|
|
509
|
+
"""Escalation needs the Agentforce authoring metadata (GenAiPlugin/GenAiPlannerBundle). If the
|
|
510
|
+
org exposes neither, the skill must BLOCK before any write instead of building routing infra
|
|
511
|
+
that can only report INCOMPLETE."""
|
|
512
|
+
|
|
513
|
+
_FULL_ARGS = ["sbx", "Support_Agent", "Human_Support_Queue",
|
|
514
|
+
"Escalate_To_Human", "Support_Agent"]
|
|
515
|
+
|
|
516
|
+
def test_blocks_before_any_write_when_genai_metadata_absent(self):
|
|
517
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
518
|
+
calllog = Path(tmp) / "calls.log"
|
|
519
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
520
|
+
rc, out = run(self._FULL_ARGS, path_prefix=fake,
|
|
521
|
+
extra_env={"FAKE_GENAI_MD": "0",
|
|
522
|
+
"FAKE_SF_CALLLOG": str(calllog)})
|
|
523
|
+
self.assertEqual(rc, 1, out)
|
|
524
|
+
verdict = _last_json(out)
|
|
525
|
+
self.assertEqual(verdict["status"], "BLOCKED", out)
|
|
526
|
+
self.assertIn("GenAiPlannerBundle", verdict["blocking_issue"])
|
|
527
|
+
log = calllog.read_text() if calllog.exists() else ""
|
|
528
|
+
self.assertNotIn("project deploy", log, "must block before any deploy")
|
|
529
|
+
self.assertNotIn("sobjects/", log, "must block before any DML write")
|
|
530
|
+
|
|
531
|
+
def test_proceeds_when_genai_metadata_present(self):
|
|
532
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
533
|
+
rc, out = run(self._FULL_ARGS, path_prefix=fake,
|
|
534
|
+
extra_env={"THRESHOLD_AUTHORED": "1", "FAKE_GENAI_MD": "1"})
|
|
535
|
+
self.assertEqual(rc, 0, out)
|
|
536
|
+
self.assertEqual(_last_json(out)["status"], "CONFIGURED", out)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
class NgaModelTests(unittest.TestCase):
|
|
540
|
+
"""On a next-gen (AiAuthoringBundle) org the skill must NOT block for a missing GenAiPlannerBundle.
|
|
541
|
+
It builds the model-agnostic routing infra, then verifies the NGA escalation surface (a reachable
|
|
542
|
+
@utils.escalate for a Service agent / a create-record action for an Employee agent) - reaching
|
|
543
|
+
CONFIGURED when present, INCOMPLETE (surface delegated to agentforce-generate) when not."""
|
|
544
|
+
|
|
545
|
+
_ARGS = ["sbx", "Support_Agent", "Human_Support_Queue"]
|
|
546
|
+
|
|
547
|
+
def test_target_specific_nga_wins_when_both_metadata_models_exist(self):
|
|
548
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
549
|
+
rc, out = run(self._ARGS, path_prefix=fake,
|
|
550
|
+
extra_env={"FAKE_MODEL": "both", "FAKE_TARGET_MODEL": "nga",
|
|
551
|
+
"THRESHOLD_AUTHORED": "1"})
|
|
552
|
+
self.assertEqual(rc, 0, out)
|
|
553
|
+
self.assertEqual(_last_json(out)["authoring_model"], "nga", out)
|
|
554
|
+
|
|
555
|
+
def test_nga_org_not_blocked(self):
|
|
556
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
557
|
+
calllog = Path(tmp) / "calls.log"
|
|
558
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
559
|
+
rc, out = run(self._ARGS, path_prefix=fake,
|
|
560
|
+
extra_env={"FAKE_MODEL": "nga", "THRESHOLD_AUTHORED": "1",
|
|
561
|
+
"FAKE_SF_CALLLOG": str(calllog)})
|
|
562
|
+
self.assertEqual(rc, 0, out)
|
|
563
|
+
verdict = _last_json(out)
|
|
564
|
+
self.assertNotEqual(verdict["status"], "BLOCKED", out)
|
|
565
|
+
self.assertEqual(verdict["authoring_model"], "nga", out)
|
|
566
|
+
|
|
567
|
+
def test_nga_configured_when_escalate_surface_present(self):
|
|
568
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
569
|
+
rc, out = run(self._ARGS, path_prefix=fake,
|
|
570
|
+
extra_env={"FAKE_MODEL": "nga", "FAKE_NGA_SURFACE": "1",
|
|
571
|
+
"THRESHOLD_AUTHORED": "1"})
|
|
572
|
+
self.assertEqual(rc, 0, out)
|
|
573
|
+
verdict = _last_json(out)
|
|
574
|
+
self.assertEqual(verdict["status"], "CONFIGURED", out)
|
|
575
|
+
self.assertEqual(verdict["authoring_model"], "nga", out)
|
|
576
|
+
self.assertEqual(verdict["agent"]["type"], "service", out)
|
|
577
|
+
self.assertTrue(verdict["agent"]["escalation_surface_present"], out)
|
|
578
|
+
# Classic-only checks must not appear on the NGA path.
|
|
579
|
+
self.assertNotIn("canEscalate", verdict["config_verification"]["missing"])
|
|
580
|
+
self.assertNotIn("outboundRouteConfigs", verdict["config_verification"]["missing"])
|
|
581
|
+
|
|
582
|
+
def test_nga_incomplete_when_surface_missing(self):
|
|
583
|
+
with FakeSf(_FAKE_SF_SANDBOX) as fake:
|
|
584
|
+
rc, out = run(self._ARGS, path_prefix=fake,
|
|
585
|
+
extra_env={"FAKE_MODEL": "nga", "FAKE_NGA_SURFACE": "0",
|
|
586
|
+
"THRESHOLD_AUTHORED": "1"})
|
|
587
|
+
self.assertEqual(rc, 0, out)
|
|
588
|
+
verdict = _last_json(out)
|
|
589
|
+
self.assertEqual(verdict["status"], "INCOMPLETE", out)
|
|
590
|
+
self.assertIn("ngaEscalationSurface", verdict["config_verification"]["missing"])
|
|
591
|
+
# The routing infra half must still pass - only the agent surface is missing.
|
|
592
|
+
self.assertNotIn("humanQueue", verdict["config_verification"]["missing"])
|
|
593
|
+
self.assertNotIn("queueRoutingConfigBound", verdict["config_verification"]["missing"])
|
|
594
|
+
|
|
595
|
+
def test_nga_classifier_service_vs_employee(self):
|
|
596
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
597
|
+
svc = Path(tmp) / "svc"
|
|
598
|
+
svc.mkdir()
|
|
599
|
+
(svc / "A.agent").write_text(
|
|
600
|
+
"start_agent x:\n connection messaging:\n reasoning:\n"
|
|
601
|
+
" actions:\n h: @utils.escalate\n")
|
|
602
|
+
rc, out = run_node(f"{SKILL_DIR}/scripts/classify-nga-escalation.mjs", [str(svc)])
|
|
603
|
+
self.assertEqual(rc, 0, out)
|
|
604
|
+
v = json.loads(out)
|
|
605
|
+
self.assertEqual(v["agentType"], "service")
|
|
606
|
+
self.assertTrue(v["escalationSurfacePresent"])
|
|
607
|
+
|
|
608
|
+
emp = Path(tmp) / "emp"
|
|
609
|
+
emp.mkdir()
|
|
610
|
+
(emp / "B.agent").write_text(
|
|
611
|
+
"start_agent y:\n reasoning:\n actions:\n"
|
|
612
|
+
" t: @actions.createIncidentRecord\n")
|
|
613
|
+
rc, out = run_node(f"{SKILL_DIR}/scripts/classify-nga-escalation.mjs", [str(emp)])
|
|
614
|
+
self.assertEqual(rc, 0, out)
|
|
615
|
+
v = json.loads(out)
|
|
616
|
+
self.assertEqual(v["agentType"], "employee")
|
|
617
|
+
self.assertTrue(v["escalationSurfacePresent"])
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
class NgaClassifierHardeningTests(unittest.TestCase):
|
|
621
|
+
"""The classifier must not report CONFIGURED off comments, prose, cross-block matches, or unrelated
|
|
622
|
+
files, and must exit 3 (not 0) when the retrieve dir is unreadable/missing."""
|
|
623
|
+
|
|
624
|
+
CLASSIFIER = f"{SKILL_DIR}/scripts/classify-nga-escalation.mjs"
|
|
625
|
+
|
|
626
|
+
def _classify(self, files: dict):
|
|
627
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
628
|
+
root = Path(tmp)
|
|
629
|
+
for rel, content in files.items():
|
|
630
|
+
p = root / rel
|
|
631
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
632
|
+
p.write_text(content)
|
|
633
|
+
rc, out = run_node(self.CLASSIFIER, [str(root)])
|
|
634
|
+
self.assertEqual(rc, 0, out)
|
|
635
|
+
return json.loads(out)
|
|
636
|
+
|
|
637
|
+
def test_commented_out_escalate_does_not_count(self):
|
|
638
|
+
# A commented escalation block (the classic false-positive) must NOT satisfy the surface.
|
|
639
|
+
v = self._classify({
|
|
640
|
+
"A.agent": (
|
|
641
|
+
"start_agent x:\n"
|
|
642
|
+
" connection messaging:\n"
|
|
643
|
+
" reasoning:\n"
|
|
644
|
+
" actions:\n"
|
|
645
|
+
" // human_handoff: @utils.escalate\n"
|
|
646
|
+
" # human_handoff: @utils.escalate\n"
|
|
647
|
+
" answer: @actions.AnswerQuestionWithKnowledge\n"
|
|
648
|
+
),
|
|
649
|
+
})
|
|
650
|
+
self.assertEqual(v["agentType"], "service")
|
|
651
|
+
self.assertFalse(v["escalationSurfacePresent"], v)
|
|
652
|
+
|
|
653
|
+
def test_escalate_outside_actions_section_does_not_count(self):
|
|
654
|
+
# `@utils.escalate` in prose/description (not under a reasoning: actions: header) is unreachable.
|
|
655
|
+
v = self._classify({
|
|
656
|
+
"A.agent": (
|
|
657
|
+
"start_agent x:\n"
|
|
658
|
+
" connection messaging:\n"
|
|
659
|
+
' description: "This agent could @utils.escalate someday."\n'
|
|
660
|
+
" reasoning:\n"
|
|
661
|
+
" actions:\n"
|
|
662
|
+
" answer: @actions.AnswerQuestionWithKnowledge\n"
|
|
663
|
+
),
|
|
664
|
+
})
|
|
665
|
+
self.assertFalse(v["escalationSurfacePresent"], v)
|
|
666
|
+
|
|
667
|
+
def test_cross_block_messaging_and_escalate_do_not_combine(self):
|
|
668
|
+
# messaging in one agent block + escalate in a DIFFERENT block must not prove a service surface.
|
|
669
|
+
v = self._classify({
|
|
670
|
+
"A.agent": (
|
|
671
|
+
"start_agent has_messaging:\n"
|
|
672
|
+
" connection messaging:\n"
|
|
673
|
+
" reasoning:\n"
|
|
674
|
+
" actions:\n"
|
|
675
|
+
" answer: @actions.AnswerQuestionWithKnowledge\n"
|
|
676
|
+
"start_agent has_escalate:\n"
|
|
677
|
+
" reasoning:\n"
|
|
678
|
+
" actions:\n"
|
|
679
|
+
" h: @utils.escalate\n"
|
|
680
|
+
),
|
|
681
|
+
})
|
|
682
|
+
# First block (messaging) sets agentType=service, but no single block has BOTH → not present.
|
|
683
|
+
self.assertEqual(v["agentType"], "service")
|
|
684
|
+
self.assertFalse(v["escalationSurfacePresent"], v)
|
|
685
|
+
|
|
686
|
+
def test_tokens_in_unrelated_non_script_file_ignored(self):
|
|
687
|
+
# A README/xml that merely mentions the tokens is not Agent Script → no bundle, no surface.
|
|
688
|
+
v = self._classify({
|
|
689
|
+
"notes.md": "connection messaging: and @utils.escalate are how NGA agents escalate.\n",
|
|
690
|
+
})
|
|
691
|
+
self.assertFalse(v["bundleFound"], v)
|
|
692
|
+
self.assertEqual(v["agentType"], "unknown")
|
|
693
|
+
self.assertFalse(v["escalationSurfacePresent"], v)
|
|
694
|
+
|
|
695
|
+
def test_unreadable_or_missing_dir_exits_3(self):
|
|
696
|
+
missing = str(Path(tempfile.gettempdir()) / "nga-does-not-exist-xyz123")
|
|
697
|
+
rc, _ = run_node(self.CLASSIFIER, [missing])
|
|
698
|
+
self.assertEqual(rc, 3)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
class CoordinatorGateTests(unittest.TestCase):
|
|
702
|
+
def test_escalation_verdict_mapping(self):
|
|
703
|
+
rc, out = run_node(VERDICT_SCRIPT, ["escalation", "CONFIGURED"])
|
|
704
|
+
self.assertEqual(rc, 0, out)
|
|
705
|
+
rc, _ = run_node(VERDICT_SCRIPT, ["escalation", "ALREADY-CONFIGURED"])
|
|
706
|
+
self.assertEqual(rc, 0)
|
|
707
|
+
rc, _ = run_node(VERDICT_SCRIPT, ["escalation", "INCOMPLETE"])
|
|
708
|
+
self.assertEqual(rc, 1)
|
|
709
|
+
rc, _ = run_node(VERDICT_SCRIPT, ["escalation", "BLOCKED"])
|
|
710
|
+
self.assertEqual(rc, 1)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
class PackagedAssetTests(unittest.TestCase):
|
|
714
|
+
def test_bundled_xml_is_wellformed(self):
|
|
715
|
+
base = SKILLS_ROOT / SKILL_DIR / "assets"
|
|
716
|
+
for rel in ("package.xml",
|
|
717
|
+
"force-app/main/default/flows/Human_Escalation_Outbound_Flow.flow-meta.xml"):
|
|
718
|
+
xml.dom.minidom.parse(str(base / rel))
|
|
719
|
+
|
|
720
|
+
def test_bundled_json_is_wellformed(self):
|
|
721
|
+
base = SKILLS_ROOT / SKILL_DIR / "assets"
|
|
722
|
+
json.loads((base / "sfdx-project.json").read_text())
|
|
723
|
+
|
|
724
|
+
def test_outbound_flow_is_queuebased(self):
|
|
725
|
+
flow = (SKILLS_ROOT / SKILL_DIR / "assets" / "force-app" / "main" / "default" /
|
|
726
|
+
"flows" / "Human_Escalation_Outbound_Flow.flow-meta.xml").read_text()
|
|
727
|
+
self.assertIn("<stringValue>QueueBased</stringValue>", flow)
|
|
728
|
+
self.assertIn("__QUEUE_DEVELOPER_NAME__", flow)
|
|
729
|
+
self.assertIn("<processType>RoutingFlow</processType>", flow)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
PATCHER = f"{SKILL_DIR}/scripts/patch-escalation-surfaces.mjs"
|
|
733
|
+
|
|
734
|
+
_PLUGIN_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
|
735
|
+
<GenAiPlugin xmlns="http://soap.sforce.com/2006/04/metadata">
|
|
736
|
+
<aiPluginUtterances>
|
|
737
|
+
<developerName>U1</developerName>
|
|
738
|
+
<language>en_US</language>
|
|
739
|
+
<masterLabel>U1</masterLabel>
|
|
740
|
+
<utterance>help</utterance>
|
|
741
|
+
</aiPluginUtterances>
|
|
742
|
+
<description>Escalation topic</description>
|
|
743
|
+
<developerName>Escalate_To_Human</developerName>
|
|
744
|
+
<language>en_US</language>
|
|
745
|
+
<masterLabel>Escalate To Human</masterLabel>
|
|
746
|
+
<pluginType>Topic</pluginType>
|
|
747
|
+
</GenAiPlugin>
|
|
748
|
+
"""
|
|
749
|
+
|
|
750
|
+
_BUNDLE_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
|
751
|
+
<GenAiPlannerBundle xmlns="http://soap.sforce.com/2006/04/metadata">
|
|
752
|
+
<masterLabel>Support Agent</masterLabel>
|
|
753
|
+
<plannerSurfaces>
|
|
754
|
+
<adaptiveResponseAllowed>false</adaptiveResponseAllowed>
|
|
755
|
+
<callRecordingAllowed>false</callRecordingAllowed>
|
|
756
|
+
<surface>SurfaceAction__Messaging</surface>
|
|
757
|
+
<surfaceType>Messaging</surfaceType>
|
|
758
|
+
</plannerSurfaces>
|
|
759
|
+
<plannerType>AiCopilot__ReAct</plannerType>
|
|
760
|
+
</GenAiPlannerBundle>
|
|
761
|
+
"""
|
|
762
|
+
|
|
763
|
+
_BUNDLE_NO_MESSAGING = _BUNDLE_XML.replace("Messaging", "Telephony")
|
|
764
|
+
|
|
765
|
+
_FLOW = "Human_Escalation_Outbound_Flow"
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _patch(mode, path, *extra):
|
|
769
|
+
proc = subprocess.run(
|
|
770
|
+
["node", str(SKILLS_ROOT / PATCHER), mode, str(path), *extra],
|
|
771
|
+
capture_output=True, text=True)
|
|
772
|
+
return proc.returncode, proc.stdout, proc.stderr
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
class PatcherXsdOrderTests(unittest.TestCase):
|
|
776
|
+
"""The schema-aware patcher must honor the GenAiPlannerBundle/GenAiPlugin XSD element order."""
|
|
777
|
+
|
|
778
|
+
def _tmp(self, tmp, name, content):
|
|
779
|
+
p = Path(tmp) / name
|
|
780
|
+
p.write_text(content)
|
|
781
|
+
return p
|
|
782
|
+
|
|
783
|
+
def test_can_escalate_inserted_after_utterances_before_description(self):
|
|
784
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
785
|
+
p = self._tmp(tmp, "t.genAiPlugin", _PLUGIN_XML)
|
|
786
|
+
rc, out, err = _patch("canEscalate", p)
|
|
787
|
+
self.assertEqual(rc, 0, err)
|
|
788
|
+
text = p.read_text()
|
|
789
|
+
xml.dom.minidom.parseString(text) # well-formed
|
|
790
|
+
i_utt = text.rindex("</aiPluginUtterances>")
|
|
791
|
+
i_esc = text.index("<canEscalate>")
|
|
792
|
+
i_desc = text.index("<description>")
|
|
793
|
+
self.assertLess(i_utt, i_esc, "canEscalate must follow aiPluginUtterances")
|
|
794
|
+
self.assertLess(i_esc, i_desc, "canEscalate must precede description")
|
|
795
|
+
self.assertIn("<canEscalate>true</canEscalate>", text)
|
|
796
|
+
|
|
797
|
+
def test_can_escalate_idempotent(self):
|
|
798
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
799
|
+
p = self._tmp(tmp, "t.genAiPlugin", _PLUGIN_XML)
|
|
800
|
+
_patch("canEscalate", p)
|
|
801
|
+
rc, out, err = _patch("canEscalate", p)
|
|
802
|
+
self.assertEqual(rc, 0, err)
|
|
803
|
+
self.assertEqual(json.loads(out)["changed"], False)
|
|
804
|
+
self.assertEqual(p.read_text().count("<canEscalate>"), 1)
|
|
805
|
+
|
|
806
|
+
def test_can_escalate_refuses_without_anchor(self):
|
|
807
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
808
|
+
p = self._tmp(tmp, "t.genAiPlugin",
|
|
809
|
+
'<?xml version="1.0"?>\n<GenAiPlugin xmlns="x"></GenAiPlugin>\n')
|
|
810
|
+
rc, out, err = _patch("canEscalate", p)
|
|
811
|
+
self.assertEqual(rc, 4, out + err)
|
|
812
|
+
|
|
813
|
+
def test_outbound_route_inserted_before_surface(self):
|
|
814
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
815
|
+
p = self._tmp(tmp, "b.genAiPlannerBundle", _BUNDLE_XML)
|
|
816
|
+
rc, out, err = _patch("outboundRoute", p, _FLOW)
|
|
817
|
+
self.assertEqual(rc, 0, err)
|
|
818
|
+
text = p.read_text()
|
|
819
|
+
xml.dom.minidom.parseString(text)
|
|
820
|
+
i_cfg = text.index("<outboundRouteConfigs>")
|
|
821
|
+
i_surface = text.index("<surface>")
|
|
822
|
+
self.assertLess(i_cfg, i_surface, "outboundRouteConfigs must precede <surface> per XSD")
|
|
823
|
+
# Internal order: escalationMessage, outboundRouteName, outboundRouteType.
|
|
824
|
+
i_msg = text.index("<escalationMessage>")
|
|
825
|
+
i_name = text.index("<outboundRouteName>")
|
|
826
|
+
i_type = text.index("<outboundRouteType>")
|
|
827
|
+
self.assertLess(i_msg, i_name)
|
|
828
|
+
self.assertLess(i_name, i_type)
|
|
829
|
+
self.assertIn("<outboundRouteType>OmniChannelFlow</outboundRouteType>", text)
|
|
830
|
+
|
|
831
|
+
def test_outbound_route_idempotent(self):
|
|
832
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
833
|
+
p = self._tmp(tmp, "b.genAiPlannerBundle", _BUNDLE_XML)
|
|
834
|
+
_patch("outboundRoute", p, _FLOW)
|
|
835
|
+
rc, out, err = _patch("outboundRoute", p, _FLOW)
|
|
836
|
+
self.assertEqual(rc, 0, err)
|
|
837
|
+
self.assertEqual(json.loads(out)["changed"], False)
|
|
838
|
+
self.assertEqual(p.read_text().count("<outboundRouteConfigs>"), 1)
|
|
839
|
+
|
|
840
|
+
def test_outbound_route_refuses_without_messaging_surface(self):
|
|
841
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
842
|
+
p = self._tmp(tmp, "b.genAiPlannerBundle", _BUNDLE_NO_MESSAGING)
|
|
843
|
+
rc, out, err = _patch("outboundRoute", p, _FLOW)
|
|
844
|
+
self.assertEqual(rc, 4, out + err)
|
|
845
|
+
|
|
846
|
+
def test_outbound_route_requires_flow_name(self):
|
|
847
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
848
|
+
p = self._tmp(tmp, "b.genAiPlannerBundle", _BUNDLE_XML)
|
|
849
|
+
rc, out, err = _patch("outboundRoute", p)
|
|
850
|
+
self.assertEqual(rc, 2, out + err)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
class AuthorSurfacesContractTests(unittest.TestCase):
|
|
854
|
+
"""Guard the AUTHOR_SURFACES CLI format + agent lifecycle so a refactor cannot regress it."""
|
|
855
|
+
|
|
856
|
+
def setUp(self):
|
|
857
|
+
self.src = (SKILLS_ROOT / SCRIPT).read_text()
|
|
858
|
+
start = self.src.index("Step 6b")
|
|
859
|
+
end = self.src.index("Step 7 - Verify", start)
|
|
860
|
+
self.block = self.src[start:end]
|
|
861
|
+
|
|
862
|
+
def test_retrieve_uses_unzip_and_deploy_uses_metadata_dir(self):
|
|
863
|
+
self.assertIn("--unzip", self.block)
|
|
864
|
+
self.assertIn("--metadata-dir", self.block)
|
|
865
|
+
|
|
866
|
+
def test_never_deploys_metadata_format_as_source_dir(self):
|
|
867
|
+
code = "\n".join(ln for ln in self.block.splitlines() if not ln.strip().startswith("#"))
|
|
868
|
+
self.assertNotIn("--source-dir", code,
|
|
869
|
+
"metadata-format retrieval must deploy with --metadata-dir, not --source-dir")
|
|
870
|
+
|
|
871
|
+
def test_agent_lifecycle_order_publish_deactivate_deploy_activate(self):
|
|
872
|
+
i_publish = self.block.index("agent publish authoring-bundle")
|
|
873
|
+
i_deactivate = self.block.index("agent deactivate")
|
|
874
|
+
i_deploy = self.block.index("deploy start --target-org \"$ORG\" --metadata-dir")
|
|
875
|
+
i_activate = self.block.rindex("agent activate")
|
|
876
|
+
self.assertLess(i_publish, i_deactivate, "publish must precede deactivate")
|
|
877
|
+
self.assertLess(i_deactivate, i_deploy, "deactivate must precede deploy (deploy fails while active)")
|
|
878
|
+
self.assertLess(i_deploy, i_activate, "activate must follow deploy")
|
|
879
|
+
|
|
880
|
+
def test_final_activation_is_fail_closed(self):
|
|
881
|
+
# The terminal reactivation must be guarded by emit_blocked, never swallowed with `|| true`.
|
|
882
|
+
tail = self.block[self.block.rindex("# 5) Reactivate"):]
|
|
883
|
+
self.assertIn("emit_blocked", tail)
|
|
884
|
+
self.assertIn("agent activate", tail)
|
|
885
|
+
activate_line = [ln for ln in tail.splitlines() if "sf agent activate" in ln and "if !" in ln]
|
|
886
|
+
self.assertTrue(activate_line, "final activate must be checked with `if !`")
|
|
887
|
+
|
|
888
|
+
def test_reverifies_latest_botversion(self):
|
|
889
|
+
self.assertIn("bot-after.json", self.block)
|
|
890
|
+
self.assertIn("classify-agent-active.mjs", self.block)
|
|
891
|
+
self.assertIn("refusing to report CONFIGURED against a stale/inactive version", self.block)
|
|
892
|
+
|
|
893
|
+
def test_refuses_when_no_authoring_identifiers(self):
|
|
894
|
+
self.assertIn("AUTHOR_SURFACES=1 requires escalation_topic_api_name and/or planner_bundle_api_name",
|
|
895
|
+
self.block)
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
if __name__ == "__main__":
|
|
899
|
+
unittest.main(verbosity=2)
|