AgentHarness separates deterministic protocol/lifecycle tests from live CLI tests that consume real provider usage.
Test commands
Run the normal suite:
$ mix test
Run the complete local precommit gate:
$ mix precommit
precommit checks formatting, compiles with warnings as errors, runs tests, and
runs Credo in strict mode.
Deterministic tests
Normal tests do not invoke authenticated coding agents. Provider boundaries are Mox behaviours:
AgentHarness.Provider
AgentHarness.Providers.Codex.Client
AgentHarness.Providers.Claude.ClientAgentHarness.Provider is the public adapter contract. The two client
behaviours are internal seams used to test the built-in adapters and are hidden
from generated API documentation; applications should not depend on them as
public extension points.
The tests exercise:
- SessionServer lifecycle and Registry behavior;
- event ordering, replay, subscriptions, streams, and await;
- exactly-once request responses and expiration;
- cancellation and transport failure;
- provider-command request claims, duplicate responses, timeout-boundary result harvesting, uncertain retirement, and SessionServer responsiveness;
- Store ownership, idempotency, and aggregate invariants;
- Codex event normalization and response encoding;
- Claude message normalization and
can_use_tooldecisions; - MCP, skills, environment, and provider option mapping.
This boundary keeps default tests fast, deterministic, and free of provider charges while still exercising the AgentHarness processes around the client.
Mox calls made by supervised processes require explicit ownership allowances
or global Mox mode. Existing provider lifecycle tests use global mode and are
therefore async: false; pure value and Store tests remain asynchronous.
Live tests
Live tests use ExUnit's :live tag and are excluded in test/test_helper.exs.
They run only when explicitly included:
$ mix test --include live
To run only one provider's integrations:
$ mix test \
test/agent_harness/providers/claude_live_test.exs \
--include live
$ mix test \
test/agent_harness/providers/codex_live_test.exs \
--include live
$ mix test \
test/agent_harness/providers/pi_live_test.exs \
--include live
Before running it:
$ claude --version
$ claude
The Claude tests use provider_options: %{auth: :subscription}, a temporary
working directory, small prompts, and long timeouts. Subscription mode refuses
to start if config :claude_code, api_key: ... is present, preventing the live
test from silently switching to API billing. The tests cover completion, an
AskUserQuestion round trip, and cancellation while Claude is blocked in
AskUserQuestion. They invoke the real model and consume your available Claude
usage.
Run the Codex tests only after:
$ codex --version
$ codex login
They open the real app-server and run a read-only ephemeral turn in a temporary directory. The turn still invokes a real model and consumes your available Codex usage.
Run the Pi tests only after installing the CLI, which needs Node 22.19 or newer:
$ npm install -g --ignore-scripts @earendil-works/pi-coding-agent
$ pi --version
Pi is bring-your-own-model, so the live tests run with auth: :inherit and
whichever API key is already exported. They default to openai/gpt-4.1-nano;
override with PI_LIVE_MODEL. Each test sets agent_dir to a temporary
directory, which points PI_CODING_AGENT_DIR away from your real ~/.pi, so
live runs never read or write your own pi config, credentials, or sessions.
They cover session opening, a completed turn, token streaming, a real read
tool call, cancellation, resume from a persisted session file, and the two
rejection paths for features pi does not have.
Pi writes an EPIPE stack trace to stderr when the harness closes its port
mid-write. It is noise on teardown: pi appends session entries as they happen,
so the transcript on disk is complete, which the resume test exercises
directly.
Live tests should always:
- use a temporary or disposable workspace;
- use bounded prompts and timeouts;
- stop sessions in
on_exit/1; - avoid destructive tools and external side effects;
- make quota consumption explicit in their module tag/documentation;
- never run in the default CI test command.
Provider output is nondeterministic. Assert protocol invariants and terminal status rather than prose beyond a deliberately constrained response.
Manual smoke test
For interactive debugging:
$ iex -S mix
Then:
{:ok, session} =
AgentHarness.start_session(:codex,
cwd: System.tmp_dir!(),
approval_policy: :never,
sandbox: :read_only,
provider_options: %{thread_options: %{ephemeral: true}}
)
{:ok, turn} =
AgentHarness.start_turn(session, "Reply with exactly OK and nothing else.")
AgentHarness.await(turn, timeout: 120_000)
AgentHarness.stop_session(session, force: true)Use Claude-native options for the corresponding smoke test:
{:ok, session} =
AgentHarness.start_session(:claude,
cwd: System.tmp_dir!(),
approval_policy: :default,
provider_options: %{auth: :subscription}
)Then run the same turn/await/stop calls. Do not carry Codex-only
thread_options.ephemeral or sandbox: :read_only into the Claude example.
If session startup fails, first run the same CLI directly in the same shell and working directory. Then inspect:
- executable discovery (
codex --versionorclaude --version); - CLI authentication;
CODEX_HOME,ANTHROPIC_API_KEY, and OAuth-related environment values;- MCP child command paths;
- skill/plugin paths;
- configured model and provider option compatibility.
Recorded protocol fixtures
test/support/fixtures/pi holds JSONL frames captured verbatim from a real
pi --mode rpc process. The normalizer and session tests replay them, so those
assertions track what pi actually emits rather than what its documentation
describes. Several adapter decisions came from differences between the two:
readiness needs a get_state probe because pi sends no startup frame,
agent_settled rather than agent_end is the terminal marker, and an abort
acknowledgement arrives after the run has already settled.
To refresh a fixture, run the corresponding scenario against a real pi process
and save its stdout. Only message_update frames should be decimated; every
lifecycle and control frame belongs in the file.
Adding provider tests
Put parsing and response translation behind the provider's client behaviour. Test it with captured SDK structs or JSON-derived fixtures rather than shell scripts that happen to print a happy path.
At minimum, a provider test suite should cover:
- open failure and cleanup;
- per-session configuration isolation;
- partial text/progress output;
- provider session ID discovery;
- questions and every approval kind;
- malformed responses;
- successful, failed, interrupted, and missing terminal records;
- cancellation while output or a request is pending;
- transport/runner failure;
- unknown events preserved through
Event.raw.
Tag all real executable/model calls :live. A fake process-based transport can
be used for framing and shutdown behavior without authenticating or consuming
quota.