diff --git a/solutions/ess-maker-skills/.github/copilot-instructions.md b/solutions/ess-maker-skills/.github/copilot-instructions.md index d3496289..147ccc28 100644 --- a/solutions/ess-maker-skills/.github/copilot-instructions.md +++ b/solutions/ess-maker-skills/.github/copilot-instructions.md @@ -3,18 +3,26 @@ ## 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`.** 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 this file FIRST, then decide +what 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 at least one entry in its `agents` object has +`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 +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. @@ -212,9 +220,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 workday` uses the checked-in ESS DA HR Workday extension guidance; - unsupported products or agent verticals must stop at their explicit routing - boundary; +- `/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 bffb68f3..55856673 100644 --- a/solutions/ess-maker-skills/.github/prompts/connect.prompt.md +++ b/solutions/ess-maker-skills/.github/prompts/connect.prompt.md @@ -6,12 +6,15 @@ description: "Connect Workday or another supported integration" # Connect **Setup-state check.** Read `.local/setup/config.json` and `.local/config.json`. -Resolve `activeAgent` to the canonical agent whose `agent.workspace_slug` -matches. Continue when canonical state has `schema_version: 4`, complete -workspace evidence, and `steps.SETUP-07.state: "done"`. Do not require -`connect_ready: true`; this command configures the product-extension -connections that may currently block runtime readiness. If local workspace -materialization is incomplete, show: +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. Continue when canonical state has +`schema_version: 4`, that agent has `authoring_ready: true`, complete workspace evidence, +and `steps.SETUP-07.state: "done"`. Do not require +`connect_ready: true`; Ignore `connect_ready`, `active_step`, blocked capacity, +and blocked connection steps for this admission check because this command +configures the product-extension connections that may currently block runtime +readiness. If local workspace materialization is incomplete, show: > Welcome to the ESS Maker Kit. Before running `/connect`, type `/setup` to set up your environment. @@ -30,7 +33,18 @@ 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 -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/agentbuilder.py b/solutions/ess-maker-skills/scripts/agentbuilder.py index f9b37516..d7464734 100644 --- a/solutions/ess-maker-skills/scripts/agentbuilder.py +++ b/solutions/ess-maker-skills/scripts/agentbuilder.py @@ -482,8 +482,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) @@ -507,6 +513,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: @@ -520,6 +527,7 @@ def authenticate( cache_path=cache_path, force_account_selection=force_account_selection, account_hint=account_hint, + scopes=scopes, ) @@ -887,6 +895,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 00000000..29773f9a --- /dev/null +++ b/solutions/ess-maker-skills/scripts/connect_servicenow_da.py @@ -0,0 +1,1372 @@ +# 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, + 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 = 4 +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.") + + environment = setup.get("environment") + config = _load_json(root / ACTIVE_CONFIG) + active_slug = config.get("activeAgent") + 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 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.") + + 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." + ) + 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)." + ) + if canonical.get("authoring_ready") is not True: + raise ServiceNowConnectError( + "DA foundation setup is not ready for authoring." + ) + 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: + raise ServiceNowConnectError("The active agent has no component snapshot path.") + snapshot_path = root / Path(relative_snapshot) + return { + "root": root, + "setup": setup, + "agentSetup": canonical, + "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) + 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), + "activeServiceNowTopicCount": sum( + 1 + 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( + 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 find_servicenow_topic( + components: dict[str, Any], + topic_id: str, +) -> dict[str, Any]: + 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." + ) + 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, + "botComponentChanges": [ + { + "$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, + 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( + environment["tenant_id"], + 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"], + 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 _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"] + 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 kept_current_topics + + 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"]) + 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() + ] + result["connectivity"] = { + "connector": { + "displayName": connector_properties.get("displayName"), + "tier": connector_properties.get("tier"), + "isCustomApi": connector_properties.get("isCustomApi"), + }, + "connections": connections, + "userConnectionBinding": { + "mode": "maker-ui", + "automationAvailable": False, + "requiredDelegatedScope": "PowerVirtualAgents.Tokens.Read", + }, + } + 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 + + +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: + 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": ( + display_name or "ESS HR ServiceNow HRSD Connection" + ), + "status": "maker-action-required", + "authMode": "entraIDUserLogin", + "instanceName": instance_name, + "resourceUri": resource_uri, + "createdBySkill": False, + } + 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", + "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 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 credential with status Connected, followed by " + "maker confirmation that it was connected to the agent." + ), + }, + } + + +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." + ) + 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"]) + 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( + "Topic state mutation requires explicit confirmation (--yes)." + ) + 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( + "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) + 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"]) + after_summary = summarize_components(after) + 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, 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["topicEnablement"] = result + state.setdefault("steps", {})["topics"] = "done" + state["componentHash"] = _component_hash(after) + state["updatedAt"] = _utc_now() + _write_json_atomic(state_path, 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( + context: dict[str, Any], + *, + confirmed: bool, +) -> dict[str, Any]: + if not confirmed: + raise ServiceNowConnectError( + "Publishing requires explicit confirmation (--yes)." + ) + 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 = _state_for_components(context, components) + state["publish"] = { + "completedAt": _utc_now(), + "componentHash": _component_hash(components), + "responseKeys": sorted(response.keys()), + } + steps = state.setdefault("steps", {}) + steps["publish"] = "done" + state["updatedAt"] = _utc_now() + _write_json_atomic(state_path, state) + 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.", + ) + 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") + + 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, + ) + + 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 + + +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 == "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: + 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/scripts/setup_existing_da.py b/solutions/ess-maker-skills/scripts/setup_existing_da.py index 8dab047c..0464f338 100644 --- a/solutions/ess-maker-skills/scripts/setup_existing_da.py +++ b/solutions/ess-maker-skills/scripts/setup_existing_da.py @@ -61,6 +61,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 " @@ -469,6 +476,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 @@ -484,6 +492,7 @@ def _validate_canonical_agent_state( "workspace", "steps", "active_step", + "authoring_ready", "connect_ready", "open_issues", "created_at", @@ -569,6 +578,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 @@ -600,6 +621,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], @@ -848,6 +906,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, @@ -898,6 +960,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]], @@ -1087,6 +1165,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 @@ -1104,6 +1186,7 @@ def maintain_setup_flightcheck( "failureCauses": list( agent_state["steps"][step_id].get("failure_causes", []) ), + "authoringReady": agent_state["authoring_ready"], "mode": agent_state["steps"][step_id].get("mode"), "connectReady": agent_state["connect_ready"], "activeStep": agent_state["active_step"], @@ -1150,6 +1233,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 @@ -1239,6 +1323,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 @@ -2096,6 +2184,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) @@ -2495,6 +2588,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: @@ -2679,6 +2773,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.", @@ -2858,6 +2961,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/SKILL.md b/solutions/ess-maker-skills/src/skills/connect/SKILL.md index df8406c2..9932a7b1 100644 --- a/solutions/ess-maker-skills/src/skills/connect/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/connect/SKILL.md @@ -13,9 +13,20 @@ 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. That file records anonymous -usage telemetry after routing knows which integration was chosen, so the -Connect capability event carries the correct `connector` attribution +Read `.local/config.json`. + +Resolve the entry in `agents` whose `slug` equals `activeAgent`. If that +entry's `releaseLine` is `da` and the selected integration is ServiceNow, +record anonymous usage telemetry attributed to ServiceNow (best-effort, +non-blocking, and with no user-facing message): +`python scripts/emit_capability.py connect --connector servicenow`. +Then 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. That file records +anonymous usage telemetry after routing knows which integration was chosen, so +the Connect capability event carries the correct `connector` attribution (workday vs servicenow) rather than being a generic "connect" wedge. (Step 1 asks which integration, detects existing state, and dispatches — @@ -30,7 +41,14 @@ already-installed lifecycle or the existing unsupported-install boundary.) Each integration routes differently — ServiceNow has its own step files; Workday routes by architecture before package detection: -- **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 00000000..a0e636be --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/connect/servicenow-da/SKILL.md @@ -0,0 +1,264 @@ +# 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: + +```text +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. 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: + +- ServiceNow topic count and active count. +- 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. + +## 3. Guide the maker to create the physical connection + +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 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. +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. + +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: + +```powershell +python scripts\connect_servicenow_da.py inspect +``` + +If exactly one matching Connected Entra user-login connection is present, show +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 use. + +## 4. Maker connects the credential to the agent + +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. + +The UI uses: + +```text +POST /powervirtualagents/bots//channels/pva-studio/user-connections +``` + +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 +python scripts/connect_servicenow_da.py record-agent-connection --connection-id +``` + +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. + +## 5. Check whether OBO parameter sharing is exposed + +Guide the maker to open **Settings** -> **Connection settings** -> the +ServiceNow connection -> **See details** -> **Connection parameters**. + +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 +``` + +## 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 "" +``` + +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_agentbuilder.py b/tests/scripts/test_agentbuilder.py index bdf91f7e..65c9d1e0 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 00000000..79b6b292 --- /dev/null +++ b/tests/scripts/test_connect_servicenow_da.py @@ -0,0 +1,1004 @@ +# 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", + "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": { + "$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["serviceNowTopics"][0]["displayName"] == ( + "ServiceNow HRSD Common Orchestrator" + ) + 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_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"] + + payload = snow.build_topic_state_update_payload( + components, + topic["id"], + "Inactive", + ) + + assert set(payload) == {"changeToken", "botComponentChanges"} + assert payload["changeToken"] == "token" + 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(), + ) + 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( + { + "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_v4_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": 4, + "intent": "DA foundation setup", + "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" + ), + }, + "agents": { + AGENT_ID: { + "authoring_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"}, + }, + } + }, + } + ), + 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_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["agents"][AGENT_ID]["authoring_ready"] = False + setup_path.write_text(json.dumps(setup), encoding="utf-8") + + with pytest.raises( + snow.ServiceNowConnectError, + match="not ready for authoring", + ): + snow.load_context(tmp_path) + + +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_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, +) -> 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_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, +) -> 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: + 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"] + ) + + +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 a74b1e08..4f979e8c 100644 --- a/tests/scripts/test_setup_existing_da.py +++ b/tests/scripts/test_setup_existing_da.py @@ -647,6 +647,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( @@ -712,7 +713,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" @@ -1128,6 +1131,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") @@ -1154,6 +1158,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" @@ -1350,6 +1355,49 @@ def test_product_registry_resolves_exact_it_requirement() -> None: ) +def test_authoring_ready_allows_incomplete_runtime_readiness( + 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"] == "done" + assert agent_state["steps"]["SETUP-05"]["mode"] == "skipped" + assert agent_state["steps"]["SETUP-02.2"]["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) @@ -1705,6 +1753,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 98c7cd14..0055a365 100644 --- a/tests/setup/test_da_setup_router.py +++ b/tests/setup/test_da_setup_router.py @@ -401,13 +401,15 @@ 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 "`authoring_ready` equal to `true`" in instructions + assert "let the invoked command resolve" 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 = ( @@ -415,7 +417,6 @@ def test_global_and_command_gates_require_canonical_da_completion() -> None: "create.prompt.md", "delete.prompt.md", "evaluate.prompt.md", - "push.prompt.md", "restore-template-configs.prompt.md", "review.prompt.md", "run.prompt.md", @@ -441,7 +442,10 @@ def test_global_and_command_gates_require_canonical_da_completion() -> None: ), 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 "`authoring_ready: true`" in connect_prompt + assert "`connect_ready`" in connect_prompt assert 'steps.SETUP-07.state: "done"' in connect_prompt assert "Do not require\n`connect_ready: true`" in connect_prompt assert "workspace evidence" in connect_prompt @@ -449,11 +453,10 @@ def test_global_and_command_gates_require_canonical_da_completion() -> None: flightcheck_prompt = (_PROMPTS / "flightcheck.prompt.md").read_text( encoding="utf-8" ) - assert "FlightCheck entry contract" in flightcheck_prompt - assert "Standalone FlightCheck" in flightcheck_prompt - assert "Canonical setup ready" in flightcheck_prompt - - assert "`.local/config.json`'s" in instructions + normalized_flightcheck_prompt = " ".join(flightcheck_prompt.split()) + assert "FlightCheck entry contract" in normalized_flightcheck_prompt + assert "Standalone FlightCheck" in normalized_flightcheck_prompt + assert "Canonical setup ready" in normalized_flightcheck_prompt def test_global_gate_routes_flightcheck_through_setup_evidence() -> None: 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 00000000..01596283 --- /dev/null +++ b/tests/setup/test_servicenow_da_connect_router.py @@ -0,0 +1,74 @@ +# 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 "`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 "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 + assert "emit_capability.py connect --connector servicenow" in router + + +def test_global_gate_allows_connection_blocked_foundation() -> None: + instructions = ( + _SOLUTION / ".github" / "copilot-instructions.md" + ).read_text(encoding="utf-8") + + 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