Code-first: a Pytest Harness for Agents

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 annotations
import os, time, msal
from dataclasses import dataclass, field
from microsoft_agents.activity import ActivityTypes
from microsoft_agents.copilotstudio.client import ConnectionSettings, CopilotClient
SCOPES = ["https://api.powerplatform.com/.default"] # check your SDK version's recommended scope helper
@dataclass
class Turn:
user: str
text: str
activities: list = field(default_factory=list)
citations: list[str] = field(default_factory=list)
latency_s: float = 0.0
@dataclass
class 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.py
import os, pathlib, yaml, pytest
from 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.py
import pytest
from agent_client import AgentSession
from spy_client import calls_for # Episode 5: reads the tool-spy log by correlation id
from judge import deterministic_checks, grounded_against_source
@pytest.mark.asyncio
async 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.py
import os, pathlib
from 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:

  1. 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.
  2. 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:

  1. Take 60–100 real responses from a test run, balanced across passes and failures.
  2. Have two people label each one pass or fail against the case’s acceptance criterion, and settle any disagreements.
  3. Run the judge on the same responses.
  4. 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, sys
from 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 fail
crit_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 == 0
print("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

  1. Wrap your agent in the client above and run one golden case 20 times. Count the distinct responses you get.
  2. 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.
  3. 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

Next Episode: the tool spy itself, and the six layers between “I’ll do that for you” and a changed record.


Leave a Reply