Test your plugin¶
iris_harness.testing builds the same runtime iris runs (routing, the governed loop,
the curator, the audit ledger) inside your test, on a scripted model, offline. A plugin
test proves three things: the logic works, the plugin works inside a governed IRIS, and
the guarantees hold.
A first test¶
from pathlib import Path
from iris_harness.testing import harness, plugin
from my_plugin import setup
MANIFEST = Path(__file__).with_name("manifest.yaml")
def test_my_plugin_answers() -> None:
script = {"default": {"content": "Scripted answer."}}
with harness(plugins=[plugin(setup, manifest=MANIFEST)], fake_model=script) as h:
assert h.plugin_loaded("my-plugin")
result = h.chat("hello") # or h.chat_stream("hello")
assert result.text == "Scripted answer."
assert h.audit_gaps() == [] # every model call and answer audited, nothing lost
harness(plugins=[...], fake_model=...)runs in a throwawayIRIS_HOME, with your ownIRIS_*settings and credentials out of the environment, a throwaway vault master key, a keyring that refuses every call, and no network. On exit it puts back every piece of process-wide state the run filled, so two harnesses in one process never see each other's plugins.plugin(setup, manifest=...)mounts your plugin without packaging it, through the same loader and manifest checks as an installed one. For a declarative plugin, pass only the manifest:plugin(manifest=PATH).
The scripted model¶
The fake model is data: rules matched in order against each model call's system and
user text (regular expressions), answering with content, json or tool_calls. A
named group in the match fills {name} in the reply. Leave out default: in a real
test, so a call you did not script fails loudly; h.model_calls() lists which rule
answered each call. The testing example
has a full fake_model.yaml that drives a tool call through the governed loop.
What a turn gives you¶
chat and chat_stream return a TurnResult: text, agent, intent, sources,
answered (false only when a streamed turn ended in an error), error, session_id,
audit_refs (the ids of the ledger rows the turn wrote) and, for chat_stream,
events (TurnEvent(kind, text), the terminal done or error last).
Plugin state and degraded plugins¶
h.plugins() is {name: (status, load_error)}. h.plugin_states() returns a frozen
PluginState(status, load_error, degraded_reason, failure_count) per plugin. A mounted
plugin giving degraded answers has a degraded_reason: guarded calls that failed, or an
optional capabilities: uses that no mounted plugin provides (optional capability
weather.forecast unavailable (degraded)). The same text is the plugin's yellow Health
line, and it clears once a provider mounts.
Assert on governance¶
rows = h.audit_rows(hook_point="pre_response", session_id=result.session_id)
assert [row.handler for row in rows if row.deterministic] == ["my_handler"]
assert h.model_calls() == () # a deterministic handler answered: no model was asked
audit_rows returns TurnAuditRows: hook_point, plugin (the governance check
that wrote the row), decision, reason, run_id, step_id, classification,
tier, session_id, tool, caller and tool_plugin (who invoked a tool call, and
which plugin owns the tool: system for a core tool), and deterministic and handler
on a deterministic handler's answer row. h.audit_gaps() is empty when every model call and every answer
has its row and the session replay finds nothing lost or contradictory (a missing record, a call that
never settled, a parent that does not exist); notes such as a writer still running are not gaps.
A turn that stops for the owner's approval (a destructive tool, a write declared
approval: pinned) leaves a pending approval; h.respond_to_approval(approval_id,
approve=True) answers it as the owner would, the halted run resumes, and the approved
call runs, governed again. The
governed-tool example
approves one call and rejects another.
Outbound HTTP: api.http and fake_http¶
A plugin makes its web requests with api.http (or current_http() in a declarative
plugin), after declaring the hosts under egress: in its manifest. In a test, wrap the call in
fake_http: it answers from a script and replaces only the transport, so the declaration, the
hooks and the ledger rows run as in production. An undeclared host raises EgressDenied (not an
OSError) and nothing is sent.
from iris_harness.testing import fake_http
def test_forecast(h) -> None: # h: your harness
with fake_http({"https://api.open-meteo.com/v1/forecast": {"json": {"days": 3}}}) as sent:
h.chat("What is the forecast?")
assert [r.url.host for r in sent] == ["api.open-meteo.com"]
Make the request on the tool's own thread: one from a thread you start has no governed call and
is denied. check_network_imports (below) fails a plugin whose source imports a raw network
library, so every call goes through the client.
No network, stable imports only¶
from pathlib import Path
from iris_harness.testing import check_network_imports, check_stable_imports, no_network
def test_my_plugin_uses_only_the_stable_api() -> None:
assert check_stable_imports([Path("src/my_plugin")]) == []
def test_my_plugin_makes_no_raw_network_call() -> None:
assert check_network_imports([Path("src/my_plugin")]) == []
def test_offline() -> None:
with no_network() as attempts:
... # an outbound connection in here raises NetworkBlockedError
assert attempts == []
Module-level state¶
A plugin that keeps a module-level registry or cache declares it once, at the bottom of its module, so the harness can restore it between tests:
from iris_harness.sdk.process_state import track_globals
_seen: dict[str, str] = {}
track_globals(__name__, "_seen")