Packages
Secure BEAM sandbox runtime for LLM code mode and MCP aggregation. Run concurrent LLM/tool clients safely while agents orchestrate approved tools, call upstream MCP/OpenAPI servers, and transform data.
Current section
Files
Jump to
Current section
Files
usage-rules/testing.md
# Testing Usage Rules
Testing PtcRunner code is straightforward because the LLM is just a 1-arity
function — pass an inline lambda in tests. There is **no** built-in
`stub`/`mock`/`fake` helper, and you don't need one.
## The fundamental pattern
```elixir
test "single-shot returns the program result" do
llm = fn _request -> {:ok, "(+ 1 2)"} end
{:ok, step} = PtcRunner.SubAgent.run("Compute", max_turns: 1, llm: llm)
assert step.return == 3
end
```
The LLM callback receives `%{system: ..., messages: [...]}` and may return
either `{:ok, raw_text}` (wrapped internally) or
`{:ok, %{content: text, tokens: ...}}`. Raw s-expressions parse fine; you can
also wrap in a ```` ```clojure ```` fence (matches what real LLMs emit).
## Scripted (multi-turn) callbacks
For loop-mode tests, script a sequence of responses keyed by turn:
```elixir
defmodule MyApp.TestLLM do
def scripted(programs) do
{:ok, agent_pid} = Agent.start_link(fn -> 0 end)
fn _input ->
turn = Agent.get_and_update(agent_pid, fn n -> {n, n + 1} end)
program = Enum.at(programs, turn) || List.last(programs)
{:ok, "```clojure\n#{program}\n```"}
end
end
end
test "agent searches then returns" do
llm = MyApp.TestLLM.scripted([
# Turn 1: store results in memory under `results`
~S|(def results (tool/search {:query "test"}))|,
# Turn 2: read from memory and return
~S|(return {:count (count results)})|
])
{:ok, step} = PtcRunner.SubAgent.run("Search and count",
signature: "{count :int}",
tools: %{"search" => fn _args -> [%{id: 1}, %{id: 2}] end},
llm: llm
)
assert step.return["count"] == 2
end
```
Tool results are **not** automatically bound to `data/<name>` — the program
must capture them with `(def ...)` (or just compose them inline in one turn).
Use `Agent` (not `:counters` or `:ets`) so the test cleans up automatically.
## What to test where
| Layer | Approach |
|-------|----------|
| **Tools** | Plain ExUnit. Tools are functions — call them directly. |
| **Prompt expansion** | `SubAgent.preview_prompt(agent, context: %{...})` returns the rendered system + user messages. Snapshot-test these. |
| **Agent end-to-end** | Scripted LLM + real tools. Exercise the loop without paying for tokens. |
| **Provider integration** | `@tag :integration` with a real model alias, gated on env. |
Don't write unit tests that just mirror function bodies. Prefer integration
tests that exercise the loop with a scripted LLM and real tools.
## Inspecting failures
```elixir
{_ok_or_error, step} = PtcRunner.SubAgent.run(agent, llm: llm)
# See every turn — generated program, tool calls, return value
PtcRunner.SubAgent.Debug.print_trace(step)
# Include raw LLM output (commentary outside the fence)
PtcRunner.SubAgent.Debug.print_trace(step, raw: true)
# Show full messages sent to the LLM each turn
PtcRunner.SubAgent.Debug.print_trace(step, messages: true)
```
In assertions, prefer `step.return` and `step.fail.reason` over poking at
internal turn structures — those are not part of the public contract.
## Real LLM tests (gated)
```elixir
@tag :integration
test "real model returns valid JSON" do
{:ok, step} = PtcRunner.SubAgent.run("Classify: I love it",
output: :text,
signature: "{sentiment :string, score :float}",
llm: "haiku"
)
assert step.return["sentiment"] in ~w[positive neutral negative]
end
```
Run with `mix test --include integration` and require an env var
(`OPENROUTER_API_KEY`, etc.) to be present.
## Don't
- Don't use `Process.sleep/1` to wait for streaming or async work — use
`assert_receive` with a timeout.
- Don't `Process.put` test state into the LLM callback closure; pass it via
the closure or a test-scoped `Agent`.
- Don't mock `PtcRunner.Lisp.run/2` or `PtcRunner.Sandbox.execute/3`. They're
fast and deterministic — running them is more reliable than mocking them.
- Don't put domain-specific hints in `prompt`/`system_prompt` that "happen to"
steer the LLM toward the test's expected answer. The orchestration layer
must be domain-blind.