LemonPlatformTest. EngineCase
(lemon_platform_test v0.1.0)
View Source
Compliance suite for LemonGateway.Engine implementations.
What an engine is
An engine is the thing that actually answers. LemonGateway decides that a
run should happen — concurrency, locking, scheduling, resume — and hands the
work to an engine, which wraps a CLI tool (codex, claude, droid), an API,
or an in-process agent. LemonGateway.Engines.Echo is the reference
implementation and is 60 lines long; CodingAgent.GatewayEngine is a real one
that lives in a different application entirely and registers itself at boot.
Engines do not return answers. They start work and stream events to a sink process, which is what makes a run observable while it is still running.
The contract
Identity
id/0 returns a slug matching ~r/^[a-z][a-z0-9_-]*$/ that is not "default"
or "help" — LemonGateway.EngineRegistry reserves those two and refuses to
start with an engine that claims either. The id appears in resume tokens,
routing config and user-visible commands, so treat it as permanent.
Resume tokens round-trip
Three callbacks share one job: letting a later run continue an earlier session.
format_resume/1renders aLemonCore.ResumeTokenas the literal command a user could paste ("codex resume thread_abc","claude --resume xyz").extract_resume/1finds your token in arbitrary text — typically the tail of your own tool's output — and returns a%LemonCore.ResumeToken{}ornil. It must returnnil, never raise, for text that is not yours, and it must not claim another engine's tokens.is_resume_line/1answers whether a line is only a resume command, so renderers can keep it when truncating output.
The suite checks the cycle: a token you formatted must be extractable, must
come back with your id/0 and the same value, and must be recognised as a
resume line.
Runs are asynchronous and event-driven
start_run/3 returns {:ok, run_ref, cancel_ctx} immediately — before the
work finishes — or {:error, reason}. Everything the caller learns after that
arrives as messages to sink_pid:
{:engine_event, run_ref, event} # LemonGateway.Event.started/action_event/completed
{:engine_delta, run_ref, text} # streaming textrun_ref ties messages to the run; cancel_ctx is opaque state you will be
handed back. Emit exactly one started event and, eventually, exactly one
completed event — the gateway's run lifecycle hangs on the completed event
arriving even for failures (ok: false), so an engine that dies silently
leaves a run wedged until the watchdog fires.
cancel/1 is total
The gateway calls cancel(cancel_ctx) and expects :ok — for the exact term
your start_run/3 returned, for a stale or partial one it is holding after a
crash or restart, and for a run that already finished. Cancelling twice is
:ok too. Pattern-matching only your happy-path context turns a routine
cancellation into a FunctionClauseError in the caller, so end your clauses
with def cancel(_ctx), do: :ok.
Steering is optional but must be declared honestly
supports_steer?/0 is a required callback; steer/2 is optional. If you
answer true you must export steer/2 and accept mid-run text, returning
:ok or {:error, reason}.
Minimal implementation
defmodule MyApp.Engine do
@behaviour LemonGateway.Engine
alias LemonCore.ResumeToken
alias LemonGateway.Event
@impl true
def id, do: "myengine"
@impl true
def format_resume(%ResumeToken{value: value}), do: "myengine resume #{value}"
@impl true
def extract_resume(text) do
case Regex.run(~r/myengine\s+resume\s+([\w-]+)/i, text) do
[_, value] -> %ResumeToken{engine: id(), value: value}
_ -> nil
end
end
@impl true
def is_resume_line(line), do: Regex.match?(~r/^\s*myengine\s+resume\s+[\w-]+\s*$/i, line)
@impl true
def supports_steer?, do: false
@impl true
def start_run(job, _opts, sink_pid) do
run_ref = make_ref()
resume = job.resume || %ResumeToken{engine: id(), value: "session-1"}
{:ok, pid} =
Task.start(fn ->
send(sink_pid, {:engine_event, run_ref, Event.started(%{engine: id(), resume: resume})})
answer = MyApp.Api.complete(job.prompt)
send(
sink_pid,
{:engine_event, run_ref,
Event.completed(%{engine: id(), resume: resume, ok: true, answer: answer})}
)
end)
{:ok, run_ref, %{task_pid: pid}}
end
@impl true
def cancel(%{task_pid: pid}) when is_pid(pid) do
Process.exit(pid, :kill)
:ok
end
def cancel(_ctx), do: :ok
endRegistering from another application
An engine that ships outside lemon_gateway registers itself when its own
application starts:
LemonGateway.EngineRegistry.register(MyApp.Engine)Registration is idempotent, validates the id, and survives a registry restart.
This suite performs that round-trip (see the :registry option).
Running the suite
defmodule MyApp.EngineComplianceTest do
use LemonPlatformTest.EngineCase, async: false, engine: MyApp.Engine
endBy default this runs the static contract only: identity, resume-token
cycle, callback consistency, registry round-trip. Nothing is executed. Pass a
:run_probe to also exercise the run lifecycle, which is worth doing for any
engine that can answer without a network call or a real model:
defmodule MyApp.EngineComplianceTest do
use LemonPlatformTest.EngineCase,
async: false,
engine: MyApp.Engine,
run_probe: {__MODULE__, :job}
def job(_context) do
%LemonGateway.Types.Job{run_id: "compliance", prompt: "ping", engine_id: "myengine"}
end
endOptions
:engine— required, the engine module under test.:resume_value— the token value used for the round-trip. Default"session-abc123". Must be something yourextract_resume/1accepts; keep it to[A-Za-z0-9_-].:registry— round-trip the engine throughLemonGateway.EngineRegistry. Defaulttrue, because "works standalone, invisible to the platform" is the integration failure this suite exists to catch. It is also the only option that is on by default and touches global state, so it is worth knowing exactly what it does: it starts:lemon_gatewayif nothing else has (with the health listener disabled, so no port is bound), and the gateway application's own startup in turn pointsLemonCore.EngineInfoBridgeat the gateway's registries for the rest of the VM's life. The engine list it registers into is snapshotted and restored per test. Passregistry: falsein a suite where starting the gateway is not acceptable.:run_probe—{Module, :function}returning a%LemonGateway.Types.Job{}, called with the test context. Enables the lifecycle tests:start_run/3returns a ref, astartedevent arrives at the sink, andcancel/1returns:ok. Only pass this if running the job is free and offline.:run_timeout— milliseconds to wait for thestartedevent. Default2_000.:cancel_tolerates_unknown_ctx— assert thatcancel/1returns:okfor a context it did not produce. Defaulttrue, becauseLemonGateway.Engine.cancel/1requires it. Set it tofalseonly to quarantine a known-strict engine you have not fixed yet; that is a departure from the contract, not a supported configuration.
Known gaps in the behaviour
steer/2is optional butsupports_steer?/0is not. An engine that answersfalsestill has to definesupports_steer?/0; nothing stops an engine from answeringtrueand omittingsteer/2except this suite, because makingsteer/2mandatory would break every engine that does not steer.start_run/3'soptsare a loose map.run_opts()documents:cwd,:env,:timeout_msand:capabilitiesas optional keys, and engines differ on which they honour. The suite passes%{}and does not assert how options are interpreted.
Summary
Functions
Starts :lemon_gateway if it is not already running.
Text extract_resume/1 is probed with, none of which it may raise on.
Engine ids LemonGateway.EngineRegistry refuses to register.
Restores :lemon_gateway, :engines when the current test ends.
Functions
@spec ensure_gateway_started!() :: :ok
Starts :lemon_gateway if it is not already running.
When this function is the one that starts it, the gateway's health listener is disabled first, so running a compliance suite never binds a port. A gateway that was already running is left exactly as it was — the suite is a guest in that case, not the owner.
Text extract_resume/1 is probed with, none of which it may raise on.
Chat text is arbitrary user input and the registry calls every engine's
extract_resume/1 on it in turn, inside its own process. The list mixes the
shapes that break naive regex and slicing code — truncated resume lines, the
engine's own id with nothing after it, unbalanced backticks, newlines,
non-ASCII, and something long enough to matter.
@spec reserved_ids() :: [String.t()]
Engine ids LemonGateway.EngineRegistry refuses to register.
@spec restore_engines_on_exit() :: :ok
Restores :lemon_gateway, :engines when the current test ends.
LemonGateway.EngineRegistry.register/1 writes the engine list back to
application environment so it survives a registry restart. That is correct for
a running system and wrong for a test suite, which would otherwise leave the
engine under test registered for everything that runs after it in the same VM.