Files
2026-07-13 13:12:00 +08:00

21 KiB

Harness test bench: a standardized capability conformance suite

A pluggable bench that, given a harness, empirically reports its verdict on every capability dimension in the harness support matrix — "is model switching available", "is steering possible", "does policy DENY actually block a call" — instead of a human hand-maintaining a spreadsheet and hoping it still reflects reality.

Status: shipped and in use. The bench on main has three transport drivers, six P0 probes, three report-only P1 probes, automatic live/offline selection, and a capability-derived matrix that has already caught and corrected real declaration drift. See Current state for what is live vs. still open. The sections before it describe the design and the decisions behind it.

Motivation

We maintain a capability matrix by hand (the native + SDK support spreadsheet). It drifts the moment a harness changes and nobody re-tests every cell. Worse, there are already three disagreeing sources of truth for any given capability:

  1. The spreadsheet — what a human believed at some point.
  2. The Executor capability flagssupports_streaming(), supports_live_message_queue(), supports_tool_boundary_interrupt(), supports_stepwise_internal_turns(), handles_tools_internally() in omnigent/inner/executor.py:541.
  3. omnigent/model_override.py — already encodes per-harness facts declaratively (_SDK_MODEL_OVERRIDE_HARNESSES, the _*_FAMILY_HARNESSES sets, single- vs multi-model rules).

The bench turns the matrix into an executable conformance suite that earns each cell by running a live turn and inspecting the event stream, and then reconciles observed behavior against the declared flags — so a flag that says but behaves becomes a test failure (a DRIFT verdict), not a production surprise.

Goals

  • One command produces the support matrix for a harness, with a verdict per dimension.
  • Adding a new official harness needs at most a one-line registry entry plus a self-declared profile — never per-probe code.
  • A community / out-of-repo harness that ships a bench profile can be probed with --harness <name> and no bench edits.
  • Detect drift between what a harness declares and what it does.

Non-goals

  • Not a replacement for the existing per-harness e2e tests (those assert specific behaviors deeply; the bench asserts breadth across dimensions).
  • Not a performance/latency benchmark. "Bench" here means conformance, not throughput.
  • The bench does not invent model ids, credentials, or transports. Facts it cannot infer must be self-declared by the harness (see BenchProfile).

Key constraint: registration is a hardcoded dict today

Harnesses register via a literal _HARNESS_MODULES: dict[str, str] mapping harness name to module path in omnigent/runtime/harnesses/__init__.py:34. There is no entry-point / plugin discovery mechanism. An out-of-repo harness cannot even register without editing that file — the same shared-file conflict pain tracked in #899, whose proposed fix was per-harness self-registration.

This constraint is what shapes the coupling decision below. It is not a limit on what the bench can probe: the probes are harness-agnostic. It is only a limit on how a harness gets discovered.

Update since this was written: entry-point plugin discovery now exists — harness_capabilities() merges contributions from the omnigent.community.harness entry-point group, and the bench derives everything from it. So the bench side of option B is realized: a plugin's harness flows in with no bench edit. The remaining hardcoded seam is not here — it is the server's native-agent seeding (see "Plugin seamlessness").

Decision: option B (registry-indexed now, profile-driven from day one)

Two coupling options were considered:

  • (A) Build the bench on dynamic discovery (entry points / plugin registry) now. True plug-and-play, but partly blocked on self-registration (#899) before out-of-repo harnesses can register at all.
  • (B) Index official harnesses from the current hardcoded registry now (one registry line + a profile per new harness), and swap enumeration to dynamic discovery when self-registration lands.

We chose B, with the critical rule that all per-harness facts live on a self-declared BenchProfile from day one — never in bench or probe code. The hardcoded list is just a convenience index of the official harnesses; it is not a gate on what the bench can probe.

This satisfies both use cases:

  • Official harnesses — already in _HARNESS_MODULES; the bench iterates the list and --harness <name> selects one.
  • Community harnesses--harness <name> resolution falls back (when the name is not in the registry) to any harness that exposes a BenchProfile (e.g. --harness mypkg.myharness, or a name resolved via an installed plugin). A ~10-line resolution shim, not the full discovery system.

The day self-registration lands, only the enumeration changes from "read the list" to "discover"; probes, profiles, and reports are untouched.

What is free vs. what a harness must provide

Free (no per-harness code):

  • Every behavioral probe — it creates a session, runs a turn, and inspects the generic event stream (TextChunk, ReasoningChunk, ToolCallRequest, TurnComplete, elicitation events). It never names a harness. Any harness the bench can launch is probed on every dimension; a dimension with no probe yet reports UNKNOWN.
  • Declared-flag reconciliation — reads the Executor capability methods that every harness inherits from the base class.

Must be self-declared on BenchProfile (the bench cannot infer these):

  • A test model (or model family) — a probe cannot invent a valid model id.
  • The CLI binary to skip-gate on (for subprocess / native harnesses).
  • The transport class (see transport drivers below).
  • The static columns the matrix records but cannot verify: Owner, Auth method, Implementation, "inherits preexisting configs", priority tier.

Derived by convention (not hand-authored): env_prefix (HARNESS_<NAME>_), marker (<NAME>_BENCH_OK).

Architecture

The implementation has three layers plus reporting:

tests/harness_bench/
  profile.py             # BenchProfile and profile-name resolution
  manifest.py            # official profiles derived from capabilities + e2e metadata
  verdict.py             # verdict vocabulary, priority, and drift reconciliation
  transport.py           # semantic Driver protocol and transport resolution
  driver.py              # sdk-inproc driver + shared TurnResult/usage helpers
  full_server.py         # shared server/runner lifecycle and registration
  full_server_driver.py  # full-server driver and session polling
  native_tui_driver.py   # native vendor CLI + host-daemon/tmux driver
  session_items.py       # shared session-item envelope parsing
  runtime_env.py         # config/credential resolution matching `omni run`
  probes/                # one module per capability dimension
  events.py              # structured progress events and plain sink
  richreport.py          # optional live Rich matrix
  bench.py               # orchestration, concurrency, and shared-server wiring
  report.py              # terminal, Markdown, and JSON rendering

Reusable configuration and runtime primitives live in production modules such as omnigent.config, find_free_port, and the harness registry rather than being reimplemented under tests.

  • Layer 0 — Profile / manifest. Static facts and declared verdicts are derived from harness_capabilities() plus the existing e2e harness metadata.
  • Layer 1 — Offline conformance. No network or credentials. It validates registration, profile shape, capability derivation, transport resolution, rendering, and orchestration behavior in normal CI.
  • Layer 2 — Live probes. Drivers execute behavioral probes through the wrap boundary or the real server/runner session API. Missing credentials, vendor binaries, or vendor login produce capability-neutral skips.
  • Report. The CLI renders the declared matrix offline or reconciles live observations into terminal, Markdown, and JSON reports. DRIFT produces a non-zero exit status.

Build on HarnessProbe, don't reinvent it

tests/e2e/_harness_probes.py already gives per-harness rows (name, model, env_prefix, marker, cli_binary) and CLI-gating that every e2e test parametrizes over. BenchProfile should extend / subsume that row so adding a harness there flows into both the existing e2e suite and the bench.

Verdict vocabulary

Maps directly to the spreadsheet glyphs, plus two operational states and the drift alarm.

Verdict Glyph Meaning
SUPPORTED probe ran, behavior confirmed
UNSUPPORTED probe ran, capability absent (and expected absent)
PARTIAL ~ works with caveats (e.g. "TUI-only", "hook-DENY only")
NOT_APPLICABLE dimension does not apply (e.g. model override on agy self-select)
UNKNOWN ? never probed / no probe written yet
SKIPPED CLI / creds / transport unavailable in this environment
DRIFT !! observed verdict disagrees with the declared flag / manifest

Each dimension also carries a P0 / P1 priority (from the spreadsheet) so CI can gate on P0 and merely report P1.

Dimension catalog

Two classes.

Static / declared (recorded, not probed)

Validated for presence and shape only: Owner, Transport, Implementation, Auth method, Inherits preexisting configs.

Behavioral (proven by a live turn)

Dimension How the probe proves it
Basic turn (P0 prerequisite) complete a marker-echo turn and require assistant text
Streaming (P0) count output-text deltas; repeated single-delta output is PARTIAL
Tool calling (P0) provoke the transport's tool mechanism and require a surfaced call
Policy DENY (P0) apply a tool-call deny and require a blocked-call signal
Policy ALLOW (P1) attach an explicit allow and require a non-blocked tool output; native hooks expose no positive ALLOW event
Policy ASK (P1) apply ask and require an elicitation/approval request
Model override (P0) validate the requested harness/model pair and complete a turn
Cost tracking (P1) read priced cost or token usage from the turn/session
Interrupt (P0) interrupt a long turn and require cancellation or early termination

Planned dimensions are steering, live queue, resume/fork, reasoning, images, and compaction.

Every behavioral probe also reads the corresponding declared flag and returns DRIFT when observed disagrees with declared.

Illustrative probe shape

class StreamingProbe(CapabilityProbe):
    name = "streaming"
    priority = P0
    applies_to = BOTH

    def declared(self, profile) -> Verdict:
        return SUPPORTED if executor_of(profile).supports_streaming() else UNSUPPORTED

    async def run(self, driver, profile) -> ProbeResult:
        deltas = await driver.count_text_chunks("Write a 3-sentence story.")
        observed = SUPPORTED if deltas > 1 else PARTIAL  # "complete-only"
        return ProbeResult(
            observed,
            note=f"{deltas} text chunks",
            drift=reconcile(observed, self.declared(profile)),
        )

Transport drivers: the real ceiling on "all dimensions"

Behavioral probes call semantic driver methods such as run_basic_turn, run_tool_turn, run_policy_turn, and run_interrupt_turn. Drivers own the transport-specific mechanism; probes interpret a common TurnResult.

Three drivers exist:

  • full-server is the SDK-family default. It drives a real server and runner, uses a server-dispatched builtin for tool probes, and observes fixed ALLOW/ASK/DENY policies.
  • native-tui drives a resident vendor CLI in a runner-owned tmux pane through the server session API. It observes vendor tool calls and tool-call DENY via the native policy hook. ALLOW/ASK are not yet implemented.
  • sdk-inproc drives the harness wrap directly. It is selected by --fast and provides cheaper wrap-level coverage, but no server-side policy surface.

A SKIPPED verdict therefore means the behavior was not measurable in that transport or environment, not that the harness lacks the capability. A novel transport class still requires a driver, but harnesses reusing one of these families flow through the existing probes without per-harness probe code.

Current state (shipped)

The bench on main includes:

  • Six P0 probes: Basic turn, Streaming, Tool calling, Policy DENY, Model override, and Interrupt.
  • Three P1 probes: Policy ALLOW, Policy ASK, and Cost tracking. P1 verdicts are report-only and do not gate the same way as P0 declarations.
  • Three transport drivers: full-server, native-tui, and sdk-inproc, selected by harness family with --transport and --fast overrides.
  • Automatic live selection: without an explicit mode, the CLI runs live when credentials are resolvable and otherwise renders the declared matrix. --live and --no-live force either mode. Credentials are derived like omni run; --profile is only an override.
  • Concurrent execution and shared infrastructure: --jobs runs harnesses concurrently while preserving report order, and full-server harnesses share one server/runner pair within a run.
  • Structured progress and reports: plain or Rich live progress plus terminal, Markdown, JSON, and optional report-file output.
  • Capability-derived registration: official SDK and native profiles derive from harness_capabilities() and existing e2e metadata. Session-item parsing, config loading, free-port selection, and polling helpers are shared rather than duplicated.

Not yet wired

  • Registry-driven server seeding for community native UI agents.
  • Steering, live queue, resume/fork, reasoning, images, and compaction probes.
  • Automatic provisioning of vendor login/provider configuration for native harnesses; unavailable environments skip cleanly.

CI integration

  • Every PR: Layer 1 offline conformance (fast, no network, no creds).
  • Nightly / on-demand: Layer 2 live probes (real API cost + flake surface), gated on CLI + creds, P0 blocking, P1 report-only. Follows the existing nightly/flake-stress pattern rather than blocking every PR on live turns.

Running the bench and reading the result

# Declared matrix only, with no credentials.
python -m tests.harness_bench --no-live

# Auto-live when configured or ambient credentials are available.
python -m tests.harness_bench --harness codex

# Force a named profile and probe several harnesses concurrently.
python -m tests.harness_bench --profile oss --jobs 4 --rich

# A community harness that ships its own BenchProfile.
python -m tests.harness_bench --harness mypkg.harness:PROFILE --live

Without --live or --no-live, resolvable credentials select live mode and missing credentials select the offline declared matrix. Native harnesses also need their vendor CLI installed and logged in; the bench cannot provision those accounts, so unavailable harnesses skip without aborting the run.

Offline conformance covers every registered harness in CI. Live runs are spot-checks of observed behavior and can vary with model behavior and timing; re-run an isolated timeout or skip before treating it as a regression. The signals that matter most are DRIFT and repeatable unexpected UNSUPPORTED/PARTIAL verdicts on a runnable harness.

Streaming is a binary declared capability

A recurring subtlety worth stating: the streaming capability is binary — a harness either forwards token-level deltas (SUPPORTED) or it does not (UNSUPPORTED). PARTIAL is a probe observation only: the streaming probe returns it for the ambiguous coalesced-single-delta case against a SUPPORTED declaration. It is never a declared value. Declaring a non-streaming harness as PARTIAL drifts against reality, because the probe reports zero deltas as UNSUPPORTED, not PARTIAL.

Declare streaming=False only from a live observation of 0 deltas — a static "the forwarder posts no delta" grep is not sufficient. That grep once flipped seven natives to False in one batch; a live run then showed pi-native streams (7 deltas) despite having no delta-posting forwarder, so the flip was reverted. Only three natives are declared non-streaming today, each live-verified at 0 deltas: kiro-native, cursor-native, qwen-native. The rest default to streaming=True (the honest default: if one turns out not to stream, the bench flags a real drift on the next run, rather than a false False that silently drifts the moment the harness does stream).

Which transport exercises which dimension

Dimension sdk-inproc (--fast) full-server (SDK default) native-tui
Basic turn, Streaming, Model override, Interrupt Wrap-level observation End-to-end server/runner observation End-to-end server/runner/vendor observation
Tool calling Request-level wrap tool Server-dispatched builtin Vendor tool mirrored into session items
Policy DENY Not observable Fixed policy blocks the builtin Session CEL policy triggers the native policy hook
Policy ALLOW / ASK Not observable Fixed policy; ASK observes and resolves an elicitation Temporary session CEL policy; ASK observes and resolves an elicitation
Cost tracking Completed-response usage when forwarded Session snapshot usage/cost Session snapshot when the vendor forwards usage

full-server remains the SDK default because it covers the deployed server path and all three policy actions. --fast trades that policy coverage for lower startup cost. native-tui now has real Tool calling and all three policy action probes through the native hook path.

Plugin seamlessness: where it is and isn't

The original goal (option B) was that a community harness ships a BenchProfile and runs with --harness <name> and no bench edits. For the bench itself, that holds: profile resolution, capability derivation, and native_vendor() all read harness_capabilities(), which discovers community plugins via entry points. A plugged-in harness needs zero bench code to be recognized.

The seam is one level down, in the omnigent server. A native harness is only drivable once the server has seeded a built-in <harness>-native-ui agent, and that seeding is a hardcoded list in server/app.py:_ensure_default_agents — one _ensure_default_<harness>_agent() call per harness. goose-native and hermes-native were in the capability registry but omitted from that list, so the bench (correctly) reported them not auto-registered on the server until the seeders were added.

So: the bench is plugin-seamless; the server's native-agent seeding is not, and the bench inherits that seam. A community native plugin today resolves in the bench, then fails at registration because nothing seeds its UI agent. The clean fix is to make _ensure_default_agents iterate native_agents() from the registry (which already includes plugins) instead of a hardcoded call list — then native harnesses and plugins register automatically. This is the highest -leverage remaining item: it is the difference between "the bench is plugin- ready" and "a plugged-in native harness works end to end".

The self-enforcing table in practice (drift case studies)

reconcile() turns a false capability declaration into a DRIFT. This is not theoretical — the bench caught several real declaration errors this way, each resolved by correcting the source (the capability model), not the bench:

  • kiro-native / streaming. Declared SUPPORTED, observed 0 deltas (!!✓>✗). kiro mirrors each complete assistant message rather than streaming tokens. Corrected to streaming=False.
  • pi-native / streaming (a fixed over-correction). A static grep had flipped pi to False; a live run showed it streams 7 deltas (!!✗>✓) despite having no delta-posting forwarder. Reverted to True. This is why the rule is "declare False only from a live 0-delta observation" — the grep lied.
  • cursor-native / streaming + provisioning. cursor could not provision at all until the lazy_chat fix (its external_session_id is created by the first message, not at launch, so gating on it pre-turn deadlocked). Once runnable, it observed 0 deltas → streaming=False.
  • qwen-native / streaming. Observed 0 deltas → streaming=False.

The pattern each time: the bench detects the mismatch, a live probe pins which side is wrong, and the capability model is corrected — not the bench massaged to agree with it.

Open items

  • Registry-driven native-agent seeding — replace the hardcoded server seeding list with registry iteration so community native harnesses work end to end after plugin installation.
  • Per-harness native provisioning — some vendors require login or provider configuration that the bench deliberately cannot create. Improve diagnostics where possible while retaining clean skips.
  • Additional dimensions — steering, live queue, resume/fork, reasoning, images, and compaction.