Drift, model swaps and CI/CD quality gates

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:

TriggerHow you detect itWhat rerunsOwner
Microsoft model or orchestration changeMessage Center, What’s new, model-availability pagesFull golden set + adversarial core, on a canary copyFactory lead
Agent or tool changePull requestAffected cases + critical subset (PR), full set (merge)Developer
Knowledge changeSharePoint/Dataverse change events, re-indexCases citing that source + poisoning checksKnowledge owner
Process or access changeRole or approval change ticketsPaired-persona and confirmation suitesPlatform admin
New threat or incidentAdvisories, SOC alerts, failed refusalsNew attack cases + targeted red teamSecurity
Time and trafficSchedule; quality, latency or cost anomalyScheduled 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:

OperationEndpoint (prefix https://api.powerplatform.com/copilotstudio/environments/{envId}/bots/{botId}/api/makerevaluation)
List test setsGET /testsets
Test set detailsGET /testsets/{testSetId}
Start a runPOST /testsets/{testSetId}/run
List runsGET /testruns
Run detailsGET /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, requests
from 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.yml
trigger: none
pr:
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:

  1. 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.
  2. 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 = 0
for 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 += 1
sys.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.kql
let 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

  1. Subscribe a team list to Copilot Studio Message Center posts, and write down who triages them and within how many hours.
  2. Script one test set run through the Evaluation API and print the run payload. Map the per-case fields in your tenant.
  3. 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

Next Episode: the security half of the series begins, with what happens when data becomes instructions.

Leave a Reply