From 27408c6e597112baa3aa43f631beb37a083e6339 Mon Sep 17 00:00:00 2001 From: Daphne Shao Date: Thu, 24 Sep 2026 00:41:03 -0700 Subject: [PATCH 1/9] feat(connect): prototype DA ServiceNow binding Add a DA-GA HR ServiceNow connection workflow with maker-guided physical connection creation, read-only Connectivity discovery, MinimalBot reference binding, and confirmed publish. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f9e8cfc7-07a1-469b-80f6-99a9cbb7aee6 --- .../.github/prompts/connect.prompt.md | 6 - .../ess-maker-skills/scripts/agentbuilder.py | 30 +- .../scripts/connect_servicenow_da.py | 801 ++++++++++++++++++ .../src/skills/connect/SKILL.md | 18 +- .../src/skills/connect/servicenow-da/SKILL.md | 103 +++ tests/scripts/test_agentbuilder.py | 33 + tests/scripts/test_connect_servicenow_da.py | 268 ++++++ tests/setup/test_da_setup_router.py | 4 +- 8 files changed, 1252 insertions(+), 11 deletions(-) create mode 100644 solutions/ess-maker-skills/scripts/connect_servicenow_da.py create mode 100644 solutions/ess-maker-skills/src/skills/connect/servicenow-da/SKILL.md create mode 100644 tests/scripts/test_connect_servicenow_da.py diff --git a/solutions/ess-maker-skills/.github/prompts/connect.prompt.md b/solutions/ess-maker-skills/.github/prompts/connect.prompt.md index 0998b440d..33fed7c92 100644 --- a/solutions/ess-maker-skills/.github/prompts/connect.prompt.md +++ b/solutions/ess-maker-skills/.github/prompts/connect.prompt.md @@ -11,12 +11,6 @@ description: "Check DA-GA product extension setup availability" and STOP. Otherwise proceed. -Show: - -> DA-GA connector setup requires the corresponding product extension. Extension setup is not yet available in this release. - -and STOP. - You are a script executor. Read `src/skills/connect/SKILL.md` (a short router file) and follow it. It will tell you which step file to read next. Each step file contains pre-written messages between **Message:** and diff --git a/solutions/ess-maker-skills/scripts/agentbuilder.py b/solutions/ess-maker-skills/scripts/agentbuilder.py index f1db6d876..7515a0403 100644 --- a/solutions/ess-maker-skills/scripts/agentbuilder.py +++ b/solutions/ess-maker-skills/scripts/agentbuilder.py @@ -452,8 +452,14 @@ def _acquire_token( token = result.get("access_token") if result else None if not token: error = result.get("error", "unknown_error") if result else "unknown_error" + description = ( + str(result.get("error_description", "")).strip() + if result + else "" + ) + detail = f": {description}" if description else "" raise AgentBuilderError( - f"AgentBuilder authentication failed ({error})." + f"AgentBuilder authentication failed ({error}){detail}" ) if cache.has_state_changed: _persist_token_cache(cache, cache_path) @@ -467,6 +473,7 @@ def authenticate( cache_path: Path = DEFAULT_TOKEN_CACHE, force_account_selection: bool = False, account_hint: str | None = None, + scopes: tuple[str, ...] | None = None, ) -> str: """Acquire an ESS ADK delegated token without contacting Dataverse.""" try: @@ -480,6 +487,7 @@ def authenticate( cache_path=cache_path, force_account_selection=force_account_selection, account_hint=account_hint, + scopes=scopes, ) @@ -847,6 +855,26 @@ def fetch_components(self, agent_id: str) -> dict[str, Any]: raise AgentBuilderError("Component fetch returned an invalid shape.") return body + def update_components( + self, + agent_id: str, + change_set: dict[str, Any], + ) -> dict[str, Any]: + """Apply one caller-supplied MinimalBot component change set.""" + if not isinstance(change_set, dict): + raise ValueError("Component change set must be a JSON object.") + body = self._json( + "PUT", + f"/copilotstudio/minimalBots/api/{agent_id}/components", + "Component update", + params={"api-version": NATIVE_ALM_API_VERSION}, + body=change_set, + timeout=180, + ) + if not isinstance(body, dict): + raise AgentBuilderError("Component update returned an invalid shape.") + return body + def update_bot_entity( self, agent_id: str, diff --git a/solutions/ess-maker-skills/scripts/connect_servicenow_da.py b/solutions/ess-maker-skills/scripts/connect_servicenow_da.py new file mode 100644 index 000000000..28fae86bb --- /dev/null +++ b/solutions/ess-maker-skills/scripts/connect_servicenow_da.py @@ -0,0 +1,801 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Connect the DA-GA HR agent to ServiceNow HRSD without Dataverse.""" + +from __future__ import annotations + +import argparse +import copy +import datetime as dt +import hashlib +import json +import os +import tempfile +import uuid +from pathlib import Path +from typing import Any +from urllib.parse import quote + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from agentbuilder import ( + AgentBuilderClient, + AgentBuilderError, + RING_CONFIG, + authenticate, + authenticate_scopes, + validate_environment_host, +) + + +SETUP_STATE = Path(".local/setup/config.json") +ACTIVE_CONFIG = Path(".local/config.json") +CONNECTOR_ID = "/providers/Microsoft.PowerApps/apis/shared_service-now" +CONNECTOR_NAME = "shared_service-now" +CONNECTIVITY_API_VERSION = "1" +SETUP_SCHEMA_VERSION = 3 +HR_SCHEMA_NAME = "gptagent_copilotforemployeeselfservicehr" +TOKEN_CACHE = Path(".local/.agentbuilder_token_cache.bin") + + +class ServiceNowConnectError(RuntimeError): + """Raised when the DA-GA ServiceNow prototype cannot proceed safely.""" + + +def _load_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise ServiceNowConnectError(f"Required file is missing: {path}") from exc + except (OSError, json.JSONDecodeError) as exc: + raise ServiceNowConnectError(f"Could not read JSON file: {path}") from exc + if not isinstance(value, dict): + raise ServiceNowConnectError(f"Expected a JSON object in {path}.") + return value + + +def _write_json_atomic(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + handle, temporary = tempfile.mkstemp( + prefix=f"{path.name}.", + suffix=".tmp", + dir=path.parent, + text=True, + ) + temporary_path = Path(temporary) + try: + with os.fdopen(handle, "w", encoding="utf-8") as stream: + json.dump(value, stream, indent=2, sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary_path, path) + finally: + if temporary_path.exists(): + temporary_path.unlink() + + +def _utc_now() -> str: + return dt.datetime.now(dt.UTC).isoformat() + + +def _state_path(agent_id: str) -> Path: + return Path(".local/connect/servicenow/agents") / agent_id / "state.json" + + +def load_context(root: Path = Path(".")) -> dict[str, Any]: + setup = _load_json(root / SETUP_STATE) + if setup.get("schema_version") != SETUP_SCHEMA_VERSION: + raise ServiceNowConnectError( + f"Expected /setup schema {SETUP_SCHEMA_VERSION}." + ) + if setup.get("intent") != "DA foundation setup": + raise ServiceNowConnectError("The setup state is not DA foundation setup.") + if setup.get("connect_ready") is not True: + raise ServiceNowConnectError("DA foundation setup is not connect-ready.") + + environment = setup.get("environment") + agent = setup.get("agent") + if not isinstance(environment, dict) or not isinstance(agent, dict): + raise ServiceNowConnectError("The setup handoff is missing agent or environment.") + if agent.get("realm") != "dev": + raise ServiceNowConnectError("/connect only supports the editable Dev realm.") + if agent.get("schema_name") != HR_SCHEMA_NAME: + raise ServiceNowConnectError( + "This prototype supports only Employee Self-Service (HR)." + ) + + config = _load_json(root / ACTIVE_CONFIG) + active_slug = config.get("activeAgent") + agents = config.get("agents") + if not isinstance(agents, list): + raise ServiceNowConnectError("Operational setup config has no agent list.") + active = next( + ( + item + for item in agents + if isinstance(item, dict) and item.get("slug") == active_slug + ), + None, + ) + if not isinstance(active, dict): + raise ServiceNowConnectError("Could not resolve the active setup agent.") + if active.get("botId") != agent.get("id"): + raise ServiceNowConnectError( + "Canonical setup state and active agent config identify different agents." + ) + + relative_snapshot = active.get("agentBuilderChangeSetPath") + if not isinstance(relative_snapshot, str) or not relative_snapshot: + raise ServiceNowConnectError("The active agent has no component snapshot path.") + snapshot_path = root / Path(relative_snapshot) + return { + "root": root, + "setup": setup, + "config": config, + "environment": environment, + "agent": agent, + "active": active, + "snapshotPath": snapshot_path, + } + + +def _walk(value: Any): + if isinstance(value, dict): + yield value + for child in value.values(): + yield from _walk(child) + elif isinstance(value, list): + for child in value: + yield from _walk(child) + + +def _component_schema(change: dict[str, Any]) -> str: + component = change.get("component") + if not isinstance(component, dict): + return "" + return str(component.get("schemaName") or "") + + +def find_servicenow_reference(components: dict[str, Any]) -> dict[str, Any]: + matches = [] + for change in components.get("connectionReferenceChanges") or []: + if not isinstance(change, dict): + continue + reference = change.get("connectionReference") + if ( + isinstance(reference, dict) + and str(reference.get("connectorId") or "").casefold() + == CONNECTOR_ID.casefold() + ): + matches.append(reference) + if len(matches) != 1: + raise ServiceNowConnectError( + "Expected exactly one ServiceNow connection reference; " + f"found {len(matches)}." + ) + return matches[0] + + +def _shared_parameters(reference: dict[str, Any]) -> dict[str, Any]: + raw = reference.get("sharedConnectionParameters") + if not raw: + return {} + if isinstance(raw, dict): + value = raw + elif isinstance(raw, str): + try: + value = json.loads(raw) + except json.JSONDecodeError as exc: + raise ServiceNowConnectError( + "ServiceNow shared connection parameters are invalid JSON." + ) from exc + else: + raise ServiceNowConnectError( + "ServiceNow shared connection parameters have an invalid shape." + ) + return value if isinstance(value, dict) else {} + + +def _parameter_value(parameters: dict[str, Any], name: str) -> str | None: + values = parameters.get("values") + if not isinstance(values, dict): + return None + item = values.get(name) + if not isinstance(item, dict): + return None + value = item.get("value") + return str(value) if value not in (None, "") else None + + +def summarize_components(components: dict[str, Any]) -> dict[str, Any]: + reference = find_servicenow_reference(components) + service_now_topics = [ + change + for change in components.get("botComponentChanges") or [] + if "ServiceNowHRSD" in _component_schema(change) + ] + action_counts = {"InvokeFlowAction": 0, "InvokeConnectorAction": 0} + service_now_action_counts = {"InvokeFlowAction": 0, "InvokeConnectorAction": 0} + flow_ids: set[str] = set() + for change in components.get("botComponentChanges") or []: + is_service_now = "ServiceNowHRSD" in _component_schema(change) + for node in _walk(change): + kind = node.get("$kind") + if kind in action_counts: + action_counts[kind] += 1 + if is_service_now: + service_now_action_counts[kind] += 1 + flow_id = node.get("flowId") + if isinstance(flow_id, str) and flow_id: + flow_ids.add(flow_id) + + parameters = _shared_parameters(reference) + return { + "botComponentCount": len(components.get("botComponentChanges") or []), + "serviceNowTopicCount": len(service_now_topics), + "activeServiceNowTopicCount": sum( + 1 + for change in service_now_topics + if isinstance(change.get("component"), dict) + and change["component"].get("state") == "Active" + ), + "connectionReferenceCount": len( + components.get("connectionReferenceChanges") or [] + ), + "connectorDefinitionCount": len( + components.get("connectorDefinitionChanges") or [] + ), + "cloudFlowDefinitionCount": len( + components.get("cloudFlowDefinitionChanges") or [] + ), + "invokeFlowActionCount": action_counts["InvokeFlowAction"], + "invokeConnectorActionCount": action_counts["InvokeConnectorAction"], + "serviceNowInvokeFlowActionCount": service_now_action_counts[ + "InvokeFlowAction" + ], + "serviceNowInvokeConnectorActionCount": service_now_action_counts[ + "InvokeConnectorAction" + ], + "flowIds": sorted(flow_ids), + "reference": { + "id": reference.get("id"), + "connectionId": reference.get("connectionId"), + "logicalName": reference.get("connectionReferenceLogicalName"), + "displayName": reference.get("displayName"), + "authMode": parameters.get("name"), + "instanceName": _parameter_value( + parameters, + "token:InstanceName", + ), + "resourceUri": _parameter_value( + parameters, + "token:ResourceUri", + ), + }, + "hasChangeToken": bool(components.get("changeToken")), + } + + +def connectivity_scopes(ring: str) -> list[str]: + config = RING_CONFIG.get(ring) + if config is None: + raise ServiceNowConnectError(f"Unsupported Power Platform ring: {ring}") + audience = str(config["audience"]) + return [ + f"{audience}/Connectivity.Connections.Read", + f"{audience}/Connectivity.Connectors.Read", + f"{audience}/Connectivity.ConnectionPermissions.Read", + ] + + +class ConnectivityClient: + """Small client for the per-environment Power Platform Connectivity API.""" + + def __init__( + self, + host: str, + environment_id: str, + token: str, + *, + ring: str, + session: requests.Session | None = None, + ) -> None: + self.host = validate_environment_host(host, ring) + self.environment_id = str(uuid.UUID(environment_id)) + self.session = session or requests.Session() + retry = Retry( + total=3, + backoff_factor=1, + status_forcelist=(429, 500, 502, 503, 504), + allowed_methods=frozenset({"GET", "HEAD", "OPTIONS"}), + respect_retry_after_header=True, + ) + self.session.mount("https://", HTTPAdapter(max_retries=retry)) + self.headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "x-ms-client-name": "EssAdk", + } + + def _filter(self) -> str: + return f"environment eq '{self.environment_id}'" + + def _request( + self, + method: str, + path: str, + *, + body: dict[str, Any] | None = None, + include_filter: bool = True, + timeout: int = 60, + ) -> dict[str, Any]: + headers = dict(self.headers) + if body is not None: + headers["Content-Type"] = "application/json" + params = {"api-version": CONNECTIVITY_API_VERSION} + if include_filter: + params["$filter"] = self._filter() + response = self.session.request( + method, + f"{self.host}{path}", + params=params, + headers=headers, + json=body, + timeout=timeout, + ) + if not response.ok: + error_code = None + try: + payload = response.json() + except ValueError: + payload = None + if isinstance(payload, dict): + error = payload.get("error") or payload + if isinstance(error, dict): + error_code = error.get("code") + detail = f"Connectivity API returned HTTP {response.status_code}" + if error_code: + detail += f" ({error_code})" + raise ServiceNowConnectError(detail) + try: + value = response.json() + except ValueError as exc: + raise ServiceNowConnectError( + "Connectivity API returned non-JSON content." + ) from exc + if not isinstance(value, dict): + raise ServiceNowConnectError( + "Connectivity API returned an invalid JSON shape." + ) + return value + + def get_connector(self) -> dict[str, Any]: + return self._request( + "GET", + f"/connectivity/connectors/{CONNECTOR_NAME}", + ) + + def list_connections(self) -> list[dict[str, Any]]: + body = self._request( + "GET", + f"/connectivity/connectors/{CONNECTOR_NAME}/connections", + ) + values = body.get("value") + if not isinstance(values, list): + raise ServiceNowConnectError( + "Connection listing returned an invalid shape." + ) + return [value for value in values if isinstance(value, dict)] + + def get_connection(self, connection_id: str) -> dict[str, Any]: + normalized = uuid.UUID(connection_id).hex + return self._request( + "GET", + f"/connectivity/connectors/{CONNECTOR_NAME}/connections/" + f"{quote(normalized, safe='')}", + ) + +def connection_summary(record: dict[str, Any]) -> dict[str, Any]: + properties = record.get("properties") + if not isinstance(properties, dict): + properties = {} + statuses = properties.get("statuses") + if not isinstance(statuses, list): + statuses = [] + selected = next( + ( + status + for status in statuses + if isinstance(status, dict) and status.get("target") == "token" + ), + next((status for status in statuses if isinstance(status, dict)), {}), + ) + parameter_set = properties.get("connectionParametersSet") + if not isinstance(parameter_set, dict): + parameter_set = {} + values = parameter_set.get("values") + visible_values = {} + if isinstance(values, dict): + visible_values = { + key: value.get("value") + for key, value in values.items() + if key in { + "instance", + "token:InstanceName", + "token:ResourceUri", + } + if isinstance(value, dict) and "value" in value + } + return { + "connectionId": record.get("name"), + "displayName": properties.get("displayName"), + "status": selected.get("status"), + "statusTarget": selected.get("target"), + "authMode": parameter_set.get("name"), + "parameterValues": visible_values, + } + + +def build_reference_update_payload( + components: dict[str, Any], + connection_id: str, +) -> dict[str, Any]: + normalized_connection_id = uuid.UUID(connection_id).hex + change_token = components.get("changeToken") + if not isinstance(change_token, str) or not change_token: + raise ServiceNowConnectError( + "MinimalBot component state has no concurrency change token." + ) + reference = copy.deepcopy(find_servicenow_reference(components)) + reference["connectionId"] = normalized_connection_id + return { + "changeToken": change_token, + "connectionReferenceChanges": [ + { + "$kind": "ConnectionReferenceUpdate", + "connectionReference": reference, + } + ], + } + + +def _component_hash(components: dict[str, Any]) -> str: + encoded = json.dumps( + components, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _agentbuilder_client( + context: dict[str, Any], + *, + force_account_selection: bool = False, +) -> AgentBuilderClient: + environment = context["environment"] + token = authenticate( + environment["tenant_id"], + environment["ring"], + cache_path=TOKEN_CACHE, + force_account_selection=force_account_selection, + ) + return AgentBuilderClient( + environment["power_platform_api_endpoint"], + token, + ring=environment["ring"], + tenant_id=environment["tenant_id"], + api_version=environment["api_version"], + ) + + +def _connectivity_client( + context: dict[str, Any], + *, + force_account_selection: bool = False, +) -> ConnectivityClient: + environment = context["environment"] + token = authenticate_scopes( + environment["tenant_id"], + connectivity_scopes(environment["ring"]), + cache_path=TOKEN_CACHE, + force_account_selection=force_account_selection, + ) + return ConnectivityClient( + environment["power_platform_api_endpoint"], + environment["id"], + token, + ring=environment["ring"], + ) + + +def _state_base( + context: dict[str, Any], + components: dict[str, Any], +) -> dict[str, Any]: + summary = summarize_components(components) + return { + "schemaVersion": 1, + "intent": "DA-GA ServiceNow HRSD connection", + "agentId": context["agent"]["id"], + "agentSchemaName": context["agent"]["schema_name"], + "environmentId": context["environment"]["id"], + "ring": context["environment"]["ring"], + "componentHash": _component_hash(components), + "reference": summary["reference"], + "updatedAt": _utc_now(), + } + + +def inspect(context: dict[str, Any], *, offline: bool = False) -> dict[str, Any]: + if offline: + components = _load_json(context["snapshotPath"]) + else: + components = _agentbuilder_client(context).fetch_components( + context["agent"]["id"] + ) + summary = summarize_components(components) + result = { + "mode": "offline" if offline else "live", + "agentId": context["agent"]["id"], + "environmentId": context["environment"]["id"], + "components": summary, + } + if not offline: + connectivity = _connectivity_client(context) + connector = connectivity.get_connector() + connector_properties = connector.get("properties") + if not isinstance(connector_properties, dict): + connector_properties = connector + connections = [ + connection_summary(record) + for record in connectivity.list_connections() + ] + referenced_connection = None + referenced_error = None + referenced_id = summary["reference"].get("connectionId") + if referenced_id: + try: + referenced_connection = connection_summary( + connectivity.get_connection(referenced_id) + ) + except ServiceNowConnectError as exc: + referenced_error = str(exc) + result["connectivity"] = { + "connector": { + "displayName": connector_properties.get("displayName"), + "tier": connector_properties.get("tier"), + "isCustomApi": connector_properties.get("isCustomApi"), + }, + "connections": connections, + "referencedConnection": referenced_connection, + "referencedConnectionError": referenced_error, + } + state = _state_base(context, components) + state["lastInspection"] = result + _write_json_atomic(_state_path(context["agent"]["id"]), state) + return result + + +def prepare_manual_connection( + context: dict[str, Any], + *, + instance_name: str | None, + resource_uri: str | None, + display_name: str | None, +) -> dict[str, Any]: + components = _agentbuilder_client(context).fetch_components( + context["agent"]["id"] + ) + summary = summarize_components(components) + reference = summary["reference"] + instance_name = instance_name or reference.get("instanceName") + resource_uri = resource_uri or reference.get("resourceUri") + if not instance_name or not resource_uri: + raise ServiceNowConnectError( + "Instance name and Entra resource URI are required." + ) + state = _state_base(context, components) + state["connection"] = { + "connectionId": None, + "displayName": ( + display_name or "ESS HR ServiceNow HRSD Connection" + ), + "status": "maker-action-required", + "authMode": "entraIDUserLogin", + "instanceName": instance_name, + "resourceUri": resource_uri, + "createdBySkill": False, + } + state["steps"] = { + "connection": "maker-action-required", + "signIn": "maker-action-required", + "referenceBinding": "pending", + "publish": "pending", + "test": "pending", + } + _write_json_atomic(_state_path(context["agent"]["id"]), state) + return { + "status": "maker-action-required", + "creationMode": "manual", + "agentId": context["agent"]["id"], + "environmentId": context["environment"]["id"], + "connection": state["connection"], + "instructions": [ + "Open https://copilotstudio.microsoft.com and select the target environment.", + "Open the Employee Self-Service HR agent.", + "Select Settings, then Connection settings.", + "Find the ServiceNow connection and select its status link.", + "Open the connection configuration and select Create new connection.", + "Choose Microsoft Entra ID User Login.", + "Enter the Instance Name and Resource URI shown in this output.", + "Select Sign in, complete authentication, then select Submit.", + "Wait until the status is Connected, then return to VS Code.", + ], + "afterCompletion": { + "command": "python scripts/connect_servicenow_da.py inspect", + "expected": ( + "A ServiceNow connection with status Connected and auth mode " + "entraIDUserLogin." + ), + }, + } + + +def bind_reference( + context: dict[str, Any], + connection_id: str, + *, + confirmed: bool, +) -> dict[str, Any]: + if not confirmed: + raise ServiceNowConnectError( + "Reference binding requires explicit confirmation (--yes)." + ) + connectivity = _connectivity_client(context) + physical = connection_summary(connectivity.get_connection(connection_id)) + if physical.get("status") != "Connected": + raise ServiceNowConnectError( + "The physical ServiceNow connection is not Connected." + ) + + agentbuilder = _agentbuilder_client(context) + before = agentbuilder.fetch_components(context["agent"]["id"]) + before_summary = summarize_components(before) + normalized_connection_id = uuid.UUID(connection_id).hex + if before_summary["reference"].get("connectionId") == normalized_connection_id: + after = before + changed = False + else: + payload = build_reference_update_payload(before, normalized_connection_id) + agentbuilder.update_components(context["agent"]["id"], payload) + after = agentbuilder.fetch_components(context["agent"]["id"]) + changed = True + after_summary = summarize_components(after) + if after_summary["reference"].get("connectionId") != normalized_connection_id: + raise ServiceNowConnectError( + "MinimalBot update completed without the expected connection binding." + ) + + state_path = _state_path(context["agent"]["id"]) + state = _load_json(state_path) if state_path.exists() else _state_base( + context, + before, + ) + state["referenceBinding"] = { + "referenceId": after_summary["reference"].get("id"), + "previousConnectionId": before_summary["reference"].get("connectionId"), + "connectionId": normalized_connection_id, + "changedBySkill": changed, + "verifiedAt": _utc_now(), + } + steps = state.setdefault("steps", {}) + steps["referenceBinding"] = "done" + state["componentHash"] = _component_hash(after) + state["updatedAt"] = _utc_now() + _write_json_atomic(state_path, state) + return state + + +def publish( + context: dict[str, Any], + *, + confirmed: bool, +) -> dict[str, Any]: + if not confirmed: + raise ServiceNowConnectError( + "Publishing requires explicit confirmation (--yes)." + ) + response = _agentbuilder_client(context).publish(context["agent"]["id"]) + state_path = _state_path(context["agent"]["id"]) + state = _load_json(state_path) if state_path.exists() else { + "schemaVersion": 1, + "agentId": context["agent"]["id"], + "environmentId": context["environment"]["id"], + } + state["publish"] = { + "completedAt": _utc_now(), + "responseKeys": sorted(response.keys()), + } + steps = state.setdefault("steps", {}) + steps["publish"] = "done" + state["updatedAt"] = _utc_now() + _write_json_atomic(state_path, state) + return state + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Prototype DA-GA ServiceNow HRSD connection setup.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + inspect_parser = subparsers.add_parser( + "inspect", + help="Inspect the active HR agent and ServiceNow connection state.", + ) + inspect_parser.add_argument( + "--offline", + action="store_true", + help="Use the cached component snapshot and skip live APIs.", + ) + + create_parser = subparsers.add_parser( + "create", + help="Show guided steps for manually creating the ServiceNow connection.", + ) + create_parser.add_argument("--instance-name") + create_parser.add_argument("--resource-uri") + create_parser.add_argument("--display-name") + + bind_parser = subparsers.add_parser( + "bind", + help="Bind a Connected physical connection to the DA reference.", + ) + bind_parser.add_argument("--connection-id", required=True) + bind_parser.add_argument("--yes", action="store_true") + + publish_parser = subparsers.add_parser( + "publish", + help="Publish the active Dev agent.", + ) + publish_parser.add_argument("--yes", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + context = load_context() + if args.command == "inspect": + result = inspect(context, offline=args.offline) + elif args.command == "create": + result = prepare_manual_connection( + context, + instance_name=args.instance_name, + resource_uri=args.resource_uri, + display_name=args.display_name, + ) + elif args.command == "bind": + result = bind_reference( + context, + args.connection_id, + confirmed=args.yes, + ) + elif args.command == "publish": + result = publish(context, confirmed=args.yes) + else: # pragma: no cover + raise ServiceNowConnectError("Unsupported command.") + except (ServiceNowConnectError, AgentBuilderError, ValueError) as exc: + print(json.dumps({"status": "error", "message": str(exc)}, indent=2)) + return 1 + print(json.dumps(result, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/solutions/ess-maker-skills/src/skills/connect/SKILL.md b/solutions/ess-maker-skills/src/skills/connect/SKILL.md index 7de1b88cc..69e899d5e 100644 --- a/solutions/ess-maker-skills/src/skills/connect/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/connect/SKILL.md @@ -16,7 +16,14 @@ If the user specified an integration as an argument (e.g., the user said pass it to step1 as PRE_SELECTED_INTEGRATION. Step1 will skip the "which system" question and go directly to routing for that integration. -Read `src/skills/connect/step1.md` and follow it. +Read `.local/config.json`. + +If `releaseLine` is `da` and the selected integration is ServiceNow, read +`src/skills/connect/servicenow-da/SKILL.md` and follow it. This is the DA-GA HR +prototype and it must not route through the retained Preview-era ServiceNow +steps. + +Otherwise read `src/skills/connect/step1.md` and follow it. (Step 1 asks which integration, detects existing state, and dispatches — ServiceNow to its own step files and Workday to the hybrid-extension boundary @@ -29,7 +36,14 @@ in `src/skills/setup/SKILL.md`.) Each integration routes differently — ServiceNow has its own step files; Workday delegates to the setup orchestrator: -- **ServiceNow**: `src/skills/connect/servicenow/` +- **ServiceNow DA-GA HR prototype**: + `src/skills/connect/servicenow-da/SKILL.md` + - State: + `.local/connect/servicenow/agents//state.json` + - Uses MinimalBot Components and Power Platform Connectivity APIs. + - Does not use Dataverse connection-reference or workflow operations. + +- **ServiceNow retained Preview path**: `src/skills/connect/servicenow/` - Steps template: `src/skills/connect/servicenow/steps.md` - State file: `.local/connect/servicenow/steps.md` - Config file: `.local/connect/servicenow/config.json` diff --git a/solutions/ess-maker-skills/src/skills/connect/servicenow-da/SKILL.md b/solutions/ess-maker-skills/src/skills/connect/servicenow-da/SKILL.md new file mode 100644 index 000000000..63d9feedc --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/connect/servicenow-da/SKILL.md @@ -0,0 +1,103 @@ +# Connect ServiceNow HRSD to a DA-GA HR Agent + +This prototype uses the DA foundation handoff, MinimalBot Components, and the +Power Platform Connectivity API. It does not query Dataverse or activate cloud +flows. In the GA HR package, ServiceNow HRSD topics use direct connector +actions; the packaged flows are Workday-only. + +## 1. Inspect + +Run: + +```text +python scripts/connect_servicenow_da.py inspect +``` + +If setup is not schema v3, is not `connect_ready`, is not the editable Dev +realm, or is not the HR agent, show the returned error and stop. + +Summarize: + +- ServiceNow topic count and active count. +- ServiceNow connector-action count. +- Physical connection count. +- Whether the currently referenced connection exists and is Connected. + +Do not treat `/setup` `connect_ready: true` as integration readiness. + +## 2. Guide the maker to create the physical connection + +If exactly one matching Connected Entra user-login connection already exists, +reuse it. Otherwise run: + +```powershell +python scripts\connect_servicenow_da.py create +``` + +This command is read-only. It derives the instance name and resource URI from +the installed ServiceNow connection-reference metadata and returns the exact +values plus these maker steps: + +1. Open Copilot Studio and select the target environment. +2. Open the Employee Self-Service HR agent. +3. Select **Settings** -> **Connection settings**. +4. Find ServiceNow and select the status link. +5. Open the connection configuration and select **Create new connection**. +6. Choose **Microsoft Entra ID User Login**. +7. Enter the displayed **Instance Name** and **Resource URI**. +8. Select **Sign in**, complete authentication, and select **Submit**. +9. Wait for the status to become **Connected**, then return to VS Code. + +Stop and wait for the maker to confirm completion. Do not request +`Connectivity.Connections.Write` and do not call a connection-create API. + +After the maker confirms, rerun: + +```powershell +python scripts\connect_servicenow_da.py inspect +``` + +If exactly one matching Connected Entra user-login connection is present, show +its connection ID and continue. If none exists, explain that the manual +connection is not Connected yet and repeat only the relevant UI step. If +multiple matches exist, show their display names and IDs and ask the maker +which one to bind. + +## 3. Bind the DA connection reference + +Only continue when the physical connection reports `Connected`. Show the +connection ID and the current reference ID, then ask for explicit confirmation. + +After confirmation, run: + +```text +python scripts/connect_servicenow_da.py bind --connection-id --yes +``` + +The command fetches a fresh change token, sends one +`ConnectionReferenceUpdate`, refetches the components, and verifies the new +connection ID. It persists only non-secret before/after evidence under: + +```text +.local/connect/servicenow/agents//state.json +``` + +## 4. Maker-assisted OBO sharing + +Guide the maker through Copilot Studio connection sharing and OBO +configuration. No supported automation API has been proven for this step. + +## 5. Publish + +Show the connection and reference changes. Publish only after explicit +confirmation: + +```text +python scripts/connect_servicenow_da.py publish --yes +``` + +## 6. Test + +Ask the maker to run one HRSD request in the Copilot Studio Test pane. Record +the prompt and pass/fail attestation in the connector-owned state. Do not claim +success from connection health alone. diff --git a/tests/scripts/test_agentbuilder.py b/tests/scripts/test_agentbuilder.py index 0abe73afd..3209dc192 100644 --- a/tests/scripts/test_agentbuilder.py +++ b/tests/scripts/test_agentbuilder.py @@ -272,6 +272,39 @@ def test_client_uses_only_configured_environment_host() -> None: } +def test_update_components_uses_native_change_set_contract() -> None: + session = FakeSession( + [ + FakeResponse( + { + "changeToken": "updated-token", + "connectionReferenceChanges": [], + } + ) + ] + ) + client = agentbuilder.AgentBuilderClient( + HOST, + "fake-token", + ring="test", + tenant_id="00000000-0000-4000-8000-000000009999", + session=session, + ) + change_set = { + "changeToken": "token", + "connectionReferenceChanges": [], + } + + result = client.update_components(AGENT_ID, change_set) + + assert result["changeToken"] == "updated-token" + assert session.calls[0]["method"] == "PUT" + assert session.calls[0]["json"] == change_set + assert session.calls[0]["params"] == { + "api-version": agentbuilder.NATIVE_ALM_API_VERSION + } + + def test_realm_configuration_rejects_unknown_realm() -> None: client = agentbuilder.AgentBuilderClient( HOST, diff --git a/tests/scripts/test_connect_servicenow_da.py b/tests/scripts/test_connect_servicenow_da.py new file mode 100644 index 000000000..b0e38445c --- /dev/null +++ b/tests/scripts/test_connect_servicenow_da.py @@ -0,0 +1,268 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from __future__ import annotations + +import json + +from pathlib import Path + +import pytest + +import connect_servicenow_da as snow + + +ENVIRONMENT_ID = "00000000-0000-4000-8000-000000001111" +AGENT_ID = "00000000-0000-4000-8000-000000002222" +CONNECTION_ID = "00000000-0000-4000-8000-000000003333" + + +def _components(connection_id: str | None = None) -> dict: + return { + "changeToken": "token", + "botComponentChanges": [ + { + "$kind": "BotComponentInsert", + "component": { + "$kind": "DialogComponent", + "schemaName": ( + f"{snow.HR_SCHEMA_NAME}.topic." + "ServiceNowHRSDSystemCommonOrchestrator" + ), + "state": "Active", + "dialog": { + "$kind": "TaskDialog", + "action": { + "$kind": "InvokeConnectorAction", + "connectionReference": "servicenow-ref", + }, + }, + }, + }, + { + "$kind": "BotComponentInsert", + "component": { + "$kind": "DialogComponent", + "schemaName": f"{snow.HR_SCHEMA_NAME}.topic.WorkdayRuntime", + "state": "Active", + "dialog": { + "$kind": "TaskDialog", + "action": { + "$kind": "InvokeFlowAction", + "flowId": "flow-1", + }, + }, + }, + }, + ], + "connectionReferenceChanges": [ + { + "$kind": "ConnectionReferenceInsert", + "connectionReference": { + "$kind": "ConnectionReference", + "version": 4, + "id": "00000000-0000-4000-8000-000000004444", + "connectionId": connection_id, + "connectorId": snow.CONNECTOR_ID, + "connectionReferenceLogicalName": "servicenow-ref", + "displayName": "ServiceNow", + "sharedConnectionParameters": json.dumps( + { + "name": "entraIDUserLogin", + "values": { + "token:ResourceUri": {"value": "resource-id"}, + "token:InstanceName": {"value": "dev123"}, + }, + } + ), + }, + } + ], + "connectorDefinitionChanges": [ + {"$kind": "ConnectorDefinitionInsert"} + ], + } + + +def test_summarize_components_separates_servicenow_from_workday_flows() -> None: + result = snow.summarize_components(_components(CONNECTION_ID)) + + assert result["serviceNowTopicCount"] == 1 + assert result["activeServiceNowTopicCount"] == 1 + assert result["serviceNowInvokeConnectorActionCount"] == 1 + assert result["serviceNowInvokeFlowActionCount"] == 0 + assert result["invokeFlowActionCount"] == 1 + assert result["cloudFlowDefinitionCount"] == 0 + assert result["reference"]["instanceName"] == "dev123" + assert result["reference"]["resourceUri"] == "resource-id" + + +def test_reference_update_is_minimal_and_preserves_reference_metadata() -> None: + components = _components() + + payload = snow.build_reference_update_payload( + components, + CONNECTION_ID, + ) + + assert set(payload) == {"changeToken", "connectionReferenceChanges"} + assert payload["changeToken"] == "token" + change = payload["connectionReferenceChanges"][0] + assert change["$kind"] == "ConnectionReferenceUpdate" + assert change["connectionReference"]["connectionId"] == ( + CONNECTION_ID.replace("-", "") + ) + assert change["connectionReference"]["version"] == 4 + assert ( + components["connectionReferenceChanges"][0]["connectionReference"][ + "connectionId" + ] + is None + ) + + +def test_connection_summary_prefers_token_status() -> None: + result = snow.connection_summary( + { + "name": CONNECTION_ID.replace("-", ""), + "properties": { + "displayName": "ServiceNow", + "statuses": [ + {"target": "connector", "status": "Ready"}, + {"target": "token", "status": "Connected"}, + ], + "connectionParametersSet": { + "name": "entraIDUserLogin", + "values": { + "token:InstanceName": {"value": "dev123"}, + "token:ResourceUri": {"value": "resource-id"}, + "password": {"value": "must-not-be-returned"}, + }, + }, + }, + } + ) + + assert result["status"] == "Connected" + assert result["statusTarget"] == "token" + assert result["parameterValues"]["token:InstanceName"] == "dev123" + assert result["parameterValues"]["token:ResourceUri"] == "resource-id" + assert "password" not in result["parameterValues"] + + +def test_load_context_requires_matching_schema_v3_hr_agent( + tmp_path: Path, +) -> None: + setup_path = tmp_path / snow.SETUP_STATE + config_path = tmp_path / snow.ACTIVE_CONFIG + snapshot = Path( + "workspace/agents/employee-self-service-hr/" + ".agentbuilder/components.json" + ) + setup_path.parent.mkdir(parents=True) + setup_path.write_text( + json.dumps( + { + "schema_version": 3, + "intent": "DA foundation setup", + "connect_ready": True, + "environment": { + "id": ENVIRONMENT_ID, + "tenant_id": "00000000-0000-4000-8000-000000009999", + "ring": "test", + "api_version": "2024-10-01", + "power_platform_api_endpoint": ( + "https://example.environment.api.test.powerplatform.com" + ), + }, + "agent": { + "id": AGENT_ID, + "schema_name": snow.HR_SCHEMA_NAME, + "realm": "dev", + }, + } + ), + encoding="utf-8", + ) + config_path.write_text( + json.dumps( + { + "activeAgent": "employee-self-service-hr", + "agents": [ + { + "slug": "employee-self-service-hr", + "botId": AGENT_ID, + "agentBuilderChangeSetPath": snapshot.as_posix(), + } + ], + } + ), + encoding="utf-8", + ) + + result = snow.load_context(tmp_path) + + assert result["agent"]["id"] == AGENT_ID + assert result["snapshotPath"] == tmp_path / snapshot + + +def test_bind_requires_confirmation() -> None: + with pytest.raises(snow.ServiceNowConnectError, match="confirmation"): + snow.bind_reference({}, CONNECTION_ID, confirmed=False) + + +def test_connectivity_scopes_are_read_only() -> None: + scopes = snow.connectivity_scopes("test") + + assert ( + "https://api.test.powerplatform.com/Connectivity.Connections.Read" + in scopes + ) + assert not any(scope.endswith(".Write") for scope in scopes) + + +def test_prepare_manual_connection_returns_exact_maker_guidance( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + class FakeAgentBuilder: + def fetch_components(self, _agent_id: str) -> dict: + return _components() + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + snow, + "_agentbuilder_client", + lambda _context: FakeAgentBuilder(), + ) + monkeypatch.setattr( + snow, + "_connectivity_client", + lambda *_args, **_kwargs: pytest.fail( + "manual preparation must not call Connectivity APIs" + ), + ) + + result = snow.prepare_manual_connection( + { + "agent": { + "id": AGENT_ID, + "schema_name": snow.HR_SCHEMA_NAME, + }, + "environment": { + "id": ENVIRONMENT_ID, + "ring": "test", + }, + }, + instance_name=None, + resource_uri=None, + display_name=None, + ) + + assert result["creationMode"] == "manual" + assert result["connection"]["instanceName"] == "dev123" + assert result["connection"]["resourceUri"] == "resource-id" + assert any( + "Connection settings" in instruction + for instruction in result["instructions"] + ) diff --git a/tests/setup/test_da_setup_router.py b/tests/setup/test_da_setup_router.py index 679301c90..6f4c366e3 100644 --- a/tests/setup/test_da_setup_router.py +++ b/tests/setup/test_da_setup_router.py @@ -899,11 +899,11 @@ def test_non_da_ga_setup_implementation_is_absent() -> None: assert not (_SOLUTION / path).exists(), path -def test_da_commands_degrade_by_operation() -> None: +def test_da_commands_route_or_degrade_by_operation() -> None: expected_text = { "push.prompt.md": "DA-GA agent is not yet available", "delete.prompt.md": "DA-GA agent is not yet available", - "connect.prompt.md": "requires the corresponding product extension", + "connect.prompt.md": "src/skills/connect/SKILL.md", "troubleshoot.prompt.md": ( "requires the corresponding product extension guidance" ), From 29565984e3389e80772936e758bda3996f1d4533 Mon Sep 17 00:00:00 2001 From: Daphne Shao Date: Thu, 24 Sep 2026 00:47:01 -0700 Subject: [PATCH 2/9] fix(connect): align prototype with setup schema v4 Resolve the active operational agent against canonical per-agent setup state, reuse the latest AgentBuilder authentication and publish contracts, and isolate DA ServiceNow routing coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f9e8cfc7-07a1-469b-80f6-99a9cbb7aee6 --- .../scripts/connect_servicenow_da.py | 80 ++++++++++++++----- .../src/skills/connect/SKILL.md | 7 +- tests/scripts/test_connect_servicenow_da.py | 19 +++-- tests/setup/test_da_setup_router.py | 4 +- .../test_servicenow_da_connect_router.py | 22 +++++ 5 files changed, 99 insertions(+), 33 deletions(-) create mode 100644 tests/setup/test_servicenow_da_connect_router.py diff --git a/solutions/ess-maker-skills/scripts/connect_servicenow_da.py b/solutions/ess-maker-skills/scripts/connect_servicenow_da.py index 28fae86bb..432bace32 100644 --- a/solutions/ess-maker-skills/scripts/connect_servicenow_da.py +++ b/solutions/ess-maker-skills/scripts/connect_servicenow_da.py @@ -26,7 +26,6 @@ AgentBuilderError, RING_CONFIG, authenticate, - authenticate_scopes, validate_environment_host, ) @@ -36,7 +35,7 @@ CONNECTOR_ID = "/providers/Microsoft.PowerApps/apis/shared_service-now" CONNECTOR_NAME = "shared_service-now" CONNECTIVITY_API_VERSION = "1" -SETUP_SCHEMA_VERSION = 3 +SETUP_SCHEMA_VERSION = 4 HR_SCHEMA_NAME = "gptagent_copilotforemployeeselfservicehr" TOKEN_CACHE = Path(".local/.agentbuilder_token_cache.bin") @@ -94,39 +93,74 @@ def load_context(root: Path = Path(".")) -> dict[str, Any]: ) if setup.get("intent") != "DA foundation setup": raise ServiceNowConnectError("The setup state is not DA foundation setup.") - if setup.get("connect_ready") is not True: - raise ServiceNowConnectError("DA foundation setup is not connect-ready.") environment = setup.get("environment") - agent = setup.get("agent") - if not isinstance(environment, dict) or not isinstance(agent, dict): - raise ServiceNowConnectError("The setup handoff is missing agent or environment.") - if agent.get("realm") != "dev": - raise ServiceNowConnectError("/connect only supports the editable Dev realm.") - if agent.get("schema_name") != HR_SCHEMA_NAME: - raise ServiceNowConnectError( - "This prototype supports only Employee Self-Service (HR)." - ) - config = _load_json(root / ACTIVE_CONFIG) active_slug = config.get("activeAgent") - agents = config.get("agents") - if not isinstance(agents, list): + operational_agents = config.get("agents") + if not isinstance(environment, dict): + raise ServiceNowConnectError("The setup handoff has no environment.") + if not isinstance(active_slug, str) or not active_slug: + raise ServiceNowConnectError("Operational config has no active agent.") + if not isinstance(operational_agents, list): raise ServiceNowConnectError("Operational setup config has no agent list.") active = next( ( item - for item in agents + for item in operational_agents if isinstance(item, dict) and item.get("slug") == active_slug ), None, ) if not isinstance(active, dict): raise ServiceNowConnectError("Could not resolve the active setup agent.") - if active.get("botId") != agent.get("id"): + + active_bot_id = active.get("botId") + if not isinstance(active_bot_id, str): + raise ServiceNowConnectError("The active agent has no bot ID.") + try: + normalized_bot_id = str(uuid.UUID(active_bot_id)) + except ValueError as exc: + raise ServiceNowConnectError( + "The active agent bot ID is invalid." + ) from exc + + canonical_agents = setup.get("agents") + if not isinstance(canonical_agents, dict): + raise ServiceNowConnectError("Canonical setup state has no agent map.") + canonical = next( + ( + value + for key, value in canonical_agents.items() + if isinstance(key, str) + and key.casefold() == normalized_bot_id.casefold() + and isinstance(value, dict) + ), + None, + ) + if not isinstance(canonical, dict): + raise ServiceNowConnectError( + "The active agent has no canonical /setup record." + ) + if canonical.get("connect_ready") is not True: + raise ServiceNowConnectError("DA foundation setup is not connect-ready.") + agent = canonical.get("agent") + if not isinstance(agent, dict): + raise ServiceNowConnectError("The setup record has no agent identity.") + if str(agent.get("id") or "").casefold() != normalized_bot_id.casefold(): raise ServiceNowConnectError( "Canonical setup state and active agent config identify different agents." ) + if agent.get("workspace_slug") != active_slug: + raise ServiceNowConnectError( + "Canonical setup state and active workspace identify different agents." + ) + if agent.get("realm") != "dev": + raise ServiceNowConnectError("/connect only supports the editable Dev realm.") + if agent.get("schema_name") != HR_SCHEMA_NAME: + raise ServiceNowConnectError( + "This prototype supports only Employee Self-Service (HR)." + ) relative_snapshot = active.get("agentBuilderChangeSetPath") if not isinstance(relative_snapshot, str) or not relative_snapshot: @@ -135,6 +169,7 @@ def load_context(root: Path = Path(".")) -> dict[str, Any]: return { "root": root, "setup": setup, + "agentSetup": canonical, "config": config, "environment": environment, "agent": agent, @@ -499,11 +534,12 @@ def _connectivity_client( force_account_selection: bool = False, ) -> ConnectivityClient: environment = context["environment"] - token = authenticate_scopes( + token = authenticate( environment["tenant_id"], - connectivity_scopes(environment["ring"]), + environment["ring"], cache_path=TOKEN_CACHE, force_account_selection=force_account_selection, + scopes=tuple(connectivity_scopes(environment["ring"])), ) return ConnectivityClient( environment["power_platform_api_endpoint"], @@ -710,7 +746,9 @@ def publish( raise ServiceNowConnectError( "Publishing requires explicit confirmation (--yes)." ) - response = _agentbuilder_client(context).publish(context["agent"]["id"]) + response = _agentbuilder_client(context).publish_agent( + context["agent"]["id"] + ) state_path = _state_path(context["agent"]["id"]) state = _load_json(state_path) if state_path.exists() else { "schemaVersion": 1, diff --git a/solutions/ess-maker-skills/src/skills/connect/SKILL.md b/solutions/ess-maker-skills/src/skills/connect/SKILL.md index 69e899d5e..1cb6d4539 100644 --- a/solutions/ess-maker-skills/src/skills/connect/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/connect/SKILL.md @@ -18,9 +18,10 @@ pass it to step1 as PRE_SELECTED_INTEGRATION. Step1 will skip the Read `.local/config.json`. -If `releaseLine` is `da` and the selected integration is ServiceNow, read -`src/skills/connect/servicenow-da/SKILL.md` and follow it. This is the DA-GA HR -prototype and it must not route through the retained Preview-era ServiceNow +Resolve the entry in `agents` whose `slug` equals `activeAgent`. If that +entry's `releaseLine` is `da` and the selected integration is ServiceNow, read +`src/skills/connect/servicenow-da/SKILL.md` and follow it. This is the DA-GA +HR prototype and it must not route through the retained Preview-era ServiceNow steps. Otherwise read `src/skills/connect/step1.md` and follow it. diff --git a/tests/scripts/test_connect_servicenow_da.py b/tests/scripts/test_connect_servicenow_da.py index b0e38445c..676a3d961 100644 --- a/tests/scripts/test_connect_servicenow_da.py +++ b/tests/scripts/test_connect_servicenow_da.py @@ -150,7 +150,7 @@ def test_connection_summary_prefers_token_status() -> None: assert "password" not in result["parameterValues"] -def test_load_context_requires_matching_schema_v3_hr_agent( +def test_load_context_requires_matching_schema_v4_hr_agent( tmp_path: Path, ) -> None: setup_path = tmp_path / snow.SETUP_STATE @@ -163,9 +163,8 @@ def test_load_context_requires_matching_schema_v3_hr_agent( setup_path.write_text( json.dumps( { - "schema_version": 3, + "schema_version": 4, "intent": "DA foundation setup", - "connect_ready": True, "environment": { "id": ENVIRONMENT_ID, "tenant_id": "00000000-0000-4000-8000-000000009999", @@ -175,10 +174,16 @@ def test_load_context_requires_matching_schema_v3_hr_agent( "https://example.environment.api.test.powerplatform.com" ), }, - "agent": { - "id": AGENT_ID, - "schema_name": snow.HR_SCHEMA_NAME, - "realm": "dev", + "agents": { + AGENT_ID: { + "connect_ready": True, + "agent": { + "id": AGENT_ID, + "schema_name": snow.HR_SCHEMA_NAME, + "realm": "dev", + "workspace_slug": "employee-self-service-hr", + }, + } }, } ), diff --git a/tests/setup/test_da_setup_router.py b/tests/setup/test_da_setup_router.py index 6f4c366e3..679301c90 100644 --- a/tests/setup/test_da_setup_router.py +++ b/tests/setup/test_da_setup_router.py @@ -899,11 +899,11 @@ def test_non_da_ga_setup_implementation_is_absent() -> None: assert not (_SOLUTION / path).exists(), path -def test_da_commands_route_or_degrade_by_operation() -> None: +def test_da_commands_degrade_by_operation() -> None: expected_text = { "push.prompt.md": "DA-GA agent is not yet available", "delete.prompt.md": "DA-GA agent is not yet available", - "connect.prompt.md": "src/skills/connect/SKILL.md", + "connect.prompt.md": "requires the corresponding product extension", "troubleshoot.prompt.md": ( "requires the corresponding product extension guidance" ), diff --git a/tests/setup/test_servicenow_da_connect_router.py b/tests/setup/test_servicenow_da_connect_router.py new file mode 100644 index 000000000..88d26b7c9 --- /dev/null +++ b/tests/setup/test_servicenow_da_connect_router.py @@ -0,0 +1,22 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from pathlib import Path + + +_ROOT = Path(__file__).resolve().parents[2] +_SOLUTION = _ROOT / "solutions" / "ess-maker-skills" + + +def test_da_servicenow_connect_routes_to_prototype_skill() -> None: + prompt = ( + _SOLUTION / ".github" / "prompts" / "connect.prompt.md" + ).read_text(encoding="utf-8") + router = ( + _SOLUTION / "src" / "skills" / "connect" / "SKILL.md" + ).read_text(encoding="utf-8") + + assert "src/skills/connect/SKILL.md" in prompt + assert "Extension setup is not yet available" not in prompt + assert "src/skills/connect/servicenow-da/SKILL.md" in router + assert "releaseLine" in router From 47d0fd119bbf79ffaa93da635e2ddba88137d2a8 Mon Sep 17 00:00:00 2001 From: Daphne Shao Date: Thu, 24 Sep 2026 12:37:21 -0700 Subject: [PATCH 3/9] fix(connect): allow connection-blocked setup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f9e8cfc7-07a1-469b-80f6-99a9cbb7aee6 --- .../scripts/connect_servicenow_da.py | 33 +++++++++++++++++-- .../src/skills/connect/servicenow-da/SKILL.md | 7 ++-- tests/scripts/test_connect_servicenow_da.py | 32 +++++++++++++++++- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/solutions/ess-maker-skills/scripts/connect_servicenow_da.py b/solutions/ess-maker-skills/scripts/connect_servicenow_da.py index 432bace32..b15f6e6c2 100644 --- a/solutions/ess-maker-skills/scripts/connect_servicenow_da.py +++ b/solutions/ess-maker-skills/scripts/connect_servicenow_da.py @@ -38,6 +38,13 @@ SETUP_SCHEMA_VERSION = 4 HR_SCHEMA_NAME = "gptagent_copilotforemployeeselfservicehr" TOKEN_CACHE = Path(".local/.agentbuilder_token_cache.bin") +CONNECT_FOUNDATION_STEPS = ( + "SETUP-01", + "SETUP-02.1", + "SETUP-03", + "SETUP-04", + "SETUP-07", +) class ServiceNowConnectError(RuntimeError): @@ -142,8 +149,6 @@ def load_context(root: Path = Path(".")) -> dict[str, Any]: raise ServiceNowConnectError( "The active agent has no canonical /setup record." ) - if canonical.get("connect_ready") is not True: - raise ServiceNowConnectError("DA foundation setup is not connect-ready.") agent = canonical.get("agent") if not isinstance(agent, dict): raise ServiceNowConnectError("The setup record has no agent identity.") @@ -161,6 +166,30 @@ def load_context(root: Path = Path(".")) -> dict[str, Any]: raise ServiceNowConnectError( "This prototype supports only Employee Self-Service (HR)." ) + steps = canonical.get("steps") + if not isinstance(steps, dict): + raise ServiceNowConnectError("The setup record has no foundation steps.") + incomplete = [ + step_id + for step_id in CONNECT_FOUNDATION_STEPS + if not isinstance(steps.get(step_id), dict) + or steps[step_id].get("state") != "done" + ] + if incomplete: + raise ServiceNowConnectError( + "DA foundation setup is incomplete for /connect: " + + ", ".join(incomplete) + + "." + ) + workspace = canonical.get("workspace") + if ( + not isinstance(workspace, dict) + or not workspace.get("folder") + or not workspace.get("agent_path") + ): + raise ServiceNowConnectError( + "DA foundation setup has no materialized agent workspace." + ) relative_snapshot = active.get("agentBuilderChangeSetPath") if not isinstance(relative_snapshot, str) or not relative_snapshot: diff --git a/solutions/ess-maker-skills/src/skills/connect/servicenow-da/SKILL.md b/solutions/ess-maker-skills/src/skills/connect/servicenow-da/SKILL.md index 63d9feedc..ac4cd293c 100644 --- a/solutions/ess-maker-skills/src/skills/connect/servicenow-da/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/connect/servicenow-da/SKILL.md @@ -13,8 +13,11 @@ Run: python scripts/connect_servicenow_da.py inspect ``` -If setup is not schema v3, is not `connect_ready`, is not the editable Dev -realm, or is not the HR agent, show the returned error and stop. +If setup is not schema v4, has not established the environment, exact editable +Dev agent, and local workspace, or is not the HR agent, show the returned error +and stop. Do not require aggregate `connect_ready`: setup can report the +ServiceNow connection as not configured, and this workflow exists to resolve +that condition. Summarize: diff --git a/tests/scripts/test_connect_servicenow_da.py b/tests/scripts/test_connect_servicenow_da.py index 676a3d961..bac010d37 100644 --- a/tests/scripts/test_connect_servicenow_da.py +++ b/tests/scripts/test_connect_servicenow_da.py @@ -176,13 +176,27 @@ def test_load_context_requires_matching_schema_v4_hr_agent( }, "agents": { AGENT_ID: { - "connect_ready": True, + "connect_ready": False, "agent": { "id": AGENT_ID, "schema_name": snow.HR_SCHEMA_NAME, "realm": "dev", "workspace_slug": "employee-self-service-hr", }, + "workspace": { + "folder": "workspace/agents/employee-self-service-hr", + "agent_path": "agent.mcs.yml", + }, + "steps": { + "SETUP-01": {"state": "done"}, + "SETUP-02.1": {"state": "done"}, + "SETUP-02.2": {"state": "blocked"}, + "SETUP-03": {"state": "done"}, + "SETUP-04": {"state": "done"}, + "SETUP-05": {"state": "blocked"}, + "SETUP-06": {"state": "done"}, + "SETUP-07": {"state": "done"}, + }, } }, } @@ -211,6 +225,22 @@ def test_load_context_requires_matching_schema_v4_hr_agent( assert result["snapshotPath"] == tmp_path / snapshot +def test_load_context_rejects_incomplete_foundation_step( + tmp_path: Path, +) -> None: + test_load_context_requires_matching_schema_v4_hr_agent(tmp_path) + setup_path = tmp_path / snow.SETUP_STATE + setup = json.loads(setup_path.read_text(encoding="utf-8")) + setup["agents"][AGENT_ID]["steps"]["SETUP-03"]["state"] = "blocked" + setup_path.write_text(json.dumps(setup), encoding="utf-8") + + with pytest.raises( + snow.ServiceNowConnectError, + match="incomplete.*SETUP-03", + ): + snow.load_context(tmp_path) + + def test_bind_requires_confirmation() -> None: with pytest.raises(snow.ServiceNowConnectError, match="confirmation"): snow.bind_reference({}, CONNECTION_ID, confirmed=False) From 697e30fd2f204ca7239f605cdf20891592636d68 Mon Sep 17 00:00:00 2001 From: Daphne Shao Date: Thu, 24 Sep 2026 12:48:57 -0700 Subject: [PATCH 4/9] fix(connect): admit connection setup before readiness Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f9e8cfc7-07a1-469b-80f6-99a9cbb7aee6 --- .../.github/copilot-instructions.md | 17 ++++++++++------- .../.github/prompts/connect.prompt.md | 7 ++++++- tests/setup/test_da_setup_router.py | 15 +++++++++++---- .../setup/test_servicenow_da_connect_router.py | 18 ++++++++++++++++++ 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/solutions/ess-maker-skills/.github/copilot-instructions.md b/solutions/ess-maker-skills/.github/copilot-instructions.md index 3fa721a38..13951ec25 100644 --- a/solutions/ess-maker-skills/.github/copilot-instructions.md +++ b/solutions/ess-maker-skills/.github/copilot-instructions.md @@ -10,11 +10,13 @@ to do based on the result. ### If setup is missing or not ready -DA setup is ready only when `.local/setup/config.json` has `schema_version` -equal to `4` and its `agents` entry matching `.local/config.json`'s -`activeAgent` workspace slug has `connect_ready` equal to `true`. Setup writes -that per-agent marker only after all eight foundation steps for that agent have -reached `done`. +DA authoring setup is ready when `.local/setup/config.json` has +`schema_version` equal to `4` and its `agents` entry matching `.local/config.json`'s +`activeAgent` workspace slug has foundation steps +`SETUP-01`, `SETUP-02.1`, `SETUP-03`, `SETUP-04`, and `SETUP-07` in `done` +state. Do not require aggregate `connect_ready`: capacity and product +connection checks can remain blocked after the exact editable agent and local +workspace are ready, and `/connect` exists to resolve a missing connection. **STOP.** Do not read any skill files. Do not load templates. Do not search for files. Do not attempt any customization work. Do not answer questions about ESS. @@ -79,8 +81,9 @@ After canonical DA setup is complete: available; - Dataverse push and server-backed validation are permitted in this workspace; run the push pipeline when the maker asks to push local changes; -- `/connect` and integration troubleshooting require the corresponding DA-GA - product extension guidance, which is not yet available; +- `/connect servicenow` is available for the DA-GA HR prototype through its + product-specific guidance; other integrations require their corresponding + DA-GA product extension guidance; - `/backup-template-configs` and `/restore-template-configs` are no longer supported because they belonged to the retired Dataverse-based agent model; - `/flightcheck` may run only its local-files scope. diff --git a/solutions/ess-maker-skills/.github/prompts/connect.prompt.md b/solutions/ess-maker-skills/.github/prompts/connect.prompt.md index 33fed7c92..71df1099c 100644 --- a/solutions/ess-maker-skills/.github/prompts/connect.prompt.md +++ b/solutions/ess-maker-skills/.github/prompts/connect.prompt.md @@ -5,7 +5,12 @@ description: "Check DA-GA product extension setup availability" # Connect -**Setup-state check.** Read `.local/setup/config.json` and `.local/config.json`. If canonical state does not have `schema_version: 4` and an `agents` entry matching the active workspace slug with `connect_ready: true`, show: +**Setup-state check.** Read `.local/setup/config.json` and `.local/config.json`. +If canonical state does not have `schema_version: 4` and an `agents` entry +matching the active workspace slug with foundation steps `SETUP-01`, +`SETUP-02.1`, `SETUP-03`, `SETUP-04`, and `SETUP-07` in `done` state, show the +message below and STOP. Do not require aggregate `connect_ready`; capacity and +the product connection itself may still need attention. > Welcome to the ESS Maker Kit. Before running `/connect`, type `/setup` to set up your environment. diff --git a/tests/setup/test_da_setup_router.py b/tests/setup/test_da_setup_router.py index 679301c90..0aae2f321 100644 --- a/tests/setup/test_da_setup_router.py +++ b/tests/setup/test_da_setup_router.py @@ -162,23 +162,23 @@ def test_public_setup_does_not_configure_mcp() -> None: assert "materialize-defaults" not in prompt -def test_global_and_command_gates_require_canonical_da_completion() -> None: +def test_global_and_command_gates_require_canonical_da_foundation() -> None: instructions = _INSTRUCTIONS.read_text(encoding="utf-8") assert "`schema_version`" in instructions assert "equal to `4`" in instructions assert "`agents` entry matching `.local/config.json`" in instructions - assert "`connect_ready` equal to `true`" in instructions + assert "Do not require aggregate `connect_ready`" in instructions + for step in ("SETUP-01", "SETUP-02.1", "SETUP-03", "SETUP-04", "SETUP-07"): + assert step in instructions assert '`status` equal to `"complete"`' not in instructions gated_prompts = ( "backup-template-configs.prompt.md", - "connect.prompt.md", "create.prompt.md", "delete.prompt.md", "evaluate.prompt.md", "flightcheck.prompt.md", - "push.prompt.md", "restore-template-configs.prompt.md", "review.prompt.md", "run.prompt.md", @@ -203,6 +203,13 @@ def test_global_and_command_gates_require_canonical_da_completion() -> None: not in normalized ), path + connect_prompt = (_PROMPTS / "connect.prompt.md").read_text(encoding="utf-8") + assert ".local/setup/config.json" in connect_prompt + assert "schema_version: 4" in connect_prompt + assert "Do not require aggregate `connect_ready`" in connect_prompt + for step in ("SETUP-01", "SETUP-02.1", "SETUP-03", "SETUP-04", "SETUP-07"): + assert step in connect_prompt + assert "`.local/config.json`'s" in instructions diff --git a/tests/setup/test_servicenow_da_connect_router.py b/tests/setup/test_servicenow_da_connect_router.py index 88d26b7c9..f888908c7 100644 --- a/tests/setup/test_servicenow_da_connect_router.py +++ b/tests/setup/test_servicenow_da_connect_router.py @@ -18,5 +18,23 @@ def test_da_servicenow_connect_routes_to_prototype_skill() -> None: assert "src/skills/connect/SKILL.md" in prompt assert "Extension setup is not yet available" not in prompt + assert "Do not require aggregate `connect_ready`" in prompt + assert all( + step in prompt + for step in ("SETUP-01", "SETUP-02.1", "SETUP-03", "SETUP-04", "SETUP-07") + ) assert "src/skills/connect/servicenow-da/SKILL.md" in router assert "releaseLine" in router + + +def test_global_gate_allows_connection_blocked_foundation() -> None: + instructions = ( + _SOLUTION / ".github" / "copilot-instructions.md" + ).read_text(encoding="utf-8") + + assert "Do not require aggregate `connect_ready`" in instructions + assert all( + step in instructions + for step in ("SETUP-01", "SETUP-02.1", "SETUP-03", "SETUP-04", "SETUP-07") + ) + assert "`/connect servicenow` is available" in instructions From bb41da2111c82cd77955f127c4540292105c34ee Mon Sep 17 00:00:00 2001 From: Daphne Shao Date: Thu, 24 Sep 2026 13:09:38 -0700 Subject: [PATCH 5/9] fix(connect): resolve active setup agent explicitly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f9e8cfc7-07a1-469b-80f6-99a9cbb7aee6 --- .../.github/copilot-instructions.md | 14 +++++++++----- .../.github/prompts/connect.prompt.md | 12 +++++++----- tests/setup/test_da_setup_router.py | 4 +++- tests/setup/test_servicenow_da_connect_router.py | 5 +++++ 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/solutions/ess-maker-skills/.github/copilot-instructions.md b/solutions/ess-maker-skills/.github/copilot-instructions.md index 13951ec25..a47f572e9 100644 --- a/solutions/ess-maker-skills/.github/copilot-instructions.md +++ b/solutions/ess-maker-skills/.github/copilot-instructions.md @@ -3,16 +3,20 @@ ## MANDATORY FIRST ACTION — Do This Before Anything Else **YOUR VERY FIRST ACTION on every new conversation must be: use your file -reading tool to try to read `.local/setup/config.json`.** +reading tool to read `.local/setup/config.json` and `.local/config.json` +together.** Do NOT skip this step. Do NOT respond to the user's message first. Do NOT greet -the user first. Do NOT list capabilities. Read this file FIRST, then decide what -to do based on the result. +the user first. Do NOT list capabilities. Read both files FIRST, then decide +what to do based on the result. ### If setup is missing or not ready DA authoring setup is ready when `.local/setup/config.json` has -`schema_version` equal to `4` and its `agents` entry matching `.local/config.json`'s -`activeAgent` workspace slug has foundation steps +`schema_version` equal to `4`. Resolve `.local/config.json`'s `activeAgent` +slug against the object in its `agents` array whose `slug` matches, then use +that object's `botId` to select the canonical entry in +`.local/setup/config.json`'s `agents` object. That canonical entry must have +foundation steps `SETUP-01`, `SETUP-02.1`, `SETUP-03`, `SETUP-04`, and `SETUP-07` in `done` state. Do not require aggregate `connect_ready`: capacity and product connection checks can remain blocked after the exact editable agent and local diff --git a/solutions/ess-maker-skills/.github/prompts/connect.prompt.md b/solutions/ess-maker-skills/.github/prompts/connect.prompt.md index 71df1099c..9972cef11 100644 --- a/solutions/ess-maker-skills/.github/prompts/connect.prompt.md +++ b/solutions/ess-maker-skills/.github/prompts/connect.prompt.md @@ -6,11 +6,13 @@ description: "Check DA-GA product extension setup availability" # Connect **Setup-state check.** Read `.local/setup/config.json` and `.local/config.json`. -If canonical state does not have `schema_version: 4` and an `agents` entry -matching the active workspace slug with foundation steps `SETUP-01`, -`SETUP-02.1`, `SETUP-03`, `SETUP-04`, and `SETUP-07` in `done` state, show the -message below and STOP. Do not require aggregate `connect_ready`; capacity and -the product connection itself may still need attention. +Resolve `.local/config.json`'s `activeAgent` slug to the matching object in its +`agents` array, then use that object's `botId` to select the entry in +`.local/setup/config.json`'s `agents` object. If setup does not have +`schema_version: 4`, or that canonical agent entry does not have foundation +steps `SETUP-01`, `SETUP-02.1`, `SETUP-03`, `SETUP-04`, and `SETUP-07` in +`done` state, show the message below and STOP. Do not require aggregate `connect_ready`; +capacity and the product connection itself may still need attention. > Welcome to the ESS Maker Kit. Before running `/connect`, type `/setup` to set up your environment. diff --git a/tests/setup/test_da_setup_router.py b/tests/setup/test_da_setup_router.py index 0aae2f321..22e1ef02b 100644 --- a/tests/setup/test_da_setup_router.py +++ b/tests/setup/test_da_setup_router.py @@ -167,7 +167,9 @@ def test_global_and_command_gates_require_canonical_da_foundation() -> None: assert "`schema_version`" in instructions assert "equal to `4`" in instructions - assert "`agents` entry matching `.local/config.json`" in instructions + assert "read `.local/setup/config.json` and `.local/config.json`" in instructions + assert "`activeAgent`" in instructions + assert "`botId`" in instructions assert "Do not require aggregate `connect_ready`" in instructions for step in ("SETUP-01", "SETUP-02.1", "SETUP-03", "SETUP-04", "SETUP-07"): assert step in instructions diff --git a/tests/setup/test_servicenow_da_connect_router.py b/tests/setup/test_servicenow_da_connect_router.py index f888908c7..d367a4e76 100644 --- a/tests/setup/test_servicenow_da_connect_router.py +++ b/tests/setup/test_servicenow_da_connect_router.py @@ -23,6 +23,8 @@ def test_da_servicenow_connect_routes_to_prototype_skill() -> None: step in prompt for step in ("SETUP-01", "SETUP-02.1", "SETUP-03", "SETUP-04", "SETUP-07") ) + assert "`activeAgent` slug" in prompt + assert "`botId`" in prompt assert "src/skills/connect/servicenow-da/SKILL.md" in router assert "releaseLine" in router @@ -37,4 +39,7 @@ def test_global_gate_allows_connection_blocked_foundation() -> None: step in instructions for step in ("SETUP-01", "SETUP-02.1", "SETUP-03", "SETUP-04", "SETUP-07") ) + assert "read `.local/setup/config.json` and `.local/config.json`" in instructions + assert "`activeAgent`" in instructions + assert "`botId`" in instructions assert "`/connect servicenow` is available" in instructions From 64ae4689b0638637e5d5e256a8fb4cefded3556b Mon Sep 17 00:00:00 2001 From: Daphne Shao Date: Thu, 24 Sep 2026 13:13:48 -0700 Subject: [PATCH 6/9] fix(connect): avoid multi-read global admission Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f9e8cfc7-07a1-469b-80f6-99a9cbb7aee6 --- .../.github/copilot-instructions.md | 16 +++++++++------- tests/setup/test_da_setup_router.py | 7 +++---- tests/setup/test_servicenow_da_connect_router.py | 5 ++--- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/solutions/ess-maker-skills/.github/copilot-instructions.md b/solutions/ess-maker-skills/.github/copilot-instructions.md index a47f572e9..aea8e6982 100644 --- a/solutions/ess-maker-skills/.github/copilot-instructions.md +++ b/solutions/ess-maker-skills/.github/copilot-instructions.md @@ -3,25 +3,27 @@ ## MANDATORY FIRST ACTION — Do This Before Anything Else **YOUR VERY FIRST ACTION on every new conversation must be: use your file -reading tool to read `.local/setup/config.json` and `.local/config.json` -together.** +reading tool to read `.local/setup/config.json`.** Do NOT skip this step. Do NOT respond to the user's message first. Do NOT greet -the user first. Do NOT list capabilities. Read both files FIRST, then decide +the user first. Do NOT list capabilities. Read this file FIRST, then decide what to do based on the result. ### If setup is missing or not ready DA authoring setup is ready when `.local/setup/config.json` has -`schema_version` equal to `4`. Resolve `.local/config.json`'s `activeAgent` -slug against the object in its `agents` array whose `slug` matches, then use -that object's `botId` to select the canonical entry in -`.local/setup/config.json`'s `agents` object. That canonical entry must have +`schema_version` equal to `4` and at least one entry in its `agents` object has foundation steps `SETUP-01`, `SETUP-02.1`, `SETUP-03`, `SETUP-04`, and `SETUP-07` in `done` state. Do not require aggregate `connect_ready`: capacity and product connection checks can remain blocked after the exact editable agent and local workspace are ready, and `/connect` exists to resolve a missing connection. +This first gate establishes only that the workspace has a usable DA foundation. +After it passes, read `.local/config.json` and let the invoked command resolve +and validate the exact active agent. If operational configuration is missing or +does not match a canonical setup entry, report that configuration error instead +of routing back to `/setup`. + **STOP.** Do not read any skill files. Do not load templates. Do not search for files. Do not attempt any customization work. Do not answer questions about ESS. Do not list your capabilities. Do not greet the user with a menu of options. diff --git a/tests/setup/test_da_setup_router.py b/tests/setup/test_da_setup_router.py index 22e1ef02b..a3d2f25cb 100644 --- a/tests/setup/test_da_setup_router.py +++ b/tests/setup/test_da_setup_router.py @@ -167,9 +167,8 @@ def test_global_and_command_gates_require_canonical_da_foundation() -> None: assert "`schema_version`" in instructions assert "equal to `4`" in instructions - assert "read `.local/setup/config.json` and `.local/config.json`" in instructions - assert "`activeAgent`" in instructions - assert "`botId`" in instructions + assert "at least one entry in its `agents` object" in instructions + assert "let the invoked command resolve" in instructions assert "Do not require aggregate `connect_ready`" in instructions for step in ("SETUP-01", "SETUP-02.1", "SETUP-03", "SETUP-04", "SETUP-07"): assert step in instructions @@ -212,7 +211,7 @@ def test_global_and_command_gates_require_canonical_da_foundation() -> None: for step in ("SETUP-01", "SETUP-02.1", "SETUP-03", "SETUP-04", "SETUP-07"): assert step in connect_prompt - assert "`.local/config.json`'s" in instructions + assert "read `.local/config.json`" in instructions def test_global_gate_preserves_flightcheck_only_mode() -> None: diff --git a/tests/setup/test_servicenow_da_connect_router.py b/tests/setup/test_servicenow_da_connect_router.py index d367a4e76..884f60e02 100644 --- a/tests/setup/test_servicenow_da_connect_router.py +++ b/tests/setup/test_servicenow_da_connect_router.py @@ -39,7 +39,6 @@ def test_global_gate_allows_connection_blocked_foundation() -> None: step in instructions for step in ("SETUP-01", "SETUP-02.1", "SETUP-03", "SETUP-04", "SETUP-07") ) - assert "read `.local/setup/config.json` and `.local/config.json`" in instructions - assert "`activeAgent`" in instructions - assert "`botId`" in instructions + assert "at least one entry in its `agents` object" in instructions + assert "let the invoked command resolve" in instructions assert "`/connect servicenow` is available" in instructions From 42d46d6e0e92e3a8ff1b10763363f3c25e30d688 Mon Sep 17 00:00:00 2001 From: Daphne Shao Date: Fri, 25 Sep 2026 12:48:45 -0700 Subject: [PATCH 7/9] feat(connect): finalize resumable ServiceNow DA flow Add deterministic authoring readiness, live topic and connection handling, maker-only binding boundaries, resumable progress, and regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f9e8cfc7-07a1-469b-80f6-99a9cbb7aee6 --- .../.github/copilot-instructions.md | 10 +- .../.github/prompts/connect.prompt.md | 19 +- .../scripts/connect_servicenow_da.py | 664 ++++++++++++++++-- .../scripts/setup_existing_da.py | 110 +++ .../src/skills/connect/servicenow-da/SKILL.md | 222 +++++- tests/scripts/test_connect_servicenow_da.py | 556 ++++++++++++++- tests/scripts/test_setup_existing_da.py | 47 ++ tests/setup/test_da_setup_router.py | 12 +- .../test_servicenow_da_connect_router.py | 49 +- 9 files changed, 1536 insertions(+), 153 deletions(-) diff --git a/solutions/ess-maker-skills/.github/copilot-instructions.md b/solutions/ess-maker-skills/.github/copilot-instructions.md index aea8e6982..b63dbec0b 100644 --- a/solutions/ess-maker-skills/.github/copilot-instructions.md +++ b/solutions/ess-maker-skills/.github/copilot-instructions.md @@ -12,11 +12,11 @@ what to do based on the result. DA authoring setup is ready when `.local/setup/config.json` has `schema_version` equal to `4` and at least one entry in its `agents` object has -foundation steps -`SETUP-01`, `SETUP-02.1`, `SETUP-03`, `SETUP-04`, and `SETUP-07` in `done` -state. Do not require aggregate `connect_ready`: capacity and product -connection checks can remain blocked after the exact editable agent and local -workspace are ready, and `/connect` exists to resolve a missing connection. +`authoring_ready` equal to `true`. This is the only readiness marker used by +this first gate. Ignore `connect_ready`, `active_step`, blocked capacity, and +blocked connection steps here: they describe runtime readiness after the exact +editable agent and local workspace are already usable, and `/connect` exists +to resolve a missing connection. This first gate establishes only that the workspace has a usable DA foundation. After it passes, read `.local/config.json` and let the invoked command resolve diff --git a/solutions/ess-maker-skills/.github/prompts/connect.prompt.md b/solutions/ess-maker-skills/.github/prompts/connect.prompt.md index 9972cef11..a7c3a024b 100644 --- a/solutions/ess-maker-skills/.github/prompts/connect.prompt.md +++ b/solutions/ess-maker-skills/.github/prompts/connect.prompt.md @@ -9,10 +9,11 @@ description: "Check DA-GA product extension setup availability" Resolve `.local/config.json`'s `activeAgent` slug to the matching object in its `agents` array, then use that object's `botId` to select the entry in `.local/setup/config.json`'s `agents` object. If setup does not have -`schema_version: 4`, or that canonical agent entry does not have foundation -steps `SETUP-01`, `SETUP-02.1`, `SETUP-03`, `SETUP-04`, and `SETUP-07` in -`done` state, show the message below and STOP. Do not require aggregate `connect_ready`; -capacity and the product connection itself may still need attention. +`schema_version: 4`, or that canonical agent entry does not have +`authoring_ready: true`, show the message below and STOP. Ignore +`connect_ready`, `active_step`, blocked capacity, and blocked connection steps +for this admission check; capacity and the product connection itself may still +need attention. > Welcome to the ESS Maker Kit. Before running `/connect`, type `/setup` to set up your environment. @@ -31,6 +32,16 @@ Rules: 3. The ONLY text the user sees is Message blocks and tool output tables. 4. Do not compose your own messages. If there is no Message block for a situation, stay silent and proceed to the next action. +5. For resumable product-specific connect flows, inspect current live and + durable status before every step. Skip completed steps with the skill's + defined skip message instead of repeating their question or operation, but + only when current completion is API-verifiable or proven by a successful + kit operation. Always ask the maker to confirm steps whose current state + cannot be verified through an available API. +6. Waiting for maker input or a portal update is not task completion. Keep the + current question as the resume point, never treat an unavailable-user + auto-response as an answer, and never require the maker to invoke + `/connect` again merely to continue that question. After reading SKILL.md, your first action is to check for `.local/connect/steps.md`. If starting fresh, your first message to the user diff --git a/solutions/ess-maker-skills/scripts/connect_servicenow_da.py b/solutions/ess-maker-skills/scripts/connect_servicenow_da.py index b15f6e6c2..64b12ab43 100644 --- a/solutions/ess-maker-skills/scripts/connect_servicenow_da.py +++ b/solutions/ess-maker-skills/scripts/connect_servicenow_da.py @@ -298,6 +298,21 @@ def summarize_components(components: dict[str, Any]) -> dict[str, Any]: flow_ids.add(flow_id) parameters = _shared_parameters(reference) + topic_summaries = sorted( + ( + { + "id": change["component"].get("id"), + "version": change["component"].get("version"), + "displayName": change["component"].get("displayName"), + "schemaName": change["component"].get("schemaName"), + "state": change["component"].get("state"), + "status": change["component"].get("status"), + } + for change in service_now_topics + if isinstance(change.get("component"), dict) + ), + key=lambda item: str(item.get("displayName") or ""), + ) return { "botComponentCount": len(components.get("botComponentChanges") or []), "serviceNowTopicCount": len(service_now_topics), @@ -307,6 +322,7 @@ def summarize_components(components: dict[str, Any]) -> dict[str, Any]: if isinstance(change.get("component"), dict) and change["component"].get("state") == "Active" ), + "serviceNowTopics": topic_summaries, "connectionReferenceCount": len( components.get("connectionReferenceChanges") or [] ), @@ -504,29 +520,98 @@ def connection_summary(record: dict[str, Any]) -> dict[str, Any]: } -def build_reference_update_payload( +def find_servicenow_topic( components: dict[str, Any], - connection_id: str, + topic_id: str, ) -> dict[str, Any]: - normalized_connection_id = uuid.UUID(connection_id).hex + normalized_topic_id = str(uuid.UUID(topic_id)) + for change in components.get("botComponentChanges") or []: + component = change.get("component") + if ( + isinstance(component, dict) + and str(component.get("id") or "").casefold() + == normalized_topic_id.casefold() + and "ServiceNowHRSD" in str(component.get("schemaName") or "") + ): + return component + raise ServiceNowConnectError( + "The requested ServiceNow topic was not found in the active agent." + ) + + +def build_topic_state_update_payload( + components: dict[str, Any], + topic_id: str, + target_state: str, +) -> dict[str, Any]: + if target_state not in {"Active", "Inactive"}: + raise ServiceNowConnectError( + "Topic state must be Active or Inactive." + ) change_token = components.get("changeToken") if not isinstance(change_token, str) or not change_token: raise ServiceNowConnectError( "MinimalBot component state has no concurrency change token." ) - reference = copy.deepcopy(find_servicenow_reference(components)) - reference["connectionId"] = normalized_connection_id + component = copy.deepcopy(find_servicenow_topic(components, topic_id)) + if component.get("$kind") != "DialogComponent": + raise ServiceNowConnectError( + "The requested ServiceNow component is not a dialog topic." + ) + if not component.get("id") or not isinstance(component.get("version"), int): + raise ServiceNowConnectError( + "The requested ServiceNow topic lacks identity or version evidence." + ) + component["state"] = target_state + component["status"] = target_state return { "changeToken": change_token, - "connectionReferenceChanges": [ + "botComponentChanges": [ { - "$kind": "ConnectionReferenceUpdate", - "connectionReference": reference, + "$kind": "BotComponentUpdate", + "component": component, } ], } +def build_enable_all_topics_payload( + components: dict[str, Any], +) -> dict[str, Any]: + change_token = components.get("changeToken") + if not isinstance(change_token, str) or not change_token: + raise ServiceNowConnectError( + "MinimalBot component state has no concurrency change token." + ) + changes = [] + for change in components.get("botComponentChanges") or []: + component = change.get("component") + if ( + not isinstance(component, dict) + or "ServiceNowHRSD" not in str(component.get("schemaName") or "") + or ( + component.get("state") == "Active" + and component.get("status") == "Active" + ) + ): + continue + topic_id = component.get("id") + if not topic_id: + raise ServiceNowConnectError( + "A ServiceNow topic lacks identity evidence." + ) + topic_payload = build_topic_state_update_payload( + components, + str(topic_id), + "Active", + ) + changes.extend(topic_payload["botComponentChanges"]) + return { + "changeToken": change_token, + "botComponentChanges": changes, + } + + def _component_hash(components: dict[str, Any]) -> str: encoded = json.dumps( components, @@ -596,6 +681,143 @@ def _state_base( } +def _state_for_components( + context: dict[str, Any], + components: dict[str, Any], +) -> dict[str, Any]: + state_path = _state_path(context["agent"]["id"]) + state = _load_json(state_path) if state_path.exists() else {} + state.update(_state_base(context, components)) + return state + + +def _inspection_progress( + state: dict[str, Any], + result: dict[str, Any], +) -> dict[str, dict[str, str]]: + components = result["components"] + connections = result.get("connectivity", {}).get("connections", []) + connected = { + str(connection.get("connectionId")): connection + for connection in connections + if connection.get("status") == "Connected" + and connection.get("authMode") == "entraIDUserLogin" + } + steps = state.get("steps") + if not isinstance(steps, dict): + steps = {} + + total_topics = components["serviceNowTopicCount"] + active_topics = components["activeServiceNowTopicCount"] + topics_done = ( + total_topics > 0 and active_topics == total_topics + ) or steps.get("topics") == "done" + + attestation = state.get("agentConnection") + if not isinstance(attestation, dict): + attestation = {} + attested_connection_id = str(attestation.get("connectionId") or "") + agent_connection_done = ( + attestation.get("makerAttested") is True + and attested_connection_id in connected + ) + + publish_record = state.get("publish") + if not isinstance(publish_record, dict): + publish_record = {} + published_hash = publish_record.get("componentHash") + current_hash = state.get("componentHash") + publish_done = ( + steps.get("publish") == "done" + and ( + published_hash == current_hash + or published_hash is None + ) + ) + + test_record = state.get("test") + if not isinstance(test_record, dict): + test_record = {} + test_result = test_record.get("result") + + parameter_record = state.get("parameterSharing") + if not isinstance(parameter_record, dict): + parameter_record = {} + parameter_status = parameter_record.get("status") + return { + "topics": { + "status": "done" if topics_done else "pending", + "message": ( + f"{active_topics}/{total_topics} ServiceNow HRSD topics are " + f"active." + ), + }, + "credential": { + "status": "done" if connected else "pending", + "message": ( + f"{len(connected)} Connected Entra user-login ServiceNow " + "credential(s) found." + ), + }, + "agentConnection": { + "status": ( + "confirmation-required" + if connected + else "pending" + ), + "message": ( + "A prior maker attestation is recorded and the selected " + "credential is still Connected, but the current agent UI " + "binding requires maker confirmation." + if agent_connection_done + else ( + "Maker confirmation of the agent's ServiceNow row is " + "required." + ) + ), + }, + "parameterSharing": { + "status": ( + "confirmation-required" + if parameter_status in {"enabled", "not-exposed"} + else "pending" + ), + "message": ( + f"Parameter sharing was recorded as {parameter_status}; " + "the current UI state requires maker confirmation." + if parameter_status + else "Parameter-sharing availability has not been recorded." + ), + }, + "publish": { + "status": "done" if publish_done else "pending", + "message": ( + "The current component revision is recorded as published." + if publish_done + else "The current component revision is not recorded as published." + ), + }, + "test": { + "status": ( + "confirmation-required" + if test_result in {"pass", "fail"} + else "pending" + ), + "message": ( + "A passing Test pane result was previously recorded; a " + "current functional result requires maker confirmation." + if test_result == "pass" + else ( + "A failing Test pane result was previously recorded; a " + "current functional result requires maker confirmation." + ) + if test_result == "fail" + else "Functional Test pane validation has not been recorded." + ), + }, + } + + def inspect(context: dict[str, Any], *, offline: bool = False) -> dict[str, Any]: if offline: components = _load_json(context["snapshotPath"]) @@ -620,16 +842,6 @@ def inspect(context: dict[str, Any], *, offline: bool = False) -> dict[str, Any] connection_summary(record) for record in connectivity.list_connections() ] - referenced_connection = None - referenced_error = None - referenced_id = summary["reference"].get("connectionId") - if referenced_id: - try: - referenced_connection = connection_summary( - connectivity.get_connection(referenced_id) - ) - except ServiceNowConnectError as exc: - referenced_error = str(exc) result["connectivity"] = { "connector": { "displayName": connector_properties.get("displayName"), @@ -637,10 +849,14 @@ def inspect(context: dict[str, Any], *, offline: bool = False) -> dict[str, Any] "isCustomApi": connector_properties.get("isCustomApi"), }, "connections": connections, - "referencedConnection": referenced_connection, - "referencedConnectionError": referenced_error, + "userConnectionBinding": { + "mode": "maker-ui", + "automationAvailable": False, + "requiredDelegatedScope": "PowerVirtualAgents.Tokens.Read", + }, } - state = _state_base(context, components) + state = _state_for_components(context, components) + result["progress"] = _inspection_progress(state, result) state["lastInspection"] = result _write_json_atomic(_state_path(context["agent"]["id"]), state) return result @@ -661,10 +877,27 @@ def prepare_manual_connection( instance_name = instance_name or reference.get("instanceName") resource_uri = resource_uri or reference.get("resourceUri") if not instance_name or not resource_uri: - raise ServiceNowConnectError( - "Instance name and Entra resource URI are required." - ) - state = _state_base(context, components) + missing_fields = [] + if not instance_name: + missing_fields.append("instanceName") + if not resource_uri: + missing_fields.append("resourceUri") + return { + "status": "input-required", + "creationMode": "manual", + "agentId": context["agent"]["id"], + "environmentId": context["environment"]["id"], + "missingFields": missing_fields, + "knownValues": { + "instanceName": instance_name, + "resourceUri": resource_uri, + }, + "message": ( + "The installed connection reference does not contain every " + "value required to create the ServiceNow connection." + ), + } + state = _state_for_components(context, components) state["connection"] = { "connectionId": None, "displayName": ( @@ -676,13 +909,13 @@ def prepare_manual_connection( "resourceUri": resource_uri, "createdBySkill": False, } - state["steps"] = { - "connection": "maker-action-required", - "signIn": "maker-action-required", - "referenceBinding": "pending", - "publish": "pending", - "test": "pending", - } + steps = state.setdefault("steps", {}) + steps.setdefault("connection", "maker-action-required") + steps.setdefault("signIn", "maker-action-required") + steps.setdefault("agentConnection", "maker-action-required") + steps.setdefault("parameterSharing", "pending") + steps.setdefault("publish", "pending") + steps.setdefault("test", "pending") _write_json_atomic(_state_path(context["agent"]["id"]), state) return { "status": "maker-action-required", @@ -699,71 +932,245 @@ def prepare_manual_connection( "Choose Microsoft Entra ID User Login.", "Enter the Instance Name and Resource URI shown in this output.", "Select Sign in, complete authentication, then select Submit.", - "Wait until the status is Connected, then return to VS Code.", + "Wait until the credential status is Connected.", + "Return to Connection settings and select Connect for ServiceNow.", + "Choose the new connection and save the agent connection.", + "Return to VS Code after the ServiceNow row shows Connected.", ], "afterCompletion": { "command": "python scripts/connect_servicenow_da.py inspect", "expected": ( - "A ServiceNow connection with status Connected and auth mode " - "entraIDUserLogin." + "A ServiceNow credential with status Connected, followed by " + "maker confirmation that it was connected to the agent." ), }, } -def bind_reference( +def record_agent_connection_attestation( context: dict[str, Any], connection_id: str, +) -> dict[str, Any]: + connectivity = _connectivity_client(context) + physical = connection_summary(connectivity.get_connection(connection_id)) + if physical.get("status") != "Connected": + raise ServiceNowConnectError( + "The physical ServiceNow connection is not Connected." + ) + + normalized_connection_id = uuid.UUID(connection_id).hex + state_path = _state_path(context["agent"]["id"]) + if state_path.exists(): + state = _load_json(state_path) + else: + components = _agentbuilder_client(context).fetch_components( + context["agent"]["id"] + ) + state = _state_base(context, components) + state["agentConnection"] = { + "mode": "maker-ui", + "connectionId": normalized_connection_id, + "displayName": physical.get("displayName"), + "physicalStatus": physical.get("status"), + "authMode": physical.get("authMode"), + "makerAttested": True, + "recordedAt": _utc_now(), + } + state.setdefault("steps", {})["agentConnection"] = "done" + state["updatedAt"] = _utc_now() + _write_json_atomic(state_path, state) + return state["agentConnection"] + + +def record_parameter_sharing( + context: dict[str, Any], + status: str, +) -> dict[str, Any]: + if status not in {"enabled", "not-exposed"}: + raise ServiceNowConnectError( + "Parameter-sharing status must be enabled or not-exposed." + ) + state_path = _state_path(context["agent"]["id"]) + if state_path.exists(): + state = _load_json(state_path) + else: + components = _agentbuilder_client(context).fetch_components( + context["agent"]["id"] + ) + state = _state_base(context, components) + result = { + "status": status, + "makerAttested": True, + "recordedAt": _utc_now(), + } + state["parameterSharing"] = result + state.setdefault("steps", {})["parameterSharing"] = "done" + state["updatedAt"] = _utc_now() + _write_json_atomic(state_path, state) + return result + + +def set_topic_state( + context: dict[str, Any], + topic_id: str, + target_state: str, *, confirmed: bool, ) -> dict[str, Any]: if not confirmed: raise ServiceNowConnectError( - "Reference binding requires explicit confirmation (--yes)." + "Topic state mutation requires explicit confirmation (--yes)." ) - connectivity = _connectivity_client(context) - physical = connection_summary(connectivity.get_connection(connection_id)) - if physical.get("status") != "Connected": + agentbuilder = _agentbuilder_client(context) + before = agentbuilder.fetch_components(context["agent"]["id"]) + before_topic = find_servicenow_topic(before, topic_id) + before_state = before_topic.get("state") + before_status = before_topic.get("status") + changed = ( + before_state != target_state + or before_status != target_state + ) + if changed: + payload = build_topic_state_update_payload( + before, + topic_id, + target_state, + ) + agentbuilder.update_components(context["agent"]["id"], payload) + after = agentbuilder.fetch_components(context["agent"]["id"]) + after_topic = find_servicenow_topic(after, topic_id) + if ( + after_topic.get("state") != target_state + or after_topic.get("status") != target_state + ): raise ServiceNowConnectError( - "The physical ServiceNow connection is not Connected." + "MinimalBot update completed without the expected topic state." ) + return { + "topicId": str(uuid.UUID(topic_id)), + "displayName": after_topic.get("displayName"), + "schemaName": after_topic.get("schemaName"), + "previousState": before_state, + "previousStatus": before_status, + "state": after_topic.get("state"), + "status": after_topic.get("status"), + "previousVersion": before_topic.get("version"), + "version": after_topic.get("version"), + "changed": changed, + "published": False, + } + +def enable_all_servicenow_topics( + context: dict[str, Any], + *, + confirmed: bool, +) -> dict[str, Any]: + if not confirmed: + raise ServiceNowConnectError( + "Enabling all ServiceNow topics requires explicit confirmation " + "(--yes)." + ) agentbuilder = _agentbuilder_client(context) before = agentbuilder.fetch_components(context["agent"]["id"]) before_summary = summarize_components(before) - normalized_connection_id = uuid.UUID(connection_id).hex - if before_summary["reference"].get("connectionId") == normalized_connection_id: - after = before - changed = False - else: - payload = build_reference_update_payload(before, normalized_connection_id) + inactive = [ + topic + for topic in before_summary["serviceNowTopics"] + if topic.get("state") != "Active" + or topic.get("status") != "Active" + ] + if inactive: + payload = build_enable_all_topics_payload(before) agentbuilder.update_components(context["agent"]["id"], payload) - after = agentbuilder.fetch_components(context["agent"]["id"]) - changed = True + after = agentbuilder.fetch_components(context["agent"]["id"]) after_summary = summarize_components(after) - if after_summary["reference"].get("connectionId") != normalized_connection_id: + not_active = [ + topic + for topic in after_summary["serviceNowTopics"] + if topic.get("state") != "Active" + or topic.get("status") != "Active" + ] + if not_active: + names = ", ".join( + str(topic.get("displayName") or topic.get("id")) + for topic in not_active + ) raise ServiceNowConnectError( - "MinimalBot update completed without the expected connection binding." + "MinimalBot update completed, but these ServiceNow topics " + f"remained inactive: {names}." ) - + result = { + "status": "updated" if inactive else "already-active", + "customerChoice": "enable-all", + "before": { + "total": before_summary["serviceNowTopicCount"], + "active": before_summary["activeServiceNowTopicCount"], + "inactive": len(inactive), + }, + "changedTopics": [ + { + "id": topic.get("id"), + "displayName": topic.get("displayName"), + "previousState": topic.get("state"), + "previousStatus": topic.get("status"), + } + for topic in inactive + ], + "after": { + "total": after_summary["serviceNowTopicCount"], + "active": after_summary["activeServiceNowTopicCount"], + "inactive": len(not_active), + }, + "published": False, + } state_path = _state_path(context["agent"]["id"]) state = _load_json(state_path) if state_path.exists() else _state_base( context, before, ) - state["referenceBinding"] = { - "referenceId": after_summary["reference"].get("id"), - "previousConnectionId": before_summary["reference"].get("connectionId"), - "connectionId": normalized_connection_id, - "changedBySkill": changed, - "verifiedAt": _utc_now(), - } - steps = state.setdefault("steps", {}) - steps["referenceBinding"] = "done" + state["topicEnablement"] = result + state.setdefault("steps", {})["topics"] = "done" state["componentHash"] = _component_hash(after) state["updatedAt"] = _utc_now() _write_json_atomic(state_path, state) - return state + return result + + +def record_keep_current_topic_choice( + context: dict[str, Any], +) -> dict[str, Any]: + components = _agentbuilder_client(context).fetch_components( + context["agent"]["id"] + ) + summary = summarize_components(components) + counts = { + "total": summary["serviceNowTopicCount"], + "active": summary["activeServiceNowTopicCount"], + "inactive": ( + summary["serviceNowTopicCount"] + - summary["activeServiceNowTopicCount"] + ), + } + result = { + "status": "recorded", + "customerChoice": "keep-current", + "before": counts, + "changedTopics": [], + "after": counts, + "published": False, + } + state_path = _state_path(context["agent"]["id"]) + state = _load_json(state_path) if state_path.exists() else _state_base( + context, + components, + ) + state["topicEnablement"] = result + state.setdefault("steps", {})["topics"] = "done" + state["componentHash"] = _component_hash(components) + state["updatedAt"] = _utc_now() + _write_json_atomic(state_path, state) + return result def publish( @@ -775,17 +1182,14 @@ def publish( raise ServiceNowConnectError( "Publishing requires explicit confirmation (--yes)." ) - response = _agentbuilder_client(context).publish_agent( - context["agent"]["id"] - ) + agentbuilder = _agentbuilder_client(context) + components = agentbuilder.fetch_components(context["agent"]["id"]) + response = agentbuilder.publish_agent(context["agent"]["id"]) state_path = _state_path(context["agent"]["id"]) - state = _load_json(state_path) if state_path.exists() else { - "schemaVersion": 1, - "agentId": context["agent"]["id"], - "environmentId": context["environment"]["id"], - } + state = _state_for_components(context, components) state["publish"] = { "completedAt": _utc_now(), + "componentHash": _component_hash(components), "responseKeys": sorted(response.keys()), } steps = state.setdefault("steps", {}) @@ -795,6 +1199,42 @@ def publish( return state +def record_test_attestation( + context: dict[str, Any], + *, + prompt: str, + result: str, + details: str | None, +) -> dict[str, Any]: + prompt = prompt.strip() + if not prompt: + raise ServiceNowConnectError("A Test pane prompt is required.") + if result not in {"pass", "fail"}: + raise ServiceNowConnectError( + "Test pane result must be pass or fail." + ) + state_path = _state_path(context["agent"]["id"]) + state = _load_json(state_path) if state_path.exists() else { + "schemaVersion": 1, + "intent": "DA-GA ServiceNow HRSD connection", + "agentId": context["agent"]["id"], + "environmentId": context["environment"]["id"], + } + attestation = { + "prompt": prompt, + "result": result, + "details": details.strip() if details else None, + "recordedAt": _utc_now(), + } + state["test"] = attestation + state.setdefault("steps", {})["test"] = ( + "done" if result == "pass" else "failed" + ) + state["updatedAt"] = _utc_now() + _write_json_atomic(state_path, state) + return attestation + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Prototype DA-GA ServiceNow HRSD connection setup.", @@ -819,18 +1259,66 @@ def build_parser() -> argparse.ArgumentParser: create_parser.add_argument("--resource-uri") create_parser.add_argument("--display-name") - bind_parser = subparsers.add_parser( - "bind", - help="Bind a Connected physical connection to the DA reference.", + agent_connection_parser = subparsers.add_parser( + "record-agent-connection", + help="Record the maker's manual Connect action after health validation.", + ) + agent_connection_parser.add_argument("--connection-id", required=True) + parameter_parser = subparsers.add_parser( + "record-parameter-sharing", + help="Record the maker-observed parameter-sharing state.", + ) + parameter_parser.add_argument( + "--status", + required=True, + choices=("enabled", "not-exposed"), + ) + + topic_state_parser = subparsers.add_parser( + "set-topic-state", + help="Set one ServiceNow topic to Active or Inactive.", + ) + topic_state_parser.add_argument("--topic-id", required=True) + topic_state_parser.add_argument( + "--state", + choices=("active", "inactive"), + required=True, + ) + topic_state_parser.add_argument("--yes", action="store_true") + + enable_all_parser = subparsers.add_parser( + "enable-all-topics", + help="Enable every ServiceNow HRSD topic after customer confirmation.", + ) + enable_all_parser.add_argument("--yes", action="store_true") + + topic_choice_parser = subparsers.add_parser( + "record-topic-choice", + help="Record the customer's decision to keep current topic states.", + ) + topic_choice_parser.add_argument( + "--choice", + choices=("keep-current",), + required=True, ) - bind_parser.add_argument("--connection-id", required=True) - bind_parser.add_argument("--yes", action="store_true") publish_parser = subparsers.add_parser( "publish", help="Publish the active Dev agent.", ) publish_parser.add_argument("--yes", action="store_true") + + test_parser = subparsers.add_parser( + "record-test", + help="Record the maker's ServiceNow HRSD Test pane attestation.", + ) + test_parser.add_argument("--prompt", required=True) + test_parser.add_argument( + "--result", + choices=("pass", "fail"), + required=True, + ) + test_parser.add_argument("--details") return parser @@ -847,14 +1335,36 @@ def main(argv: list[str] | None = None) -> int: resource_uri=args.resource_uri, display_name=args.display_name, ) - elif args.command == "bind": - result = bind_reference( + elif args.command == "record-agent-connection": + result = record_agent_connection_attestation( context, args.connection_id, + ) + elif args.command == "record-parameter-sharing": + result = record_parameter_sharing(context, args.status) + elif args.command == "set-topic-state": + result = set_topic_state( + context, + args.topic_id, + args.state.title(), confirmed=args.yes, ) + elif args.command == "enable-all-topics": + result = enable_all_servicenow_topics( + context, + confirmed=args.yes, + ) + elif args.command == "record-topic-choice": + result = record_keep_current_topic_choice(context) elif args.command == "publish": result = publish(context, confirmed=args.yes) + elif args.command == "record-test": + result = record_test_attestation( + context, + prompt=args.prompt, + result=args.result, + details=args.details, + ) else: # pragma: no cover raise ServiceNowConnectError("Unsupported command.") except (ServiceNowConnectError, AgentBuilderError, ValueError) as exc: diff --git a/solutions/ess-maker-skills/scripts/setup_existing_da.py b/solutions/ess-maker-skills/scripts/setup_existing_da.py index 7d1577cd8..e008128ea 100644 --- a/solutions/ess-maker-skills/scripts/setup_existing_da.py +++ b/solutions/ess-maker-skills/scripts/setup_existing_da.py @@ -56,6 +56,13 @@ "SETUP-06", "SETUP-07", ) +AUTHORING_READY_STEPS = ( + "SETUP-01", + "SETUP-02.1", + "SETUP-03", + "SETUP-04", + "SETUP-07", +) SETUP_STEP_NOTES = { "SETUP-01": ( "Records locked environment identity, endpoint, and DA foundation " @@ -442,6 +449,7 @@ def _load_canonical_setup_state( raise ExistingDASetupError( "Canonical DA setup state is incomplete or malformed." ) + _normalize_authoring_ready_markers(state) for agent_id, agent_state in state["agents"].items(): _validate_canonical_agent_state(agent_id, agent_state) return state @@ -457,6 +465,7 @@ def _validate_canonical_agent_state( "workspace", "steps", "active_step", + "authoring_ready", "connect_ready", "open_issues", "created_at", @@ -528,6 +537,18 @@ def _validate_canonical_agent_state( "active step." ) connect_ready = agent_state.get("connect_ready") + authoring_ready = agent_state.get("authoring_ready") + expected_authoring_ready = _is_authoring_ready(steps, workspace) + if not isinstance(authoring_ready, bool): + raise ExistingDASetupError( + f"Canonical DA setup state for agent {agent_id} has an invalid " + "authoring-ready marker." + ) + if authoring_ready != expected_authoring_ready: + raise ExistingDASetupError( + "Canonical DA authoring readiness does not match its foundation " + "step and workspace results." + ) all_steps_done = all( steps[step_id].get("state") == "done" for step_id in SETUP_STEP_ORDER @@ -559,6 +580,43 @@ def _validate_canonical_agent_state( ) +def _is_authoring_ready( + steps: dict[str, Any], + workspace: dict[str, Any], +) -> bool: + return ( + all( + isinstance(steps.get(step_id), dict) + and steps[step_id].get("state") == "done" + for step_id in AUTHORING_READY_STEPS + ) + and bool(workspace.get("folder")) + and bool(workspace.get("agent_path")) + ) + + +def _normalize_authoring_ready_markers(state: dict[str, Any]) -> bool: + changed = False + agents = state.get("agents") + if not isinstance(agents, dict): + return changed + for agent_state in agents.values(): + if ( + not isinstance(agent_state, dict) + or "authoring_ready" in agent_state + ): + continue + steps = agent_state.get("steps") + workspace = agent_state.get("workspace") + if isinstance(steps, dict) and isinstance(workspace, dict): + agent_state["authoring_ready"] = _is_authoring_ready( + steps, + workspace, + ) + changed = True + return changed + + def _canonical_environment_matches_connection( state: dict[str, Any], connection: dict[str, Any], @@ -719,6 +777,10 @@ def _build_canonical_setup_progress( "workspace": existing.get("workspace", {}) if existing else {}, "steps": steps, "active_step": _next_setup_step(steps), + "authoring_ready": _is_authoring_ready( + steps, + existing.get("workspace", {}) if existing else {}, + ), "connect_ready": False, "open_issues": list(existing.get("open_issues", [])) if existing else [], "created_at": existing.get("created_at", now) if existing else now, @@ -769,6 +831,22 @@ def _store_canonical_agent_state( return updated +def sync_authoring_readiness(kit_root: Path) -> dict[str, Any]: + state = _load_canonical_setup_state(kit_root) + if state is None: + raise ExistingDASetupError( + "Canonical DA setup state is unavailable. Run /setup first." + ) + _write_json(kit_root / CANONICAL_SETUP_STATE, state) + return { + "schemaVersion": state["schema_version"], + "agents": { + agent_id: agent_state["authoring_ready"] + for agent_id, agent_state in state["agents"].items() + }, + } + + def _matching_flightcheck_rows( checkpoint: str, results: list[dict[str, Any]], @@ -894,6 +972,10 @@ def maintain_setup_flightcheck( ) agent_state["active_step"] = _next_setup_step(agent_state["steps"]) + agent_state["authoring_ready"] = _is_authoring_ready( + agent_state["steps"], + agent_state["workspace"], + ) agent_state["connect_ready"] = all( agent_state["steps"][candidate]["state"] == "done" for candidate in SETUP_STEP_ORDER @@ -911,6 +993,7 @@ def maintain_setup_flightcheck( "failureCauses": list( agent_state["steps"][step_id].get("failure_causes", []) ), + "authoringReady": agent_state["authoring_ready"], "connectReady": agent_state["connect_ready"], "activeStep": agent_state["active_step"], } @@ -956,6 +1039,7 @@ def _record_canonical_setup_blocked( recorded_at=now, ) agent_state["active_step"] = _next_setup_step(agent_state["steps"]) + agent_state["authoring_ready"] = False agent_state["connect_ready"] = False agent_state["updated_at"] = now agent_state["completed_at"] = None @@ -1045,6 +1129,10 @@ def _record_canonical_setup_ready( note=SETUP_STEP_NOTES["SETUP-07"], recorded_at=now, ) + agent_state["authoring_ready"] = _is_authoring_ready( + agent_state["steps"], + agent_state["workspace"], + ) agent_state["connect_ready"] = all( agent_state["steps"][step_id]["state"] == "done" for step_id in SETUP_STEP_ORDER @@ -1900,6 +1988,11 @@ def select_local_agent( "agentName": str(selected.get("name") or normalized_agent_id), "activeAgent": slug, "workspaceFolder": selected.get("folder"), + "authoringReady": ( + setup_state.get("authoring_ready") + if isinstance(setup_state, dict) + else None + ), "connectReady": ( setup_state.get("connect_ready") if isinstance(setup_state, dict) @@ -2298,6 +2391,7 @@ def attach_existing_dev( "connectionStatus": "workspace-ready", "workspace": workspace, "setupState": CANONICAL_SETUP_STATE.as_posix(), + "authoringReady": canonical_agent["authoring_ready"], "connectReady": canonical_agent["connect_ready"], } if cleanup_warnings: @@ -2411,6 +2505,15 @@ def build_parser() -> argparse.ArgumentParser: type=Path, default=Path.cwd(), ) + sync_readiness = commands.add_parser( + "sync-authoring-readiness", + help="Persist the derived authoring-ready marker in canonical state.", + ) + sync_readiness.add_argument( + "--kit-root", + type=Path, + default=Path.cwd(), + ) select_agent = commands.add_parser( "select-agent", help="Select one already configured local agent. No remote operations.", @@ -2541,6 +2644,13 @@ def main(argv: list[str] | None = None) -> int: f"{json.dumps(result, ensure_ascii=True)}" ) return 0 if result["state"] == "done" else 1 + if args.command == "sync-authoring-readiness": + result = sync_authoring_readiness(args.kit_root.resolve()) + print( + "DA_AUTHORING_READINESS_JSON:" + f"{json.dumps(result, ensure_ascii=True)}" + ) + return 0 if args.command == "select-agent": result = select_local_agent( args.kit_root.resolve(), diff --git a/solutions/ess-maker-skills/src/skills/connect/servicenow-da/SKILL.md b/solutions/ess-maker-skills/src/skills/connect/servicenow-da/SKILL.md index ac4cd293c..b39e1f838 100644 --- a/solutions/ess-maker-skills/src/skills/connect/servicenow-da/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/connect/servicenow-da/SKILL.md @@ -1,10 +1,54 @@ -# Connect ServiceNow HRSD to a DA-GA HR Agent +z# Connect ServiceNow HRSD to a DA-GA HR Agent This prototype uses the DA foundation handoff, MinimalBot Components, and the Power Platform Connectivity API. It does not query Dataverse or activate cloud flows. In the GA HR package, ServiceNow HRSD topics use direct connector actions; the packaged flows are Workday-only. +## Resume and interaction contract + +This is one resumable interactive workflow. A question that requires maker +input pauses at that question; it does not complete the task and it does not +require the maker to invoke `/connect servicenow` again. + +- Run `inspect` at the beginning of every invocation and use its `progress` + object as the current step status. +- Before every step, check its current live evidence and durable status. +- Automatically skip a step only when its current state can be verified by an + API or by a successful kit operation tied to the current component revision. + When such a step is `done`, do not repeat its question or operation. Print + the corresponding skip message below and continue immediately. +- Agent UI binding, parameter-sharing controls, and Test pane behavior cannot + be verified by the available APIs. Always ask the maker to confirm their + current state, even when a prior local attestation exists. A prior + attestation may be shown as context but is never sufficient to skip the + current confirmation. +- Ask only for the first `pending` or `failed` step. +- A normal maker reply to the current question resumes this workflow at that + step. Never require another slash-command merely because the workflow was + waiting for input or a portal update. +- Never interpret a repeated `/connect servicenow` invocation as confirmation + of a previous question. Destructive or remote mutations still require the + explicit answer defined by their step. +- If the host reports that the maker is unavailable, treat that as no answer. + Do not select a choice, do not advance state, do not say **Task completed**, + and do not tell the maker to run the command again. Show only: + + > **Waiting for your response** + > + > Complete the requested Copilot Studio action or provide the requested + > value here. I will continue from this step when you reply. + +- Use **Task completed** only after a passing Test pane result has been + recorded. A healthy credential, maker attestation, or successful publish is + progress, not completion. + +Skip messages: + +- Topics: `✓ ServiceNow topics are already prepared; skipping topic updates.` +- Credential: `✓ A healthy ServiceNow credential already exists; skipping credential creation.` +- Publish: `✓ The current ServiceNow component revision is already published; skipping publish.` + ## 1. Inspect Run: @@ -15,9 +59,10 @@ python scripts/connect_servicenow_da.py inspect If setup is not schema v4, has not established the environment, exact editable Dev agent, and local workspace, or is not the HR agent, show the returned error -and stop. Do not require aggregate `connect_ready`: setup can report the -ServiceNow connection as not configured, and this workflow exists to resolve -that condition. +and stop. Canonical setup uses `authoring_ready` for this foundation boundary. +Do not require aggregate `connect_ready`: setup can report the ServiceNow +connection as not configured, and this workflow exists to resolve that +condition. Summarize: @@ -25,21 +70,78 @@ Summarize: - ServiceNow connector-action count. - Physical connection count. - Whether the currently referenced connection exists and is Connected. +- The status and message for every entry in `progress`. Do not treat `/setup` `connect_ready: true` as integration readiness. +Do not treat the MinimalBot reference's connection ID as proof of the maker UI +agent binding. + +## 2. Ask whether to enable all ServiceNow topics + +If `progress.topics.status` is `done`, print the Topics skip message and +continue to step 3. + +Otherwise show every ServiceNow HRSD topic with its current `Active` or +`Inactive` state. Summarize the total, active, and inactive counts, then ask: + +> There are {INACTIVE_COUNT} inactive ServiceNow HRSD topics. Would you like +> to enable all ServiceNow HRSD topics? + +If the maker says no, do not mutate any topic or continue to physical +connection creation. Record the choice and unchanged counts: + +```text +python scripts/connect_servicenow_da.py record-topic-choice --choice keep-current +``` + +Report that the workflow is paused by the maker's topic choice. Do not call it +completed. + +If the maker says yes, show the exact inactive topic names one more time and +ask for final confirmation. After confirmation, run: + +```text +python scripts/connect_servicenow_da.py enable-all-topics --yes +``` + +The command sends `BotComponentUpdate` entries containing the complete fetched +`DialogComponent` objects, preserves identity, version, schema, parent +metadata, and dialog graphs, and changes `state` and `status` together. It +refetches and requires every ServiceNow HRSD topic to be `Active`. + +Do not include Workday or other integration topics. Do not publish during topic +preparation. Record the before counts, customer choice, changed topic IDs and +names, and verified after counts. -## 2. Guide the maker to create the physical connection +## 3. Guide the maker to create the physical connection -If exactly one matching Connected Entra user-login connection already exists, -reuse it. Otherwise run: +If `progress.credential.status` is `done`, print the Credential skip message, +select the single Connected Entra user-login credential when unambiguous, and +continue to step 4. If multiple Connected credentials exist, ask which one to +use. + +Otherwise run: ```powershell python scripts\connect_servicenow_da.py create ``` This command is read-only. It derives the instance name and resource URI from -the installed ServiceNow connection-reference metadata and returns the exact -values plus these maker steps: +the installed ServiceNow connection-reference metadata when available. + +If the command returns `status: input-required`, ask the maker only for the +fields listed in `missingFields`: + +- **Instance Name**: the ServiceNow instance prefix, for example `contoso` + rather than `contoso.service-now.com`. +- **Resource URI**: the Microsoft Entra resource identifier configured for + this ServiceNow integration. + +Then rerun `create` with `--instance-name` and/or `--resource-uri` for the +missing values. Do not invent defaults or persist placeholder values. + +When all required values are known, the command returns the exact values plus +these maker steps: 1. Open Copilot Studio and select the target environment. 2. Open the Employee Self-Service HR agent. @@ -51,8 +153,9 @@ values plus these maker steps: 8. Select **Sign in**, complete authentication, and select **Submit**. 9. Wait for the status to become **Connected**, then return to VS Code. -Stop and wait for the maker to confirm completion. Do not request -`Connectivity.Connections.Write` and do not call a connection-create API. +Ask the maker for the current result and remain at this step until the maker +answers. Do not request `Connectivity.Connections.Write` and do not call a +connection-create API. After the maker confirms, rerun: @@ -61,46 +164,101 @@ python scripts\connect_servicenow_da.py inspect ``` If exactly one matching Connected Entra user-login connection is present, show -its connection ID and continue. If none exists, explain that the manual +its display name and continue. If none exists, explain that the manual connection is not Connected yet and repeat only the relevant UI step. If multiple matches exist, show their display names and IDs and ask the maker -which one to bind. +which one to use. -## 3. Bind the DA connection reference +## 4. Maker connects the credential to the agent -Only continue when the physical connection reports `Connected`. Show the -connection ID and the current reference ID, then ask for explicit confirmation. +Only continue when the physical connection reports `Connected`. Guide the +maker to return to **Settings** -> **Connection settings**, select +**Connect** for ServiceNow, choose the newly created connection, and save. +Disconnect uses the corresponding UI action. -After confirmation, run: +The UI uses: ```text -python scripts/connect_servicenow_da.py bind --connection-id --yes +POST /powervirtualagents/bots//channels/pva-studio/user-connections ``` -The command fetches a fresh change token, sends one -`ConnectionReferenceUpdate`, refetches the components, and verifies the new -connection ID. It persists only non-secret before/after evidence under: +with a `connectorBindings` payload. The endpoint and payload were captured +from a live UI request and returned `204`, but the ESS ADK OAuth client cannot +request its required `PowerVirtualAgents.Tokens.Read` delegated scope +(`AADSTS65002`: first-party preauthorization required). Do not call +MinimalBot `ConnectionReferenceUpdate` as a substitute and do not reuse a +browser token. + +Do not infer this confirmation from a repeated slash-command or from the +MinimalBot reference field. After the maker explicitly reports that the +ServiceNow row shows Connected, record the +manual action and independently verify that the selected physical connection +is still Connected: ```text -.local/connect/servicenow/agents//state.json +python scripts/connect_servicenow_da.py record-agent-connection --connection-id ``` -## 4. Maker-assisted OBO sharing +This command records non-secret maker attestation and physical health evidence +under `.local/connect/servicenow/agents//state.json`. It does not +claim API-level verification of the UI binding. The Test pane provides the +functional verification. -Guide the maker through Copilot Studio connection sharing and OBO -configuration. No supported automation API has been proven for this step. +## 5. Check whether OBO parameter sharing is exposed -## 5. Publish +Guide the maker to open **Settings** -> **Connection settings** -> the +ServiceNow connection -> **See details** -> **Connection parameters**. -Show the connection and reference changes. Publish only after explicit -confirmation: +This is a maker-confirmed step. Ask every time this workflow validates the +current connection, even when `progress.parameterSharing.status` is +`confirmation-required` because a prior observation was recorded. + +Some connections that support SSO expose **Allow permission to share +parameters**. If this option is present, explain that it lets an end user +authorize the agent to use the selected connection parameters on that user's +behalf. Ask the maker to enable it, select the required parameters, and save. + +If the page shows only the parameter values and does not expose the sharing +option, record `OBO parameter sharing: not exposed` and continue. Do not claim +that OBO sharing was configured, and do not block a connection that is already +reported as `Connected`. End users might still receive a consent or connection +prompt when they first use the connector. + +No supported automation API has been proven for this conditional step. + +After the maker answers, persist the observed status: + +```text +python scripts/connect_servicenow_da.py record-parameter-sharing --status enabled +python scripts/connect_servicenow_da.py record-parameter-sharing --status not-exposed +``` + +Run only the command matching the maker's answer. + +## 6. Publish + +If `progress.publish.status` is `done`, print the Publish skip message and +continue to step 7. + +Otherwise show the pending ServiceNow changes. Publish only after explicit +confirmation in the current question: ```text python scripts/connect_servicenow_da.py publish --yes ``` -## 6. Test +## 7. Test + +Ask the maker to run one HRSD request in the Copilot Studio Test pane. This is +a maker-confirmed step and must be asked during every validation because no API +can prove the current functional result. Record +the prompt and pass/fail attestation in the connector-owned state: + +```text +python scripts/connect_servicenow_da.py record-test --prompt "" --result --details "" +``` -Ask the maker to run one HRSD request in the Copilot Studio Test pane. Record -the prompt and pass/fail attestation in the connector-owned state. Do not claim -success from connection health alone. +Do not claim success from connection health alone. +After a failed result, report the failure and remain at the Test step for a +later corrected result. After a passing result is recorded, and only then, +report **Task completed**. diff --git a/tests/scripts/test_connect_servicenow_da.py b/tests/scripts/test_connect_servicenow_da.py index bac010d37..6d6917616 100644 --- a/tests/scripts/test_connect_servicenow_da.py +++ b/tests/scripts/test_connect_servicenow_da.py @@ -25,11 +25,15 @@ def _components(connection_id: str | None = None) -> dict: "$kind": "BotComponentInsert", "component": { "$kind": "DialogComponent", + "id": "00000000-0000-4000-8000-000000005555", + "version": 7, + "displayName": "ServiceNow HRSD Common Orchestrator", "schemaName": ( f"{snow.HR_SCHEMA_NAME}.topic." "ServiceNowHRSDSystemCommonOrchestrator" ), "state": "Active", + "status": "Active", "dialog": { "$kind": "TaskDialog", "action": { @@ -89,6 +93,9 @@ def test_summarize_components_separates_servicenow_from_workday_flows() -> None: assert result["serviceNowTopicCount"] == 1 assert result["activeServiceNowTopicCount"] == 1 + assert result["serviceNowTopics"][0]["displayName"] == ( + "ServiceNow HRSD Common Orchestrator" + ) assert result["serviceNowInvokeConnectorActionCount"] == 1 assert result["serviceNowInvokeFlowActionCount"] == 0 assert result["invokeFlowActionCount"] == 1 @@ -97,29 +104,245 @@ def test_summarize_components_separates_servicenow_from_workday_flows() -> None: assert result["reference"]["resourceUri"] == "resource-id" -def test_reference_update_is_minimal_and_preserves_reference_metadata() -> None: +def test_topic_state_update_replays_full_dialog_component() -> None: components = _components() + topic = components["botComponentChanges"][0]["component"] - payload = snow.build_reference_update_payload( + payload = snow.build_topic_state_update_payload( components, - CONNECTION_ID, + topic["id"], + "Inactive", ) - assert set(payload) == {"changeToken", "connectionReferenceChanges"} + assert set(payload) == {"changeToken", "botComponentChanges"} assert payload["changeToken"] == "token" - change = payload["connectionReferenceChanges"][0] - assert change["$kind"] == "ConnectionReferenceUpdate" - assert change["connectionReference"]["connectionId"] == ( - CONNECTION_ID.replace("-", "") + change = payload["botComponentChanges"][0] + assert change["$kind"] == "BotComponentUpdate" + updated = change["component"] + assert updated["id"] == topic["id"] + assert updated["version"] == 7 + assert updated["schemaName"] == topic["schemaName"] + assert updated["dialog"] == topic["dialog"] + assert updated["state"] == "Inactive" + assert updated["status"] == "Inactive" + assert topic["state"] == "Active" + assert topic["status"] == "Active" + + +def test_topic_state_update_requires_change_token() -> None: + components = _components() + del components["changeToken"] + topic_id = components["botComponentChanges"][0]["component"]["id"] + + with pytest.raises(snow.ServiceNowConnectError, match="change token"): + snow.build_topic_state_update_payload( + components, + topic_id, + "Inactive", + ) + + +def test_topic_state_mutation_requires_confirmation() -> None: + with pytest.raises(snow.ServiceNowConnectError, match="confirmation"): + snow.set_topic_state( + {}, + "00000000-0000-4000-8000-000000005555", + "Inactive", + confirmed=False, + ) + + +def test_enable_all_topics_payload_contains_only_inactive_servicenow_topics() -> None: + components = _components() + topic = components["botComponentChanges"][0]["component"] + topic["state"] = "Inactive" + topic["status"] = "Inactive" + + payload = snow.build_enable_all_topics_payload(components) + + assert payload["changeToken"] == "token" + assert len(payload["botComponentChanges"]) == 1 + change = payload["botComponentChanges"][0] + assert change["$kind"] == "BotComponentUpdate" + assert change["component"]["id"] == topic["id"] + assert change["component"]["state"] == "Active" + assert change["component"]["status"] == "Active" + + +def test_enable_all_topics_refetches_and_verifies( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + components = _components() + topic = components["botComponentChanges"][0]["component"] + topic["state"] = "Inactive" + topic["status"] = "Inactive" + + class FakeAgentBuilder: + def fetch_components(self, _agent_id: str) -> dict: + return json.loads(json.dumps(components)) + + def update_components( + self, + _agent_id: str, + payload: dict, + ) -> dict: + update = payload["botComponentChanges"][0]["component"] + components["botComponentChanges"][0]["component"] = update + components["changeToken"] = "token-2" + return {} + + monkeypatch.setattr( + snow, + "_agentbuilder_client", + lambda _context: FakeAgentBuilder(), ) - assert change["connectionReference"]["version"] == 4 - assert ( - components["connectionReferenceChanges"][0]["connectionReference"][ - "connectionId" - ] - is None + monkeypatch.chdir(tmp_path) + + result = snow.enable_all_servicenow_topics( + { + "agent": { + "id": AGENT_ID, + "schema_name": snow.HR_SCHEMA_NAME, + }, + "environment": { + "id": ENVIRONMENT_ID, + "ring": "test", + }, + }, + confirmed=True, ) + assert result["customerChoice"] == "enable-all" + assert result["before"] == {"total": 1, "active": 0, "inactive": 1} + assert result["after"] == {"total": 1, "active": 1, "inactive": 0} + assert result["changedTopics"][0]["id"] == topic["id"] + assert result["published"] is False + + +def test_enable_all_topics_requires_confirmation() -> None: + with pytest.raises(snow.ServiceNowConnectError, match="confirmation"): + snow.enable_all_servicenow_topics({}, confirmed=False) + + +def test_enable_all_topics_is_noop_when_all_are_active( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + components = _components() + + class FakeAgentBuilder: + def fetch_components(self, _agent_id: str) -> dict: + return components + + def update_components(self, _agent_id: str, payload: dict) -> dict: + raise AssertionError(f"Unexpected update: {payload}") + + monkeypatch.setattr( + snow, + "_agentbuilder_client", + lambda _context: FakeAgentBuilder(), + ) + monkeypatch.chdir(tmp_path) + + result = snow.enable_all_servicenow_topics( + { + "agent": { + "id": AGENT_ID, + "schema_name": snow.HR_SCHEMA_NAME, + }, + "environment": { + "id": ENVIRONMENT_ID, + "ring": "test", + }, + }, + confirmed=True, + ) + + assert result["status"] == "already-active" + assert result["changedTopics"] == [] + assert result["published"] is False + + +def test_keep_current_topic_choice_records_without_mutation( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + components = _components() + + class FakeAgentBuilder: + def fetch_components(self, _agent_id: str) -> dict: + return components + + def update_components(self, _agent_id: str, payload: dict) -> dict: + raise AssertionError(f"Unexpected update: {payload}") + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + snow, + "_agentbuilder_client", + lambda _context: FakeAgentBuilder(), + ) + + result = snow.record_keep_current_topic_choice( + { + "agent": { + "id": AGENT_ID, + "schema_name": snow.HR_SCHEMA_NAME, + }, + "environment": { + "id": ENVIRONMENT_ID, + "ring": "test", + }, + } + ) + + assert result["customerChoice"] == "keep-current" + assert result["changedTopics"] == [] + state = json.loads( + ( + tmp_path + / ".local" + / "connect" + / "servicenow" + / "agents" + / AGENT_ID + / "state.json" + ).read_text(encoding="utf-8") + ) + assert state["topicEnablement"]["customerChoice"] == "keep-current" + + +def test_enable_all_topics_fails_when_refetch_is_still_inactive( + monkeypatch: pytest.MonkeyPatch, +) -> None: + components = _components() + topic = components["botComponentChanges"][0]["component"] + topic["state"] = "Inactive" + topic["status"] = "Inactive" + + class FakeAgentBuilder: + def fetch_components(self, _agent_id: str) -> dict: + return components + + def update_components(self, _agent_id: str, payload: dict) -> dict: + return {} + + monkeypatch.setattr( + snow, + "_agentbuilder_client", + lambda _context: FakeAgentBuilder(), + ) + + with pytest.raises( + snow.ServiceNowConnectError, + match="remained inactive", + ): + snow.enable_all_servicenow_topics( + {"agent": {"id": AGENT_ID}}, + confirmed=True, + ) + def test_connection_summary_prefers_token_status() -> None: result = snow.connection_summary( @@ -241,9 +464,216 @@ def test_load_context_rejects_incomplete_foundation_step( snow.load_context(tmp_path) -def test_bind_requires_confirmation() -> None: - with pytest.raises(snow.ServiceNowConnectError, match="confirmation"): - snow.bind_reference({}, CONNECTION_ID, confirmed=False) +def test_record_agent_connection_validates_health_and_persists_attestation( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + state_path = ( + tmp_path + / ".local" + / "connect" + / "servicenow" + / "agents" + / AGENT_ID + / "state.json" + ) + state_path.parent.mkdir(parents=True) + state_path.write_text( + json.dumps( + { + "schemaVersion": 1, + "agentId": AGENT_ID, + "environmentId": ENVIRONMENT_ID, + } + ), + encoding="utf-8", + ) + + class FakeConnectivity: + def get_connection(self, connection_id: str) -> dict: + assert connection_id == CONNECTION_ID + return { + "name": CONNECTION_ID.replace("-", ""), + "properties": { + "displayName": "ServiceNow", + "statuses": [{"target": "token", "status": "Connected"}], + "connectionParametersSet": { + "name": "entraIDUserLogin", + }, + }, + } + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + snow, + "_connectivity_client", + lambda _context: FakeConnectivity(), + ) + + result = snow.record_agent_connection_attestation( + { + "agent": {"id": AGENT_ID}, + "environment": {"id": ENVIRONMENT_ID}, + }, + CONNECTION_ID, + ) + + assert result["connectionId"] == CONNECTION_ID.replace("-", "") + assert result["physicalStatus"] == "Connected" + assert result["makerAttested"] is True + state = json.loads(state_path.read_text(encoding="utf-8")) + assert state["steps"]["agentConnection"] == "done" + + +def test_inspect_preserves_progress_and_reports_completed_steps( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + state_path = ( + tmp_path + / ".local" + / "connect" + / "servicenow" + / "agents" + / AGENT_ID + / "state.json" + ) + state_path.parent.mkdir(parents=True) + state_path.write_text( + json.dumps( + { + "schemaVersion": 1, + "agentId": AGENT_ID, + "environmentId": ENVIRONMENT_ID, + "agentConnection": { + "connectionId": CONNECTION_ID.replace("-", ""), + "makerAttested": True, + }, + "parameterSharing": {"status": "not-exposed"}, + "publish": {"completedAt": "2026-09-24T00:00:00Z"}, + "test": {"result": "pass"}, + "steps": { + "topics": "done", + "agentConnection": "done", + "parameterSharing": "done", + "publish": "done", + "test": "done", + }, + } + ), + encoding="utf-8", + ) + + class FakeAgentBuilder: + def fetch_components(self, _agent_id: str) -> dict: + return _components(CONNECTION_ID) + + class FakeConnectivity: + def get_connector(self) -> dict: + return { + "properties": { + "displayName": "ServiceNow", + "tier": "Premium", + "isCustomApi": False, + } + } + + def list_connections(self) -> list[dict]: + return [ + { + "name": CONNECTION_ID.replace("-", ""), + "properties": { + "displayName": "maker@example.com", + "statuses": [ + {"target": "token", "status": "Connected"} + ], + "connectionParametersSet": { + "name": "entraIDUserLogin", + }, + }, + } + ] + + context = { + "agent": { + "id": AGENT_ID, + "schema_name": snow.HR_SCHEMA_NAME, + }, + "environment": { + "id": ENVIRONMENT_ID, + "ring": "test", + }, + } + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + snow, + "_agentbuilder_client", + lambda _context: FakeAgentBuilder(), + ) + monkeypatch.setattr( + snow, + "_connectivity_client", + lambda _context: FakeConnectivity(), + ) + + result = snow.inspect(context) + + assert result["progress"]["topics"]["status"] == "done" + assert result["progress"]["credential"]["status"] == "done" + assert result["progress"]["publish"]["status"] == "done" + assert ( + result["progress"]["agentConnection"]["status"] + == "confirmation-required" + ) + assert ( + result["progress"]["parameterSharing"]["status"] + == "confirmation-required" + ) + assert result["progress"]["test"]["status"] == "confirmation-required" + state = json.loads(state_path.read_text(encoding="utf-8")) + assert state["agentConnection"]["makerAttested"] is True + assert state["parameterSharing"]["status"] == "not-exposed" + assert state["test"]["result"] == "pass" + + +def test_record_parameter_sharing_persists_maker_observation( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + state_path = ( + tmp_path + / ".local" + / "connect" + / "servicenow" + / "agents" + / AGENT_ID + / "state.json" + ) + state_path.parent.mkdir(parents=True) + state_path.write_text( + json.dumps( + { + "schemaVersion": 1, + "agentId": AGENT_ID, + "environmentId": ENVIRONMENT_ID, + } + ), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + result = snow.record_parameter_sharing( + { + "agent": {"id": AGENT_ID}, + "environment": {"id": ENVIRONMENT_ID}, + }, + "not-exposed", + ) + + assert result["status"] == "not-exposed" + assert result["makerAttested"] is True + state = json.loads(state_path.read_text(encoding="utf-8")) + assert state["steps"]["parameterSharing"] == "done" def test_connectivity_scopes_are_read_only() -> None: @@ -301,3 +731,95 @@ def fetch_components(self, _agent_id: str) -> dict: "Connection settings" in instruction for instruction in result["instructions"] ) + + +def test_prepare_manual_connection_requests_missing_metadata( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + components = _components() + reference = components["connectionReferenceChanges"][0][ + "connectionReference" + ] + reference["connectionId"] = None + reference["sharedConnectionParameters"] = json.dumps( + { + "name": "entraIDUserLogin", + "values": { + "token:ResourceUri": {"value": None}, + "token:InstanceName": {"value": None}, + }, + } + ) + + class FakeAgentBuilder: + def fetch_components(self, _agent_id: str) -> dict: + return components + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + snow, + "_agentbuilder_client", + lambda _context: FakeAgentBuilder(), + ) + + result = snow.prepare_manual_connection( + { + "agent": { + "id": AGENT_ID, + "schema_name": snow.HR_SCHEMA_NAME, + }, + "environment": { + "id": ENVIRONMENT_ID, + "ring": "test", + }, + }, + instance_name=None, + resource_uri=None, + display_name=None, + ) + + assert result["status"] == "input-required" + assert result["missingFields"] == ["instanceName", "resourceUri"] + assert not ( + tmp_path + / ".local" + / "connect" + / "servicenow" + / "agents" + / AGENT_ID + / "state.json" + ).exists() + + +def test_record_test_attestation_persists_result( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.chdir(tmp_path) + context = { + "agent": {"id": AGENT_ID}, + "environment": {"id": ENVIRONMENT_ID}, + } + + result = snow.record_test_attestation( + context, + prompt="Show my HR cases", + result="pass", + details="Returned the case list.", + ) + + state = json.loads( + ( + tmp_path + / ".local" + / "connect" + / "servicenow" + / "agents" + / AGENT_ID + / "state.json" + ).read_text(encoding="utf-8") + ) + assert result["result"] == "pass" + assert state["test"]["prompt"] == "Show my HR cases" + assert state["steps"]["test"] == "done" diff --git a/tests/scripts/test_setup_existing_da.py b/tests/scripts/test_setup_existing_da.py index c9317b09c..faa93dead 100644 --- a/tests/scripts/test_setup_existing_da.py +++ b/tests/scripts/test_setup_existing_da.py @@ -618,6 +618,7 @@ def test_attach_materializes_complete_workspace(tmp_path: Path) -> None: setup_state = _agent_setup_state(tmp_path) assert canonical_state["schema_version"] == 4 assert canonical_state["environment"]["id"] == ENVIRONMENT_ID + assert setup_state["authoring_ready"] is False assert setup_state["connect_ready"] is False assert setup_state["active_step"] == "SETUP-02.1" assert set(setup_state["steps"]) == set( @@ -658,7 +659,9 @@ def test_same_environment_agents_keep_independent_state_and_folders( canonical = _setup_state(tmp_path) assert set(canonical["agents"]) == {AGENT_ID, OTHER_AGENT_ID} assert canonical["agents"][AGENT_ID] == first_ready + assert canonical["agents"][AGENT_ID]["authoring_ready"] is True assert canonical["agents"][AGENT_ID]["connect_ready"] is True + assert canonical["agents"][OTHER_AGENT_ID]["authoring_ready"] is False assert canonical["agents"][OTHER_AGENT_ID]["connect_ready"] is False second_slug = canonical["agents"][OTHER_AGENT_ID]["agent"]["workspace_slug"] assert second_slug == "employee-self-service-hr-00000000" @@ -1074,6 +1077,7 @@ def test_skipped_flightcheck_steps_reopen_on_attach( recorded_at=agent_state["created_at"], ) agent_state["active_step"] = "SETUP-07" + agent_state["authoring_ready"] = True agent_state["connect_ready"] = True agent_state["completed_at"] = agent_state["created_at"] state_path.write_text(json.dumps(state), encoding="utf-8") @@ -1100,6 +1104,7 @@ def test_flightcheck_maintenance_completes_setup(tmp_path: Path) -> None: loaded = setup_existing_da._load_canonical_setup_state(tmp_path) assert loaded is not None loaded_agent = loaded["agents"][AGENT_ID] + assert loaded_agent["authoring_ready"] is True assert loaded_agent["connect_ready"] is True assert loaded_agent["completed_at"] assert loaded_agent["steps"]["SETUP-02.1"]["checkpoint"] == "DA-AGENT-001" @@ -1187,6 +1192,47 @@ def test_not_configured_connection_blocks_setup(tmp_path: Path) -> None: assert loaded["agents"][AGENT_ID]["steps"]["SETUP-05"]["failure_causes"] +def test_authoring_ready_allows_connection_blocked_setup( + tmp_path: Path, +) -> None: + _attach(FakeClient(), tmp_path) + results_path = _write_flightcheck_results( + tmp_path, + "DA-AGENT-001", + "Passed", + ) + result = setup_existing_da.maintain_setup_flightcheck( + tmp_path, + agent_id=AGENT_ID, + checkpoint="DA-AGENT-001", + results_path=results_path, + ) + + assert result["authoringReady"] is True + assert result["connectReady"] is False + loaded = setup_existing_da._load_canonical_setup_state(tmp_path) + assert loaded is not None + agent_state = loaded["agents"][AGENT_ID] + assert agent_state["authoring_ready"] is True + assert agent_state["steps"]["SETUP-05"]["state"] == "pending" + + +def test_sync_authoring_readiness_migrates_schema_v4_state( + tmp_path: Path, +) -> None: + _attach(FakeClient(), tmp_path) + state_path = tmp_path / setup_existing_da.CANONICAL_SETUP_STATE + state = json.loads(state_path.read_text(encoding="utf-8")) + del state["agents"][AGENT_ID]["authoring_ready"] + state_path.write_text(json.dumps(state), encoding="utf-8") + + result = setup_existing_da.sync_authoring_readiness(tmp_path) + + assert result["schemaVersion"] == 4 + persisted = json.loads(state_path.read_text(encoding="utf-8")) + assert persisted["agents"][AGENT_ID]["authoring_ready"] is False + + def test_connect_ready_rejects_incomplete_steps(tmp_path: Path) -> None: _attach(FakeClient(), tmp_path) _complete_setup_flightchecks(tmp_path) @@ -1541,6 +1587,7 @@ def test_parser_exposes_only_composable_setup_operations() -> None: "list-agents", "maintain-flightcheck", "select-agent", + "sync-authoring-readiness", "validate-agent", } diff --git a/tests/setup/test_da_setup_router.py b/tests/setup/test_da_setup_router.py index a3d2f25cb..569046ec1 100644 --- a/tests/setup/test_da_setup_router.py +++ b/tests/setup/test_da_setup_router.py @@ -167,11 +167,10 @@ def test_global_and_command_gates_require_canonical_da_foundation() -> None: assert "`schema_version`" in instructions assert "equal to `4`" in instructions - assert "at least one entry in its `agents` object" in instructions + assert "`authoring_ready` equal to `true`" in instructions assert "let the invoked command resolve" in instructions - assert "Do not require aggregate `connect_ready`" in instructions - for step in ("SETUP-01", "SETUP-02.1", "SETUP-03", "SETUP-04", "SETUP-07"): - assert step in instructions + assert "This is the only readiness marker" in instructions + assert "Ignore `connect_ready`" in instructions assert '`status` equal to `"complete"`' not in instructions gated_prompts = ( @@ -207,9 +206,8 @@ def test_global_and_command_gates_require_canonical_da_foundation() -> None: connect_prompt = (_PROMPTS / "connect.prompt.md").read_text(encoding="utf-8") assert ".local/setup/config.json" in connect_prompt assert "schema_version: 4" in connect_prompt - assert "Do not require aggregate `connect_ready`" in connect_prompt - for step in ("SETUP-01", "SETUP-02.1", "SETUP-03", "SETUP-04", "SETUP-07"): - assert step in connect_prompt + assert "`authoring_ready: true`" in connect_prompt + assert "`connect_ready`" in connect_prompt assert "read `.local/config.json`" in instructions diff --git a/tests/setup/test_servicenow_da_connect_router.py b/tests/setup/test_servicenow_da_connect_router.py index 884f60e02..c9f3523d2 100644 --- a/tests/setup/test_servicenow_da_connect_router.py +++ b/tests/setup/test_servicenow_da_connect_router.py @@ -18,13 +18,14 @@ def test_da_servicenow_connect_routes_to_prototype_skill() -> None: assert "src/skills/connect/SKILL.md" in prompt assert "Extension setup is not yet available" not in prompt - assert "Do not require aggregate `connect_ready`" in prompt - assert all( - step in prompt - for step in ("SETUP-01", "SETUP-02.1", "SETUP-03", "SETUP-04", "SETUP-07") - ) + assert "`authoring_ready: true`" in prompt + assert "Ignore" in prompt + assert "`connect_ready`" in prompt assert "`activeAgent` slug" in prompt assert "`botId`" in prompt + assert "Skip completed steps" in prompt + assert "Waiting for maker input" in prompt + assert "never require the maker to invoke" in prompt assert "src/skills/connect/servicenow-da/SKILL.md" in router assert "releaseLine" in router @@ -34,11 +35,37 @@ def test_global_gate_allows_connection_blocked_foundation() -> None: _SOLUTION / ".github" / "copilot-instructions.md" ).read_text(encoding="utf-8") - assert "Do not require aggregate `connect_ready`" in instructions - assert all( - step in instructions - for step in ("SETUP-01", "SETUP-02.1", "SETUP-03", "SETUP-04", "SETUP-07") - ) - assert "at least one entry in its `agents` object" in instructions + assert "`authoring_ready` equal to `true`" in instructions + assert "This is the only readiness marker" in instructions + assert "Ignore `connect_ready`" in instructions assert "let the invoked command resolve" in instructions assert "`/connect servicenow` is available" in instructions + + +def test_da_servicenow_skill_is_resumable_across_maker_questions() -> None: + skill = ( + _SOLUTION + / "src" + / "skills" + / "connect" + / "servicenow-da" + / "SKILL.md" + ).read_text(encoding="utf-8") + normalized = " ".join(skill.split()) + + assert "Run `inspect` at the beginning of every invocation" in skill + assert "Before every step, check its current live evidence" in skill + assert "do not repeat its question or operation" in skill + assert "Always ask the maker to confirm their current state" in normalized + assert "cannot be verified by the available APIs" in normalized + assert "never sufficient to skip the current confirmation" in normalized + assert ( + "does not require the maker to invoke `/connect servicenow` again" + in normalized + ) + assert "Never interpret a repeated `/connect servicenow` invocation" in skill + assert "If the host reports that the maker is unavailable" in skill + assert "Do not select a choice" in skill + assert "Use **Task completed** only after a passing Test pane result" in skill + assert "record-parameter-sharing --status enabled" in skill + assert "record-parameter-sharing --status not-exposed" in skill From e72397b7b4a12c70c7fd0eb735643645cc7704eb Mon Sep 17 00:00:00 2001 From: Daphne Shao Date: Fri, 25 Sep 2026 12:49:50 -0700 Subject: [PATCH 8/9] fix(connect): align runtime gate with authoring readiness Use the canonical authoring_ready marker in the ServiceNow runtime and remove a stray skill heading character. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f9e8cfc7-07a1-469b-80f6-99a9cbb7aee6 --- .../scripts/connect_servicenow_da.py | 22 ++----------------- .../src/skills/connect/servicenow-da/SKILL.md | 2 +- tests/scripts/test_connect_servicenow_da.py | 4 +++- 3 files changed, 6 insertions(+), 22 deletions(-) diff --git a/solutions/ess-maker-skills/scripts/connect_servicenow_da.py b/solutions/ess-maker-skills/scripts/connect_servicenow_da.py index 64b12ab43..37363142d 100644 --- a/solutions/ess-maker-skills/scripts/connect_servicenow_da.py +++ b/solutions/ess-maker-skills/scripts/connect_servicenow_da.py @@ -38,13 +38,6 @@ SETUP_SCHEMA_VERSION = 4 HR_SCHEMA_NAME = "gptagent_copilotforemployeeselfservicehr" TOKEN_CACHE = Path(".local/.agentbuilder_token_cache.bin") -CONNECT_FOUNDATION_STEPS = ( - "SETUP-01", - "SETUP-02.1", - "SETUP-03", - "SETUP-04", - "SETUP-07", -) class ServiceNowConnectError(RuntimeError): @@ -166,20 +159,9 @@ def load_context(root: Path = Path(".")) -> dict[str, Any]: raise ServiceNowConnectError( "This prototype supports only Employee Self-Service (HR)." ) - steps = canonical.get("steps") - if not isinstance(steps, dict): - raise ServiceNowConnectError("The setup record has no foundation steps.") - incomplete = [ - step_id - for step_id in CONNECT_FOUNDATION_STEPS - if not isinstance(steps.get(step_id), dict) - or steps[step_id].get("state") != "done" - ] - if incomplete: + if canonical.get("authoring_ready") is not True: raise ServiceNowConnectError( - "DA foundation setup is incomplete for /connect: " - + ", ".join(incomplete) - + "." + "DA foundation setup is not ready for authoring." ) workspace = canonical.get("workspace") if ( diff --git a/solutions/ess-maker-skills/src/skills/connect/servicenow-da/SKILL.md b/solutions/ess-maker-skills/src/skills/connect/servicenow-da/SKILL.md index b39e1f838..a0e636be1 100644 --- a/solutions/ess-maker-skills/src/skills/connect/servicenow-da/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/connect/servicenow-da/SKILL.md @@ -1,4 +1,4 @@ -z# Connect ServiceNow HRSD to a DA-GA HR Agent +# Connect ServiceNow HRSD to a DA-GA HR Agent This prototype uses the DA foundation handoff, MinimalBot Components, and the Power Platform Connectivity API. It does not query Dataverse or activate cloud diff --git a/tests/scripts/test_connect_servicenow_da.py b/tests/scripts/test_connect_servicenow_da.py index 6d6917616..0e9524d28 100644 --- a/tests/scripts/test_connect_servicenow_da.py +++ b/tests/scripts/test_connect_servicenow_da.py @@ -399,6 +399,7 @@ def test_load_context_requires_matching_schema_v4_hr_agent( }, "agents": { AGENT_ID: { + "authoring_ready": True, "connect_ready": False, "agent": { "id": AGENT_ID, @@ -455,11 +456,12 @@ def test_load_context_rejects_incomplete_foundation_step( setup_path = tmp_path / snow.SETUP_STATE setup = json.loads(setup_path.read_text(encoding="utf-8")) setup["agents"][AGENT_ID]["steps"]["SETUP-03"]["state"] = "blocked" + setup["agents"][AGENT_ID]["authoring_ready"] = False setup_path.write_text(json.dumps(setup), encoding="utf-8") with pytest.raises( snow.ServiceNowConnectError, - match="incomplete.*SETUP-03", + match="not ready for authoring", ): snow.load_context(tmp_path) From 53ec3dc6b47b5bba0ef984ef3983d2798f81e49a Mon Sep 17 00:00:00 2001 From: Daphne Shao Date: Fri, 25 Sep 2026 17:14:43 -0700 Subject: [PATCH 9/9] fix(connect): address resumability review findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f9e8cfc7-07a1-469b-80f6-99a9cbb7aee6 --- .../.github/prompts/connect.prompt.md | 7 +- .../scripts/connect_servicenow_da.py | 14 +- tests/scripts/test_connect_servicenow_da.py | 177 ++++++++++++++++++ .../test_servicenow_da_connect_router.py | 2 + 4 files changed, 196 insertions(+), 4 deletions(-) diff --git a/solutions/ess-maker-skills/.github/prompts/connect.prompt.md b/solutions/ess-maker-skills/.github/prompts/connect.prompt.md index a7c3a024b..76c4484e7 100644 --- a/solutions/ess-maker-skills/.github/prompts/connect.prompt.md +++ b/solutions/ess-maker-skills/.github/prompts/connect.prompt.md @@ -43,6 +43,7 @@ Rules: auto-response as an answer, and never require the maker to invoke `/connect` again merely to continue that question. -After reading SKILL.md, your first action is to check for -`.local/connect/steps.md`. If starting fresh, your first message to the user -is the checklist table from the Fresh Start section. +Do not inspect `.local/connect/steps.md` before the router selects a path. If +the router selects the retained Preview path, use that path's persisted +`steps.md` and Fresh Start contract. If it selects the DA ServiceNow path, +start with that skill's live `inspect` contract instead. diff --git a/solutions/ess-maker-skills/scripts/connect_servicenow_da.py b/solutions/ess-maker-skills/scripts/connect_servicenow_da.py index 37363142d..29773f9ae 100644 --- a/solutions/ess-maker-skills/scripts/connect_servicenow_da.py +++ b/solutions/ess-maker-skills/scripts/connect_servicenow_da.py @@ -303,6 +303,7 @@ def summarize_components(components: dict[str, Any]) -> dict[str, Any]: for change in service_now_topics if isinstance(change.get("component"), dict) and change["component"].get("state") == "Active" + and change["component"].get("status") == "Active" ), "serviceNowTopics": topic_summaries, "connectionReferenceCount": len( @@ -691,9 +692,15 @@ def _inspection_progress( total_topics = components["serviceNowTopicCount"] active_topics = components["activeServiceNowTopicCount"] + topic_enablement = state.get("topicEnablement") + kept_current_topics = ( + isinstance(topic_enablement, dict) + and topic_enablement.get("customerChoice") == "keep-current" + and steps.get("topics") == "done" + ) topics_done = ( total_topics > 0 and active_topics == total_topics - ) or steps.get("topics") == "done" + ) or kept_current_topics attestation = state.get("agentConnection") if not isinstance(attestation, dict): @@ -939,6 +946,11 @@ def record_agent_connection_attestation( raise ServiceNowConnectError( "The physical ServiceNow connection is not Connected." ) + if physical.get("authMode") != "entraIDUserLogin": + raise ServiceNowConnectError( + "The physical ServiceNow connection must use Microsoft Entra ID " + "User Login." + ) normalized_connection_id = uuid.UUID(connection_id).hex state_path = _state_path(context["agent"]["id"]) diff --git a/tests/scripts/test_connect_servicenow_da.py b/tests/scripts/test_connect_servicenow_da.py index 0e9524d28..79b6b292f 100644 --- a/tests/scripts/test_connect_servicenow_da.py +++ b/tests/scripts/test_connect_servicenow_da.py @@ -104,6 +104,18 @@ def test_summarize_components_separates_servicenow_from_workday_flows() -> None: assert result["reference"]["resourceUri"] == "resource-id" +def test_summarize_components_requires_active_state_and_status() -> None: + components = _components(CONNECTION_ID) + topic = components["botComponentChanges"][0]["component"] + topic["state"] = "Active" + topic["status"] = "Inactive" + + result = snow.summarize_components(components) + + assert result["serviceNowTopicCount"] == 1 + assert result["activeServiceNowTopicCount"] == 0 + + def test_topic_state_update_replays_full_dialog_component() -> None: components = _components() topic = components["botComponentChanges"][0]["component"] @@ -527,6 +539,39 @@ def get_connection(self, connection_id: str) -> dict: assert state["steps"]["agentConnection"] == "done" +def test_record_agent_connection_rejects_unsupported_auth_mode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeConnectivity: + def get_connection(self, _connection_id: str) -> dict: + return { + "name": CONNECTION_ID.replace("-", ""), + "properties": { + "displayName": "ServiceNow", + "statuses": [{"target": "token", "status": "Connected"}], + "connectionParametersSet": {"name": "oauth2"}, + }, + } + + monkeypatch.setattr( + snow, + "_connectivity_client", + lambda _context: FakeConnectivity(), + ) + + with pytest.raises( + snow.ServiceNowConnectError, + match="Microsoft Entra ID User Login", + ): + snow.record_agent_connection_attestation( + { + "agent": {"id": AGENT_ID}, + "environment": {"id": ENVIRONMENT_ID}, + }, + CONNECTION_ID, + ) + + def test_inspect_preserves_progress_and_reports_completed_steps( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -638,6 +683,138 @@ def list_connections(self) -> list[dict]: assert state["test"]["result"] == "pass" +def test_inspect_reopens_topics_after_later_deactivation( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + state_path = ( + tmp_path + / ".local" + / "connect" + / "servicenow" + / "agents" + / AGENT_ID + / "state.json" + ) + state_path.parent.mkdir(parents=True) + state_path.write_text( + json.dumps( + { + "schemaVersion": 1, + "agentId": AGENT_ID, + "environmentId": ENVIRONMENT_ID, + "topicEnablement": {"customerChoice": "enable-all"}, + "steps": {"topics": "done"}, + } + ), + encoding="utf-8", + ) + components = _components(CONNECTION_ID) + components["botComponentChanges"][0]["component"]["state"] = "Inactive" + components["botComponentChanges"][0]["component"]["status"] = "Inactive" + + class FakeAgentBuilder: + def fetch_components(self, _agent_id: str) -> dict: + return components + + class FakeConnectivity: + def get_connector(self) -> dict: + return {"properties": {}} + + def list_connections(self) -> list[dict]: + return [] + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + snow, + "_agentbuilder_client", + lambda _context: FakeAgentBuilder(), + ) + monkeypatch.setattr( + snow, + "_connectivity_client", + lambda _context: FakeConnectivity(), + ) + + result = snow.inspect( + { + "agent": { + "id": AGENT_ID, + "schema_name": snow.HR_SCHEMA_NAME, + }, + "environment": {"id": ENVIRONMENT_ID, "ring": "test"}, + } + ) + + assert result["progress"]["topics"]["status"] == "pending" + + +def test_inspect_preserves_explicit_keep_current_topic_choice( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + state_path = ( + tmp_path + / ".local" + / "connect" + / "servicenow" + / "agents" + / AGENT_ID + / "state.json" + ) + state_path.parent.mkdir(parents=True) + state_path.write_text( + json.dumps( + { + "schemaVersion": 1, + "agentId": AGENT_ID, + "environmentId": ENVIRONMENT_ID, + "topicEnablement": {"customerChoice": "keep-current"}, + "steps": {"topics": "done"}, + } + ), + encoding="utf-8", + ) + components = _components(CONNECTION_ID) + components["botComponentChanges"][0]["component"]["state"] = "Inactive" + components["botComponentChanges"][0]["component"]["status"] = "Inactive" + + class FakeAgentBuilder: + def fetch_components(self, _agent_id: str) -> dict: + return components + + class FakeConnectivity: + def get_connector(self) -> dict: + return {"properties": {}} + + def list_connections(self) -> list[dict]: + return [] + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + snow, + "_agentbuilder_client", + lambda _context: FakeAgentBuilder(), + ) + monkeypatch.setattr( + snow, + "_connectivity_client", + lambda _context: FakeConnectivity(), + ) + + result = snow.inspect( + { + "agent": { + "id": AGENT_ID, + "schema_name": snow.HR_SCHEMA_NAME, + }, + "environment": {"id": ENVIRONMENT_ID, "ring": "test"}, + } + ) + + assert result["progress"]["topics"]["status"] == "done" + + def test_record_parameter_sharing_persists_maker_observation( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/tests/setup/test_servicenow_da_connect_router.py b/tests/setup/test_servicenow_da_connect_router.py index c9f3523d2..4ee6e8c0a 100644 --- a/tests/setup/test_servicenow_da_connect_router.py +++ b/tests/setup/test_servicenow_da_connect_router.py @@ -26,6 +26,8 @@ def test_da_servicenow_connect_routes_to_prototype_skill() -> None: assert "Skip completed steps" in prompt assert "Waiting for maker input" in prompt assert "never require the maker to invoke" in prompt + assert "Do not inspect `.local/connect/steps.md` before" in prompt + assert "start with that skill's live `inspect` contract" in prompt assert "src/skills/connect/servicenow-da/SKILL.md" in router assert "releaseLine" in router