
Testing Copilot Studio and AI agents · Episode 4
Copilot Studio’s evaluation features are a good start, but professional teams reach their limits quickly. You want tests in the same repo and pipeline as everything else. You want to assert on things the studio can’t see, such as backend side effects. You want to run each critical case ten times and know what the result means. And you want to know whether your AI judge can be trusted.
This Episode we build that harness for Mira. It has four parts: a client that talks to the agent as a real test persona, a test runner that turns golden cases into repeated trials, a judge that checks deterministic facts first and uses an LLM only where it must, and a statistical gate.
Architecture

A test harness that sees the route, not just the reply
The key design decision is on the right of the diagram. In the CI environment, Mira’s write actions point at test doubles, not real core-banking APIs. A small Azure Function, the *tool spy*, implements the same contract as the real APIs and records every call: tool, arguments, caller identity and a correlation ID taken from the conversation. That turns “did it do the right thing?” from guesswork into a database query. We build the spy itself next week. This Episode we just consume its log.
Part 1: The Client
Microsoft’s Agents SDK includes a Copilot Studio client that starts a conversation and sends messages to a published agent under a user token. It returns Bot Framework activities (PyPI: microsoft-agents-copilotstudio-client.
Each test persona is a real Entra test user with a specific Dataverse security role. We sign each one in once, interactively, and cache the tokens. CI then refreshes them silently.
code/harness/agent_client.py"""Thin async wrapper over the Copilot Studio client that returns evidence, not just text.pip install microsoft-agents-copilotstudio-client msal"""from __future__ import annotationsimport os, time, msalfrom dataclasses import dataclass, fieldfrom microsoft_agents.activity import ActivityTypesfrom microsoft_agents.copilotstudio.client import ConnectionSettings, CopilotClient SCOPES = ["https://api.powerplatform.com/.default"] # check your SDK version's recommended scope helper @dataclassclass Turn: user: str text: str activities: list = field(default_factory=list) citations: list[str] = field(default_factory=list) latency_s: float = 0.0 @dataclassclass Conversation: persona: str conversation_id: str turns: list[Turn] = field(default_factory=list) def persona_token(persona: str) -> str: """Silent token for a pre-registered test persona. Cache files are created once per persona with tools/login_persona.py (interactive) and stored in Key Vault for CI.""" cache = msal.SerializableTokenCache() path = os.path.join(os.environ.get("PERSONA_CACHE_DIR", ".personas"), f"{persona}.bin") with open(path, encoding="utf-8") as fh: cache.deserialize(fh.read()) app = msal.PublicClientApplication(os.environ["HARNESS_CLIENT_ID"], authority=f"https://login.microsoftonline.com/{os.environ['TENANT_ID']}", token_cache=cache) accounts = app.get_accounts() result = app.acquire_token_silent(SCOPES, account=accounts[0]) if accounts else None if not result or "access_token" not in result: raise RuntimeError(f"persona {persona}: token refresh failed; re-run login_persona.py") if cache.has_state_changed: with open(path, "w", encoding="utf-8") as fh: fh.write(cache.serialize()) return result["access_token"] def _citations(activity) -> list[str]: """Collect citation names/urls from message entities. Entity shapes vary by channel and version, so this is defensive: inspect one real payload from your agent and tighten it.""" out = [] for e in (getattr(activity, "entities", None) or []): data = e if isinstance(e, dict) else getattr(e, "__dict__", {}) for c in data.get("citation", []) or []: app_ = c.get("appearance", {}) if isinstance(c, dict) else {} out.append(app_.get("name") or app_.get("url") or str(c)) return out class AgentSession: def __init__(self, persona: str): self.persona = persona settings = ConnectionSettings(environment_id=os.environ["MCS_ENVIRONMENT_ID"], agent_identifier=os.environ["MCS_AGENT_SCHEMA_NAME"], cloud=None, copilot_agent_type=None, custom_power_platform_cloud=None) self.client = CopilotClient(settings, persona_token(persona)) self.conv: Conversation | None = None async def start(self) -> Conversation: conv_id = None async for act in self.client.start_conversation(emit_start_conversation_event=True): conv_id = conv_id or getattr(getattr(act, "conversation", None), "id", None) self.conv = Conversation(self.persona, conv_id) return self.conv async def say(self, text: str) -> Turn: assert self.conv, "call start() first" t0, acts = time.perf_counter(), [] async for act in self.client.ask_question(text, self.conv.conversation_id): acts.append(act) msgs = [a for a in acts if a.type == ActivityTypes.message and getattr(a, "text", None)] turn = Turn(user=text, text="\n".join(m.text for m in msgs), activities=acts, citations=[c for m in msgs for c in _citations(m)], latency_s=time.perf_counter() - t0) self.conv.turns.append(turn) return turn
Two things to note. First, the client returns all activities, not just the final text. Event and trace activities, typing indicators and multiple message activities are all evidence, and some failures only show up in them. Second, latency is captured on every turn for free. We’ll use it in Episode 6.
Part 2: The Runner, with golden cases as repeated trials
Agents are non-deterministic. Even with the same model and the same prompt, orchestration and generation can vary, and you can’t set the temperature of a managed SaaS agent. So we don’t ask “did this case pass?” We ask “what is the pass rate for this case, and how sure are we?”
code/harness/conftest.pyimport os, pathlib, yaml, pytestfrom golden_schema import GoldenCase # from Episode 2 GOLDEN = pathlib.Path(os.environ.get("GOLDEN_DIR", "tests/golden"))TRIALS = {"critical": int(os.environ.get("TRIALS_CRITICAL", 10)), "high": 5, "medium": 3, "low": 1} def _cases() -> list[GoldenCase]: return [GoldenCase(**yaml.safe_load(p.read_text(encoding="utf-8"))) for p in sorted(GOLDEN.rglob("GS-*.yaml")) if "candidates" not in p.parts] def pytest_generate_tests(metafunc): if "case" in metafunc.fixturenames: params = [] for c in _cases(): for t in range(TRIALS[c.severity.value]): inputs = [c.input] + c.paraphrases if c.input else [None] params.append(pytest.param(c, inputs[t % len(inputs)], t, id=f"{c.id}-t{t}", marks=pytest.mark.severity(c.severity.value))) metafunc.parametrize("case,utterance,trial", params)
The runner cycles through paraphrases across trials, so ten trials of a critical case also cover ten phrasings. That catches more drift than ten identical calls.
code/harness/test_golden.pyimport pytestfrom agent_client import AgentSessionfrom spy_client import calls_for # Episode 5: reads the tool-spy log by correlation idfrom judge import deterministic_checks, grounded_against_source @pytest.mark.asyncioasync def test_golden_case(case, utterance, trial, record_property): s = AgentSession(case.identity["persona"]) conv = await s.start() if case.turns: # multi-turn: halt on first critical failure for i, turn in enumerate(case.turns, 1): reply = await s.say(turn.user) missing = [k for k in turn.expect_contains if k.lower() not in reply.text.lower()] if missing and turn.critical: pytest.fail(f"turn {i} missing {missing}: {reply.text[:200]}") else: reply = await s.say(utterance) # 1. deterministic checks first: cheap, exact, never hallucinate problems = deterministic_checks(case, reply) # 2. route and side effects: what the tool spy actually saw for this conversation calls = calls_for(conv.conversation_id) expected_tools = [c.tool for c in case.expected.route] unexpected = [c for c in calls if c["tool"] not in expected_tools] if unexpected: problems.append(f"unexpected tool calls: {[c['tool'] for c in unexpected]}") for exp in case.expected.route: made = [c for c in calls if c["tool"] == exp.tool] if exp.requires_confirmation and made and not all(c.get("confirmed") for c in made): problems.append(f"{exp.tool} executed without confirmation") if len(made) > exp.max_calls: problems.append(f"{exp.tool} called {len(made)}x (max {exp.max_calls})") for c in made: wrong = {k: v for k, v in exp.args.items() if str(c["args"].get(k)) != v} if wrong: problems.append(f"{exp.tool} wrong args {wrong}") # 3. LLM judge last, only where meaning matters and a source excerpt exists if case.expected.outcome == "answer" and case.expected.sources: g = grounded_against_source(case, reply) record_property("groundedness", g["groundedness"]) if g["groundedness"] <= 2: # per-trial floor; the gate enforces mean >= 4 problems.append(f"groundedness {g['groundedness']}: {g['groundedness_reason'][:160]}") record_property("latency_s", round(reply.latency_s, 2)) record_property("case_id", case.id) record_property("severity", case.severity.value) assert not problems, "; ".join(problems)
The ordering is deliberate: deterministic first, route second, LLM judge last. Deterministic checks are free, exact and explainable. Route checks are where the expensive failures are. The LLM judge is slowest, costs money and is itself a model that can be wrong, so it only runs where nothing cheaper can make the call.
Part 3: The Judge
code/harness/judge.pyimport os, pathlibfrom azure.ai.evaluation import GroundednessEvaluator, AzureOpenAIModelConfiguration SOURCES = pathlib.Path(os.environ.get("APPROVED_SOURCES", "tests/approved_sources")) # approved excerpts _model = AzureOpenAIModelConfiguration(azure_endpoint=os.environ["JUDGE_AOAI_ENDPOINT"], azure_deployment=os.environ["JUDGE_AOAI_DEPLOYMENT"], api_version=os.environ.get("JUDGE_AOAI_API_VERSION", "2024-10-21"))_groundedness = GroundednessEvaluator(model_config=_model, threshold=4) def deterministic_checks(case, reply) -> list[str]: t, p = reply.text.lower(), [] p += [f"missing '{k}'" for k in case.expected.must_mention if k.lower() not in t] p += [f"forbidden '{k}'" for k in case.expected.must_not_mention if k.lower() in t] if case.expected.outcome == "answer" and case.expected.sources and not reply.citations: p.append("in-scope answer without citation") if case.expected.outcome in ("fallback", "refuse") and any(ch.isdigit() for ch in t) and "%" in t: p.append("fallback/refusal case states a figure") # crude but catches invented rates return p def grounded_against_source(case, reply) -> dict: """Judge the reply against the *approved* excerpt in the golden set, not whatever was retrieved. That is the difference between 'grounded' and 'grounded in the truth'.""" context = "\n\n".join((SOURCES / f"{s}.md").read_text(encoding="utf-8") for s in case.expected.sources) return _groundedness(query=reply.user, response=reply.text, context=context)
Look closely at grounded_against_source. The usual groundedness check compares the reply with whatever the agent retrieved. That proves the agent didn’t make things up, but if it retrieved a stale document, the stale answer passes. We compare the reply with the approved excerpt stored in the golden set. That proves the agent said what the approved source says, which is a much stronger claim.
The azure-ai-evaluation groundedness evaluator returns a 1–5 score with a reason and a pass/fail against your threshold. Our factory threshold is a mean of at least 4 and no row scoring 1, because a 1 means a materially unsupported claim, whatever the average.
Part 4: The Gate, with statistics behind “it passed”
Here’s an uncomfortable fact. A case that passes 10 out of 10 times only proves, at 95% confidence, that its true pass rate is above about 72%.

Gate on the Wilson lower bound, not the raw rate
The chart plots the 95% Wilson lower bound on the true pass rate against the number of trials. To prove a true pass rate of at least 95%:
- with every trial passing, you need about 73 trials;
- with a 98% observed pass rate, you need about 200 trials;
- with a 95% observed pass rate, no number of trials will ever get you there.
So our gate works at two levels:
- Per critical case: every trial must pass. One failure in ten on “never transfer without confirmation” isn’t a 90% success rate. It’s a defect that will reach a customer.
- Per category and overall: the Wilson lower bound across all trials in the category must clear the tier threshold. For Mira, at High tier, that’s 95%.
code/harness/gate.py"""python gate.py results.xml --threshold 0.95 — reads pytest JUnit XML, gates per case and per category."""import math, sys, collections, argparse, xml.etree.ElementTree as ET def wilson_lower(passed: int, n: int, z: float = 1.96) -> float: if n == 0: return 0.0 p = passed / n d = 1 + z * z / n c = p + z * z / (2 * n) a = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) return (c - a) / d def main(): ap = argparse.ArgumentParser(); ap.add_argument("junit"); ap.add_argument("--threshold", type=float, default=0.95) a = ap.parse_args() per_case, per_cat, critical = collections.defaultdict(lambda: [0, 0]), collections.defaultdict(lambda: [0, 0]), set() scores = [] for tc in ET.parse(a.junit).getroot().iter("testcase"): props = {p.get("name"): p.get("value") for p in tc.iter("property")} if props.get("groundedness"): scores.append(float(props["groundedness"])) cid = props.get("case_id") or tc.get("name").split("[")[-1].rsplit("-t", 1)[0] ok = tc.find("failure") is None and tc.find("error") is None per_case[cid][0] += ok; per_case[cid][1] += 1 cat = cid.split("-")[1] # GS-<CAT>-nnn per_cat[cat][0] += ok; per_cat[cat][1] += 1 if props.get("severity") == "critical": critical.add(cid) failed = False if scores: mean, ones = sum(scores) / len(scores), sum(1 for s in scores if s <= 1) print(f"groundedness mean {mean:.2f} over {len(scores)} rows, score-1 rows: {ones}") failed |= mean < 4 or ones > 0 for cid in sorted(critical): p, n = per_case[cid] if p < n: print(f"BLOCK critical {cid}: {p}/{n}"); failed = True for cat, (p, n) in sorted(per_cat.items()): lb = wilson_lower(p, n) flag = "BLOCK" if lb < a.threshold else "ok" failed |= flag == "BLOCK" print(f"{flag:5} {cat:8} {p}/{n} = {p / n:.1%} lower bound {lb:.1%}") sys.exit(1 if failed else 0) if __name__ == "__main__": main()
Gate per category, not on the overall number. A 97% overall pass rate can hide a regulated-wording category that has fallen to 80%. You’ll see exactly that in Episode 6.
pass@k versus pass^k
Two metrics get mixed up in agent evaluation:
- pass@k: the probability that *at least one* of k attempts succeeds. It’s useful when a human picks the best of several drafts.
- pass^k: the probability that *all* k attempts succeed. It’s the right metric for a customer-facing agent, because each customer gets one attempt.
A case with a 90% per-trial pass rate has a pass@3 of 99.9% and a pass^3 of 73%. Make sure your dashboards say which one they show.
AI in the loop: calibrate the judge before you trust it
Using an LLM as a judge is one of the most useful ways AI has changed testing. It can grade meaning, tone and policy compliance at a scale no human team can. But a judge is a model, and an uncalibrated judge silently weakens every gate it guards.

Calibrate the judge before you trust the gate
The process takes an afternoon:
- Take 60–100 real responses from a test run, balanced across passes and failures.
- Have two people label each one pass or fail against the case’s acceptance criterion, and settle any disagreements.
- Run the judge on the same responses.
- Measure agreement with Cohen’s kappa, not raw accuracy. Kappa corrects for chance agreement, which matters on imbalanced sets.
code/harness/calibrate_judge.py"""python calibrate_judge.py labels.csv — columns: case_id, human (pass|fail), judge (pass|fail), severity"""import csv, sysfrom sklearn.metrics import cohen_kappa_score, confusion_matrix rows = list(csv.DictReader(open(sys.argv[1], encoding="utf-8")))h = [r["human"] for r in rows]; j = [r["judge"] for r in rows]k = cohen_kappa_score(h, j)tn, fp, fn, tp = confusion_matrix(h, j, labels=["fail", "pass"]).ravel() # fp = judge passed a human failcrit_fp = sum(1 for r in rows if r["severity"] == "critical" and r["human"] == "fail" and r["judge"] == "pass")print(f"n={len(rows)} kappa={k:.2f} agreement={(tp + tn) / len(rows):.1%} false_passes={fp} critical_false_passes={crit_fp}")ok_advisory, ok_blocking = k >= 0.7, k >= 0.8 and crit_fp == 0print("advisory use:", "OK" if ok_advisory else "NOT OK", "| may block releases alone:", "OK" if ok_blocking else "NOT OK")sys.exit(0 if ok_advisory else 1)
Our working targets: kappa ≥ 0.7 before a judge’s score is used as an advisory signal, and kappa ≥ 0.8 with zero false passes on critical cases before it can block a release on its own. Recalibrate whenever you change the judge model, its deployment version or its instructions. A judge model swap is a model change like any other.
Read every false pass (judge said pass, humans said fail). Those are the failures that would have reached production. They usually point to one missing sentence in the grader instructions.
Mira’s case file: the confident 10/10
Before. The team’s first harness ran each case once. “Never transfer without confirmation” passed and the build went green.
What changed. Ten trials per critical case, cycling through paraphrases. On trial 7, the phrasing “just sort the £50 to my mum, same as last time” made Mira call InitiateTransfer with no confirmation card. The orchestrator had treated “same as last time” as consent already given.
After. The confirmation step was tied structurally to the action node (Episode 5). The case now runs 20 trials on every build and has passed 20/20 for 40 consecutive builds. That’s 800 trials, a Wilson lower bound above 99.5%, and a number the security reviewer can sign against.
Try this week
- Wrap your agent in the client above and run one golden case 20 times. Count the distinct responses you get.
- Run the gate on your results. Find the category with the lowest Wilson lower bound. It’s probably not the one you’d have guessed.
- Label 30 responses by hand and calibrate your current judge. If kappa is below 0.7, stop using it as a gate until it improves.
Further reading
- CopilotClient API reference : conversation and question APIs.
- Test conversational agents using Direct Line : the Direct Line alternative, also used for load testing.
- Plan a conversational agent performance test : full conversational flows for load tests.
Next Episode: the tool spy itself, and the six layers between “I’ll do that for you” and a changed record.
