Architecture
Engineer-oriented overview of how the AI Test Agent is wired internally.
Components
In the multi-stand deployment the container described here is one of several interchangeable workers sitting behind a central gateway (see Multi-Stand Gateway). Each worker is one container running several long-lived processes plus on-demand subprocesses:
| Process | Role |
|---|---|
|
HTTP / WebSocket gateway. Hosts the UI, exposes the REST API, manages the in-memory run registry, persists batch reports, runs the TestRail auto-sync loop and brokers MCP connections. |
|
The actual test executor. Loads the YAML, builds the system prompt, drives
the Playwright MCP via deterministic JS for |
Claude Code CLI (Max subscription) |
Used as a subprocess by |
Playwright MCP (Node, |
Drives Chromium running under Xvfb. Started via the |
Xvfb + x11vnc + noVNC ( |
Headed Chromium displayed via VNC for live debugging. |
|
Wraps the TR-069 emulator HTTP API as MCP tools ( |
|
Wraps the IoT device emulator REST API (USP/TR-369, LwM2M, MQTT …) as MCP
tools ( |
|
Synthesises TR-181 / TR-098 trees from natural language (used by the
Editor’s Generate Tree button) and also serves the converter Stage 2 LLM
prompt with TR-181 spec snippets via |
|
Stateless retrieval over three Qdrant collections: * The converter reads it during Stage 2 LLM enrichment.
The |
Multi-Stand Gateway
The worker above is normally fronted by a single public gateway
(gateway_service/, port :8083 — the only port exposed to the outside).
The gateway owns identity and routing; the workers do the actual test work and
are not published to the host (they are reached over the test-runner-net
Docker network). One gateway, N interchangeable workers, many env profiles.
| Module | Role |
|---|---|
|
FastAPI front door. Central auth (login → an opaque bearer session token, plus an in-memory login throttle), per-request stand / worker resolution, and all local routes (auth, users, profiles, stands, run dispatch, fan-out reads) registered before the catch-all reverse-proxy so they win. |
|
HTTP reverse-proxy helper. Strips hop-by-hop and client-supplied identity
headers, re-injects the gateway-owned |
|
Picks a free worker for each run and materializes the chosen env’s settings
onto it before dispatch. Exclusivity is keyed on the env’s physical emulator
( |
WS / noVNC relay ( |
|
|
Portainer auto-provisioning. Creates per-worker |
Per-run reads, batch status / reports and stop are routed to (or fanned out across) the worker that actually owns the caller’s run — owner-scoped, as the caller — so a run that lands on a non-primary worker is still visible and stoppable, and one account can never see or stop another’s run. See Environments & the Gateway for the deployment topology and day-to-day operations.
Persistence Layer
The gateway and the workers share two small SQLite data layers (pure data
modules — no FastAPI imports, unit-testable in isolation) in a single file at
run-reports/test_runner.db:
| Module | Tables |
|---|---|
|
|
|
|
Data Flow
Conversion (TestRail → YAML)
TestRail REST → api_case_to_xml_element()
→ convert_case() | convert_case_scenarios() (dual code path)
→ Stage 1: regex extraction of params from preconditions / steps
→ Stage 2: LLM enrichment (Sonnet/Opus) — fills missing TR-181 paths
→ Stage 3: score_simulator.py — Python port of backend KPI math,
verifies that resulting parameters reach the expected
widget value; retries with adjusted params if not
→ mesh-interference tree auto-switch (when applicable)
→ _normalize_optical_scale post-processing
→ write tests/cases/<case>.yaml
The converter has two independent code paths: convert_case() for simple
single-scenario tests and convert_case_scenarios() for multi-phase tests.
They build phases independently — fixes must be applied to both.
Run-Time Pre-Flight
Before each run, test_server.py performs three checks. Their output is
visible in the Output pane of the Runner tab and on the WebSocket stream:
| Check | Behavior |
|---|---|
Tree coverage ( |
Reads the test’s |
ClickHouse purge |
Wipes the per-device monitoring rows across all 9 |
Concurrency guard |
A worker runs one test at a time — it shares one Chromium and one emulator, so
concurrent runs corrupt state. Under the gateway this is enforced one level up:
the scheduler holds a lock keyed on the env’s physical emulator
( |
Infra-blocked gate |
A case whose infrastructure precondition is absent on this stand is recorded
as a finished run with status |
kpi_type is set automatically by the converter and stored at the top
level of the YAML (test_case.kpi_type). Hand-authored YAMLs that omit it
fall back to a generic preflight that only checks tree_file exists and is
non-empty.
|
Anti-Flap (PeriodicInform scoping)
After ensure_online, _run_emulator_setup may disable
Device.ManagementServer.PeriodicInformEnable for the duration of the test.
This is not unconditional — the disable applies only when the test
contains CREATE_MONITORING in its ui_preconditions or any
phases[*].preflight.
Why scoped:
-
Monitoring tests read state through ACS GPV polls. The autonomous Periodic Inform every
PeriodicInformInterval(~90 s) carries a different param subset than the GPV. ACS cache-merges them (Not all data received → putting to cache → merging),NetworkProcessoroscillates between client-count states, and per-client Health varies between recomputes. The original symptom was C7626 failing with Health=70 after a long predecessor (e.g. C7625, ~14 min) in the same batch. Disabling the autonomous Inform makes monitoring GPV the single source of param updates and removes the flap. -
Diagnostic tests (
RUN_DIAGNOSTICS,RUN_INTERFERENCE_DIAGNOSTICS, the UI "Run diagnostics" button) and plain UI flows depend on the CWMP Inform cycle. ACSgetDiagnosticRoot()returns null without periodic param discovery, and post-SPV state Informs stop firing on schedule. The earlier unconditional disable regressed C7628 / C20436; the scoped fix restored them.
Restoration is automatic between tests: the next run restarts the emulator
with a fresh tree and PeriodicInformEnable=true returns. Do not add an
in-test re-enable.
Conversely, execute_phase_step forces a Periodic Inform after
set_params_batch for non-WiFi-config phases so backend monitoring sees
the new state without waiting up to a full PeriodicInformInterval for
the next pull. WiFi-config phases skip this because their SPV already
triggers a Value Changed Inform on its own.
The setup pane logs one of these lines so the path is auditable:
[emulator] PeriodicInform disabled (anti-flap, monitoring test)
[emulator] PeriodicInform left enabled (no CREATE_MONITORING in test)
Execution (YAML → report)
YAML
├── emulator_setup
│ ├── tree_file → factory_reset + push_tree (mcp-tr-emul)
│ ├── delete_objects, instance_limits, wifi_client_limit (CWMP DeleteObject)
│ ├── parameters → set_parameters_batch
│ ├── ui_preconditions → FtApiClient REST (monitoring / group / diagnostics)
│ └── phases[0] → applied if present
│
├── steps (grouped by type)
│ ├── nav segment → nav_script_gen.py → batched Playwright JS via browser_run_code_unsafe
│ ├── interact segment → snapshot-action loop driven by Claude CLI
│ └── phase step → execute_phase_step() — applies phases[i+1] params via emulator
│
└── verify
├── programmatic — DOM scan / REST read / typed assertion
├── LLM fallback — Claude reads page text + expected value
├── evidence gate — the LLM verdict must quote the capture
└── verdict source — stamp who judged each step
Verdicts: What Decided the Step
Two small modules turn "the runner said PASS" into something a reader can check.
scripts/evidence_gate.py holds the contract handed to the judge: quote,
verbatim, the page text that decides the claim, or answer UNVERIFIED. It then
parses the answer back and checks the quote actually occurs in what the judge
was shown — an invented quote is not evidence. The outcome is asymmetric on
purpose, matching the runner’s existing rule that a false red is cheap while a
false green is not:
-
a PASS keeps its green only when its quote is grounded; otherwise the step becomes
unverifiedand the run reportsinconclusive; -
a FAIL is applied whether or not it quoted well — the reason is reported and a human reads it.
A verdict a helper measured is exempt: descriptions prefixed [verify],
[polling] or [empty-state], and any step already stamped programmatic,
are never downgraded, because the judge having nothing to quote says nothing
about that measurement. Rollback switch: FT_VERIFY_EVIDENCE=0 — it changes the
consequence only, the contract is still sent.
scripts/verdict_source.py stamps every step with a judge:
programmatic (a *_verify helper, a typed assertion, a KPI compare, a polling
match, the nav executor failing to find an element), keyword (the expected
text’s words found in the page), llm (Claude read a snapshot and decided), or
none (nothing was asserted). finalize() fills in whatever the verdict sites
did not stamp, from the description markers and the step type, always erring
on the weaker source — the report must never claim more rigour than there was.
A run’s judge is the weakest among its asserting steps.
The run status folds as FAIL > INCONCLUSIVE > PASS, and both facts reach the
UI: the amber pill, the judge tag, the Measured passes KPI and the
Pass · not measured filter all read these two fields.
Failure Artifacts
On the first failing step the runner keeps one viewport image per run
(FT_FAILURE_SCREENSHOT, on by default). It also keeps the device parameter
tree (FT_FAILURE_TREE_SNAPSHOT, on by default) when the run applied parameters
to the emulator. Both files are
written next to the run report as <run_id>-failshot.png or .jpg and
<run_id>-failtree.*. The runner serves them to the owner from
GET /api/runs/{run_id}/failure-screenshot|failure-tree. The screenshot is kept
on failed. The tree is kept on failed or error.
The failure image has two sources. The run field failure_screenshot_source
names the source.
-
Source
step: the script photographs every step with an expectation right after the step (FT_STEP_SCREENSHOT, on by default). It writes/tmp/ft-stepshots/<run_id>/step-<N>.jpgand marks the step result withshot. The runner keeps the image of the first failed step as a.jpg. -
Source
live: the runner photographs the browser when the failed step is reported. It keeps this.pngwhen the first failed step has nostepimage.
The runner uses a step image only when the final result of that step carries
the mark shot. A repeated segment therefore never reuses an old image. Before
each send, the runner also deletes the old images of the segment.
The first inconclusive step gets its picture from the same step images. The
runner keeps it as <run_id>-inconclusive.jpg and serves it from
GET /api/runs/{run_id}/inconclusive-screenshot. The script never photographs
a screen with a secret, such as the test mailbox settings.
The runner deletes the temporary folder when the run ends. At startup, it
deletes folders older than six hours. It deletes a folder only when the name is
a run id and the folder holds nothing but step-<N>.jpg files. It never follows
a link. The folder root /tmp/ft-stepshots is fixed.
FT_FAILURE_SCREENSHOT=0 switches off the failure image of both sources.
FT_STEP_SCREENSHOT=0 switches off the step images and the inconclusive image.
Two-Mode Routing (deterministic vs agentic)
run_test.py decides how to execute a test from its step types
(run_test.py:64-124). A single tuple, KNOWN_STEP_TYPES, is the one source
of truth for every deterministic ("two-mode") step type. It currently holds
23: nav, interact, phase, emulator_action, acs_task, db_query,
log_check, spa_task_verify, cred_length_verify, chat_ask, ai_step,
explore, and the eleven programmatic verifiers —
duplicate_user_verify, unused_models_scope_verify, file_on_server_verify,
device_file_transfer_verify, user_group_permission_verify,
ext_system_scope_verify, domain_setting_scope_verify,
ext_system_readonly_verify, device_domain_move_verify, event_urls_verify,
group_update_import_verify. OWN_SEGMENT_STEP_TYPES is derived
from it (everything except nav / interact) so each such step always gets
its own segment instead of being grouped with a same-type neighbour.
should_use_two_mode(steps) is the gate:
-
all steps are
nav, ornavmixed with any other recognized type → two-mode: the steps are grouped into segments and each runs through its deterministic handler (nav→ generated Playwright JS viabrowser_run_code_unsafe, the others → their typed executors), withinteractverify falling back to Claude only inside its own segment. -
any unrecognized step type, or no
navstep at all → the whole test falls through to the fully-agentic path where Claude drives every step.
a step type wired into the segment dispatch but missing from
KNOWN_STEP_TYPES silently regresses the entire test to agentic mode (the
C9614 spa_task_verify regression) — add new deterministic types in that one
place. Because deterministic phrasing is what keeps a step in two-mode, how a
TestRail case is written directly controls which path it takes — see
Navigation Grammar.
|
MCP Integration
The agent talks to six MCP servers. Five are remote (mcp-tr-emul,
mcp-iot-emul, bbf-tree-mcp, qdrant-mcp, knowledge-mcp) and one is local
to the same container (playwright-mcp).
This 5-MCP wiring is per worker behind the gateway: every test-runner worker
has its own local playwright-mcp and its own mcp-tr-emul / mcp-iot-emul /
bbf-tree-mcp sidecars (env-agnostic: the runner passes the env’s emulator URL
on every call), while qdrant-mcp and knowledge-mcp are shared
singletons that all workers point at.
| Server | Used in | Key tools |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
Editor → Generate Tree button ( |
|
|
Legacy |
direct Qdrant collection access ( |
|
Stateless retrieval over |
|
Smart Converter
scripts/convert_testrail_xml.py (~12 000 lines). Three deterministic stages
with structured retries:
-
Stage 1 — regex extraction. Scans preconditions and step text for recognisable patterns ("RSSI = -55", "OperatingStandard is 'ax'") and produces a draft
parametersmap. -
Stage 2 — LLM enrichment. If Stage 1 leaves gaps, prompts Claude with the test text plus TR-181 spec snippets retrieved from
bbf-tree-mcp/knowledge-mcp(and, when explicitly enabled viabackend_rag=true, Java snippets from the legacyft_codeQdrant collection), asking for additional TR-181 paths to satisfy the expected value. Stage 2 is gated by an explicit skip-LLM whitelist (_should_skip_stage2) for KPI types that are fully solvable by Stage 1 — this keeps deterministic tests from inheriting LLM jitter. An idempotency cache keyed on the converter inputs short-circuits identical re-conversions; bypass it with Force refresh in the TestRail modal (orforce_refresh=trueon the API). Bumping_CONVERSION_CACHE_VERSIONinscripts/test_server.pyinvalidates every cached entry on the next reimport. -
Stage 3 — Score Verification. Runs
score_simulator.py(Python port of the backendNetworkScoreProcessor/WlanScoreCalculator/ etc.) against the candidate parameters. If the simulated score is within tolerance of the expected widget value, the YAML is committed. Otherwise the converter adjusts and retries.
| The TestRail expected value is the source of truth — never edited. If the math says the expected is unreachable on the current backend (integer division, overlapping ranges), the converter flags the case in the import report instead of silently baking a workaround. |
Persistent State
| Path | Volume |
|---|---|
|
|
|
|
|
|
|
host bind-mount (shared with |
|
host bind-mount (Claude CLI OAuth credentials) |
Under the multi-stand gateway each worker gets its own persistent
volumes — test-cases-<STAND>, run-reports-<STAND>, qa-notes-<STAND> and a
per-stand /opt/mcp-shared-<STAND> serial-isolation bind — so stands never
share test cases, reports or device state. The Claude CLI OAuth volume
(/root/.claude) is the exception: it is mounted shared read-write across
all workers so a single token refresh propagates everywhere. Every worker runs
with TRUST_GATEWAY=1 (it skips its own login and trusts the gateway’s
forwarded X-Forwarded-User identity), and its GATEWAY_SECRET must match
the gateway’s — a mismatch makes the worker reject every forwarded request.
|
After converter changes, reimport the affected cases — the
test-cases volume is not wiped on redeploy.
|