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 ( |
|
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: |
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 |
Refuses to start a new run while another is still active (the agent shares one Chromium / one emulator — concurrent runs corrupt state). The UI greys out the Run Selected button while a run is in flight. |
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)
QA Notes Pipeline
Team-authored behavioural notes flow into prompts through a separate pull-pattern indexed alongside the spec corpus:
UI Save ──► POST /api/notes
├── /data/qa-notes/<id>.yaml (atomic write, source of truth)
└── webhook POST knowledge-mcp/admin/refresh-notes
└── QaNotesIndex.refresh()
├── GET test-runner:8083/api/notes/raw
├── (id, version, payload-hash) diff
└── embed + upsert into Qdrant ft_qa_notes
Run / Reimport
├── converter Stage 2 (`_build_qa_notes_section`) ─► search_qa_notes
├── LLM verify (`_fetch_qa_notes_block`) ─► search_qa_notes
└── interact loop proactive tool ─► mcp__knowledge__search_qa_notes
Failure of any leg degrades gracefully — the prompt is built as if the note didn’t exist. See QA Validation Notes for the user-facing workflow and the scope-filter semantics.
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
│ ├── 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 from the rendered page
└── LLM fallback — Claude reads page text + expected value
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 — nav, interact,
phase, emulator_action, acs_task, db_query, log_check,
spa_task_verify, cred_length_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, 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 five MCP servers. Four are remote (mcp-tr-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 / bbf-tree-mcp
sidecars (an env’s emulator is pinned via the gateway-provisioned
mcp-tr-emul-<env> wrapper), 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). When a team-authored QA note matches the test’s scope (test_id /kpi_type/wifi_health_topo), the converter appends a QA Validation Notes section to the LLM prompt via_build_qa_notes_section— see QA Validation Notes. Bumping_CONVERSION_CACHE_VERSIONinscripts/test_server.pyinvalidates every cached entry on the next reimport (used when a new note is significant enough that older YAMLs should reflect it). -
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.
|