
EPISODE 6 OF 10
Testing Copilot Studio and AI agents · Episode 6 of 10
In Episodes 2 to 5 we built a golden set, studio test sets, a pytest harness and route tests. All of it is worthless if someone has to remember to run it. Manual re-testing is the first discipline to go when a deadline gets close.
This Episode we automate it. We also deal with the change that makes agent testing different from anything before: behaviour can drift when nothing in your repo has changed.
Drift: the system around the agent moves

Retest when the system changes
A release is only one of six triggers. Each needs an owner, a detection signal and a defined rerun:
| Trigger | How you detect it | What reruns | Owner |
| Microsoft model or orchestration change | Message Center, What’s new, model-availability pages | Full golden set + adversarial core, on a canary copy | Factory lead |
| Agent or tool change | Pull request | Affected cases + critical subset (PR), full set (merge) | Developer |
| Knowledge change | SharePoint/Dataverse change events, re-index | Cases citing that source + poisoning checks | Knowledge owner |
| Process or access change | Role or approval change tickets | Paired-persona and confirmation suites | Platform admin |
| New threat or incident | Advisories, SOC alerts, failed refusals | New attack cases + targeted red team | Security |
| Time and traffic | Schedule; quality, latency or cost anomaly | Scheduled replay at tier cadence (Episode 9) | Service owner |
The model trigger deserves extra attention. Microsoft retires and replaces the models behind Copilot Studio on its own schedule. Our factory guide’s example is the GPT-4o to GPT-4.1 transition at the end of October 2025. Our rule: treat every model or orchestration change as a mandatory full regression, not a spot check. In our experience it’s the biggest single source of unannounced behaviour change.
The factory runs this as two linked tests:
- Model-change tracking. Subscribe a team distribution list to Copilot Studio Message Center posts (Health → Message center → Preferences, custom view). Bookmark the Copilot Studio What’s new page. Pass criterion: a full regression starts within 48 hours of any deprecation notice. Where the platform offers a temporary option to keep using a retired model, use it to buy time for root-causing, not to defer the decision.
- Side-by-side comparison. Switch the model on a non-production canary copy of the agent, run the same test sets, and use the evaluation page’s compare feature to see which cases improved or declined. Pass criterion: no case moves from pass to fail without being resolved before promotion.
Mira’s case file: the model that softened the Ombudsman

Same agent, new model: compare before you promote
Before. A model retirement notice arrived. The canary run looked fine on the headline number, with overall pass rate going from 97% to 93%. A team gating on the average would have shipped it.
What the per-category gate caught. Regulated wording fell from 100% to 82%. Two patterns stood out:
- The APR answer dropped the representative example wording that consumer-credit rules require.
- “How do I complain about a decision?” softened “you can refer your complaint to the Financial Ombudsman Service” to “you can raise a concern with us”.
Both answers were fluent, polite and well grounded, and both were non-compliant. Only exact-match and keyword graders on regulated cases caught them. General quality scored them highly.
Fix. Targeted, topic-level changes that state the required wording explicitly, plus exact-match cases for each regulated phrase. No broad instruction rewrite, because those cause new regressions elsewhere.
After. The full comparison was rerun. Regulated wording returned to 100%, and the canary was promoted well inside the grace window.
| Gate per category, not on the average. A model can improve your FAQ answers and break your compliance answers in the same release. |
The Evaluation API: studio test sets from code
Copilot Studio’s evaluation can be driven through the Power Platform API. You can list test sets, start a run, list runs and get run details (Microsoft Learn). All endpoints use API version 2024-10-01:
| Operation | Endpoint (prefix https://api.powerplatform.com/copilotstudio/environments/{envId}/bots/{botId}/api/makerevaluation) |
| List test sets | GET /testsets |
| Test set details | GET /testsets/{testSetId} |
| Start a run | POST /testsets/{testSetId}/run |
| List runs | GET /testruns |
| Run details | GET /testruns/{runId} |
Two practical details matter when you script it:
- Identity during the run. The start request can include mcsConnectionId to choose the user profile used to authenticate connections. Without it, the run is unauthenticated (Microsoft Learn). For Mira, every run uses a test persona’s connection.
- Delegated auth only. The Power CAT team’s write-up of an Azure DevOps evaluation gate notes there’s no app-only path today. Unattended pipelines use a cached MSAL refresh token from Key Vault, and refresh tokens expire after 90 days of inactivity. The same post shows evaluating the draft agent with runOnPublishedBot: false (Power CAT blog).
code/pipeline/eval_gate.py"""Run every Copilot Studio test set whose name starts with a prefix, wait, gate, emit JUnit.python eval_gate.py --env $ENV_ID --bot $BOT_ID --prefix "GATE-" --min-pass 0.95 --junit eval.xml"""import argparse, os, sys, time, json, msal, requestsfrom xml.etree.ElementTree import Element, SubElement, ElementTree API = "https://api.powerplatform.com/copilotstudio/environments/{env}/bots/{bot}/api/makerevaluation"V = {"api-version": "2024-10-01"} def token() -> str: cache = msal.SerializableTokenCache(); cache.deserialize(os.environ["EVAL_TOKEN_CACHE"]) # from Key Vault app = msal.PublicClientApplication(os.environ["EVAL_CLIENT_ID"], token_cache=cache, authority=f"https://login.microsoftonline.com/{os.environ['TENANT_ID']}") res = app.acquire_token_silent(["https://api.powerplatform.com/.default"], account=app.get_accounts()[0]) if not res or "access_token" not in res: print("##vso[task.logissue type=error]AUTH FAILURE (not a quality failure): refresh the eval token") sys.exit(2) # distinct exit code for credential issues if cache.has_state_changed: open(os.environ.get("EVAL_TOKEN_CACHE_OUT", "token_cache.out"), "w").write(cache.serialize()) return res["access_token"] def outcomes(run: dict) -> list[tuple[str, bool]]: """Per-case pass/fail from run details. The per-case field names below are assumptions, because the public docs don't list them. Run once with --dump, inspect the payload and adjust before relying on the gate.""" rows = run.get("testCasesResults") or run.get("testCaseResults") or [] out = [] for r in rows: metrics = r.get("metricsResults") or r.get("metrics") or [] ok = bool(metrics) and all(str(m.get("result", {}).get("status", m.get("status"))).lower() == "pass" for m in metrics) out.append((r.get("testCaseId") or r.get("id"), ok)) return out def main(): ap = argparse.ArgumentParser() for a in ("--env", "--bot", "--prefix", "--junit"): ap.add_argument(a, required=True) ap.add_argument("--min-pass", type=float, default=0.95); ap.add_argument("--connection-id") ap.add_argument("--published", action="store_true"); ap.add_argument("--dump", action="store_true") a = ap.parse_args() base, h = API.format(env=a.env, bot=a.bot), {"Authorization": f"Bearer {token()}"} sets = [s for s in requests.get(f"{base}/testsets", params=V, headers=h).json()["value"] if s["displayName"].startswith(a.prefix) and s.get("state") == "Active"] suite, worst = Element("testsuites"), 1.0 for s in sets: body = {"runOnPublishedBot": a.published} if a.connection_id: body["mcsConnectionId"] = a.connection_id run = requests.post(f"{base}/testsets/{s['id']}/run", params=V, headers=h, json=body).json() run_id, deadline = run["runId"], time.time() + 1800 while time.time() < deadline: details = requests.get(f"{base}/testruns/{run_id}", params=V, headers=h).json() if str(details.get("state")).lower() == "completed": break time.sleep(20) else: sys.exit(f"timeout waiting for {s['displayName']}") if a.dump: print(json.dumps(details, indent=2)[:4000]) res = outcomes(details); passed = sum(ok for _, ok in res); rate = passed / max(len(res), 1) worst = min(worst, rate) ts = SubElement(suite, "testsuite", name=s["displayName"], tests=str(len(res)), failures=str(len(res) - passed)) for cid, ok in res: tc = SubElement(ts, "testcase", name=str(cid), classname=s["displayName"]) if not ok: SubElement(tc, "failure", message="evaluation method(s) failed") print(f"{s['displayName']}: {passed}/{len(res)} = {rate:.1%}") ElementTree(suite).write(a.junit, encoding="utf-8", xml_declaration=True) sys.exit(0 if worst >= a.min_pass else 1) # every set must clear the bar, not the average if __name__ == "__main__": main()
Notice the last line again: the gate uses the worst test set, not the average.
The pipeline

A pipeline where a quality regression physically blocks the merge
code/pipeline/azure-pipelines.ymltrigger: nonepr: branches: { include: [ main, release/* ] } variables: - group: mira-ci # CI_DEV_ENV_URL, ENV_ID, BOT_SCHEMA_NAME, TENANT_ID, client ids, MIN_PASS_RATE - name: MIN_PASS_RATE value: "0.95" stages:- stage: Build jobs: - job: pack pool: { vmImage: ubuntu-latest } steps: - task: PowerPlatformToolInstaller@2 - script: | pac solution pack --zipfile "$(Build.ArtifactStagingDirectory)/mira_unmanaged.zip" --folder "$(Build.SourcesDirectory)/src" python tests/golden/golden_cli.py coverage --set tests/golden # Episode 2: schema + coverage floor displayName: Pack solution and validate golden set - publish: $(Build.ArtifactStagingDirectory) artifact: solution - stage: DeployCI dependsOn: Build jobs: - job: import pool: { vmImage: ubuntu-latest } steps: - download: current artifact: solution - task: PowerPlatformToolInstaller@2 - script: | pac auth create --url "$(CI_DEV_ENV_URL)" --applicationId "$(SPN_ID)" --clientSecret "$(SPN_SECRET)" --tenant "$(TENANT_ID)" pac solution import --path "$(Pipeline.Workspace)/solution/mira_unmanaged.zip" --async --force-overwrite python code/pipeline/pin_config.py --solution src --pins tests/config-pins.yaml # settings drift guard displayName: Import into CI environment (not git-bound) - stage: Evaluate dependsOn: DeployCI jobs: - job: gates timeoutInMinutes: 120 pool: { vmImage: ubuntu-latest } steps: - task: AzureKeyVault@2 inputs: { azureSubscription: mira-kv, KeyVaultName: mira-ci-kv, SecretsFilter: "EVAL-TOKEN-CACHE,PERSONA-CACHES,SPY-STORAGE" } - script: pip install -r tests/requirements.txt - script: | # dataverse_token: set by an earlier AzureCLI step (az account get-access-token --resource $(CI_DEV_ENV_URL)) BOT_ID=$(curl -s -H "Authorization: Bearer $(dataverse_token)" \ "$(CI_DEV_ENV_URL)api/data/v9.2/bots?\$filter=schemaname eq '$(BOT_SCHEMA_NAME)'&\$select=botid" | jq -r '.value[0].botid') python code/pipeline/eval_gate.py --env "$(ENV_ID)" --bot "$BOT_ID" --prefix "GATE-" \ --min-pass $(MIN_PASS_RATE) --connection-id "$(PERSONA_CONNECTION_ID)" --junit eval.xml displayName: Copilot Studio evaluation gate env: { EVAL_TOKEN_CACHE: $(EVAL-TOKEN-CACHE) } - script: | pytest tests/harness tests/route-tests -n 8 --junitxml=harness.xml python code/harness/gate.py harness.xml --threshold $(MIN_PASS_RATE) displayName: Harness, route tests and statistical gate env: { SPY_STORAGE: $(SPY-STORAGE) } - script: npx promptfoo@latest redteam run -c tests/security/promptfooconfig.yaml --max-concurrency 4 -o redteam.json displayName: Red-team smoke (Episode 7/8) - script: python code/pipeline/cost_latency_gate.py --baseline baselines/prod.json --window 2h displayName: Cost and latency regression gate - task: PublishTestResults@2 condition: always() inputs: { testResultsFormat: JUnit, testResultsFiles: "*.xml", mergeTestResults: true }
Make the Evaluate stage a required status check on the release branch’s PR policy, so a direct merge can’t bypass it. Two rules from our factory guide apply here:
- Never lower MIN_PASS_RATE to get a build out. If the gate fails, pull the results locally, find the failing case, fix it and rerun the golden set locally before pushing. Overrides are rare, logged and signed off by the release manager.
- Separate credential failures from quality failures. An expired token is not a regression. The script exits with a different code so the dashboard can tell them apart.
Static review as an early stage
The Copilot Agent Kit’s Agent Review Pipeline runs deterministic structural checks plus AI-assessed design-pattern and instruction-compliance scoring. It approves or rejects a Power Platform Pipelines deployment against a configurable threshold, 60 by default (Microsoft Learn). If you deploy with Power Platform Pipelines rather than Azure DevOps, it’s the natural place for an early “lint” stage. It doesn’t replace the behaviour gates.
Guarding against settings drift
Some of the most damaging regressions are settings, not code: general knowledge turned back on, web search enabled in a clone, content moderation lowered. These live in the solution, so they can be pinned:
code/pipeline/pin_config.py"""Fail the build if pinned agent settings in the unpacked solution differ from the approved values.Find each key path once by diffing an unpacked solution before/after toggling the setting in the studio."""import argparse, json, pathlib, sys, yaml def walk(obj, path=""): if isinstance(obj, dict): for k, v in obj.items(): yield from walk(v, f"{path}.{k}" if path else k) else: yield path, obj ap = argparse.ArgumentParser(); ap.add_argument("--solution"); ap.add_argument("--pins"); a = ap.parse_args()pins = yaml.safe_load(open(a.pins)) # {file_glob: {key.path: expected_value}}bad = 0for glob, expected in pins.items(): for f in pathlib.Path(a.solution).glob(glob): text = f.read_text(encoding="utf-8") data = json.loads(text) if f.suffix == ".json" else yaml.safe_load(text) flat = dict(walk(data)) for key, want in expected.items(): if key in flat and flat[key] != want: print(f"SETTINGS DRIFT {f}: {key} = {flat[key]!r}, approved {want!r}"); bad += 1sys.exit(1 if bad else 0)
Cost and latency: the regression quality tests miss
A model swap can pass every accuracy and groundedness test and still double the cost per turn or break a latency SLA. Our factory triggers, measured against a production baseline of the last 500 turns:
- P95 latency +25% → manual review;
- tokens per turn +20% → notify the cost owner;
- fallback rate +10 percentage points → block promotion.
With Application Insights connected to the agent, the baseline is a short KQL query. Event and dimension names depend on your telemetry configuration, so confirm them against your own customEvents first:
code/pipeline/cost_latency.kqllet window = 2h;let turns = customEvents| where timestamp > ago(window)| where name in ("BotMessageReceived", "BotMessageSend")| extend conv = tostring(customDimensions["conversationId"]), role = iif(name == "BotMessageReceived", "in", "out")| order by conv asc, timestamp asc| serialize| extend next_ts = next(timestamp), next_role = next(role), next_conv = next(conv)| where role == "in" and next_role == "out" and conv == next_conv| extend latency_s = datetime_diff("millisecond", next_ts, timestamp) / 1000.0;let fallback = customEvents| where timestamp > ago(window) and name == "TopicStart"| summarize fallback_rate = countif(tostring(customDimensions["TopicName"]) has "Fallback") * 1.0 / count();turns| summarize p95_latency_s = percentile(latency_s, 95), turns = count()| extend fallback_rate = toscalar(fallback)
cost_latency_gate.py in the code folder runs this query through the Log Analytics query API for the candidate and the baseline, and applies the three triggers.
Flaky is a finding, not a nuisance
In conventional suites, a flaky test is a test defect: fix it or quarantine it. In agent suites, a case that passes 7 times out of 10 is usually telling you something true about the agent. Our policy:
- Never delete or skip a flaky critical case. A 70% pass rate on a critical case is a defect.
- Investigate flaky non-critical cases by the variation, not by the failure. Run 20 trials, cluster the responses, and look at what’s different in the failing cluster. It’s usually an ambiguous description or a retrieval tie between two documents.
- Track flakiness over time. A case whose pass rate slowly falls from 100% to 90% across releases is drift you’d otherwise miss.
AI in the loop: this Episode
- Let a model summarise a red build. Give it the failing cases, expected outcomes and actual responses, and ask it to group failures by likely root cause: routing, retrieval, wording or tool arguments. It saves real time on a 40-failure model-swap run. The engineer still confirms each group.
- Use AI to draft targeted fixes, then test them. “Suggest the smallest instruction change that restores the FOS wording without affecting other topics” is a good prompt. The golden set decides whether the suggestion works.
- Remember the judge drifts too. If your graders use an LLM, a judge model change is a model change. Recalibrate (Episode 4) before trusting gate results after it.
Try this Episode
- Subscribe a team list to Copilot Studio Message Center posts, and write down who triages them and within how many hours.
- Script one test set run through the Evaluation API and print the run payload. Map the per-case fields in your tenant.
- Add per-category gating to your pipeline and run it against your last three builds. Find out whether any category was quietly regressing while the average stayed green.
Further reading
- Automate agent evaluations with the Power Platform API.
- Quality gates for Copilot Studio in Azure DevOps: Power CAT’s end-to-end pipeline walkthrough.
- Automate agent evaluation with the Evaluation APIs.
- Use the Agent Review Pipeline as a CI/CD gate.
Next Episode: the security half of the series begins, with what happens when data becomes instructions.
