agentkit.core.testing¶
Test doubles for every Protocol — FakeLLM, FakeFetch, FakeSearch,
FakeMemory, FakeTool, FakeCompactor, FakeGrounder, FakeClock
— plus the make_test_ctx() builder that wires them into a RunContext.
Two doubles are not port doubles, because the CLI cognitions are not
behind a port: FakeClaudeCli and FakeCodexCli stand in for the
subprocess itself (with the shared CliRun / CliStderr /
CliInvocation value types, and codex_turn() to build one well-formed
Codex turn), so a test still runs the real parsing, event mapping and
meter charging.
Canonical test utilities for agentkit consumers.
What lives here:
- make_test_ctx(...) — factory that builds a REAL RunContext for
tests, wired with the FakeLLM-backed Invoker + noop Trace/Observer
defaults. Suitable for any test that needs a real ctx.
What lives under fakes/:
- FakeCtx — minimum Ctx that RECORDS spans (different from
agentkit.core.runtime.NullCtx, which records nothing). Use when a test
needs to assert on what spans were opened.
- FakeLLM, FakeClock, FakeFetch, FakeSearch — port doubles.
- FakeGrounder, FakeCompactor — capability doubles for
RequestBuilder tests.
- FakeClaudeCli / FakeCodexCli (+ the shared CliRun, CliStderr,
CliInvocation, and codex_turn for building a well-formed Codex
turn) — the two doubles that are NOT port doubles, because the
claude and codex CLIs are not behind a port. Each replaces the
subprocess, replaying recorded or scripted JSON so the cognition's own
parsing, event mapping and meter charging still run.
Naming convention: Fake* is the standard for test doubles. Null* /
Noop* are production-grade null-object patterns; they live in their
respective runtime/kernel modules, NOT here.
CliInvocation
dataclass
¶
CliInvocation(
argv: tuple[str, ...],
cwd: str | None = None,
env: Mapping[str, str] = dict(),
stdin: bytes = b"",
start_new_session: bool = False,
lines_read: int = 0,
terminated: bool = False,
killed: bool = False,
returncode: int | None = None,
)
What one spawn was asked to do, and what happened to it.
Records argv/cwd/env so a flags test can assert on them without the
process-wide create_subprocess_exec patch, and stdin so a session
test can assert on what the cognition wrote — which is otherwise invisible,
because a session's input side never reaches the filesystem or the event
stream.
CliRun
dataclass
¶
CliRun(
stdout: bytes = b"",
stderr: bytes = b"",
returncode: int = 0,
interleaved_stderr: tuple[tuple[int, bytes], ...] = (),
)
One spawn of a CLI, whole: its stdout, its stderr, its exit code.
Frozen because it is the recording. Two doubles over one CliRun must not
be able to affect each other, and the cheapest way to guarantee that is for
the shared thing to be immutable and the per-spawn cursor to live in the
iterator.
of
classmethod
¶
of(
payloads: Iterable[
Mapping[str, Any] | CliStderr | str | bytes
],
*,
stderr: bytes = b"",
returncode: int = 0,
) -> CliRun
Assemble a run from payloads.
A Mapping is serialised as one JSON line — the ordinary case. A
str or bytes is written verbatim, newline and all, which is
the whole malformed-session story in one rule: '{"type":"result",'
is a process killed mid-object, '\n' is a blank warm-up line,
'warning: ...\n' is a plain-text diagnostic on stdout. No
truncate= knob, no corrupt= flag; every shape a real binary can
emit is already expressible, and a knob per shape would only enumerate
the ones someone thought of.
CliStderr
dataclass
¶
A chunk the CLI wrote to stderr at this point in its stdout stream.
Positional, not a lump attached to the run, because a cognition reads
stderr exactly once — at the end, after stdout hits EOF. A diagnostic
emitted in the middle of a long run therefore has to survive until then,
and "did it?" is a real question: stderr is the only channel that explains
a bare cli_exit_1 to an operator. Placing chunks in the stream is what
lets a test ask it.
FakeClaudeCli
¶
FakeClaudeCli(
runs: Sequence[CliRun] = (),
*,
repeat_last: bool = False,
)
Deterministic offline claude CLI. Pass it as spawn= to a
:class:~agentkit.agents.cognition.ClaudeCliCognition.
Holds an ordered list of :class:CliRun s — one per spawn, not per
turn, because that is the unit the CLI actually has: drive() spawns
once per run, while a session spawns once and serves many turns off the
same stdout. Each spawn consumes the next run; asking for one the recording
does not have raises :class:~agentkit.core.testing.ScriptExhausted, for the
reason that class exists — a recording is a claim about how many times the
CLI is invoked, and code that invokes it more has a defect (a retry that
should not have fired, a loop that should have stopped) that replaying the
last run would answer with a plausible, wrong result.
repeat_last=True replays the final run forever, for the test that
deliberately drives an unbounded number of spawns.
remaining
property
¶
Runs not yet spawned. Negative is impossible; zero after the last spawn is the normal end of a fully-consumed recording.
replay
classmethod
¶
replay(
path: Path | str,
*more: Path | str,
repeat_last: bool = False,
) -> FakeClaudeCli
Replay recorded stream-json session file(s), one spawn per file.
The bytes are read now, once, and held immutably — not streamed off
disk per spawn. Two reasons, and neither is performance: a recording
that vanishes or changes between construction and the spawn three
layers down would surface as tests.evals["error"] inside the cognition's
except BaseException, where a missing fixture is nearly unreadable;
and "replays byte-identically twice" is trivially true of an immutable
buffer and merely probable of a file. Streaming happens on the other
side — the buffer is handed to the consumer one line at a time.
script
classmethod
¶
script(
payloads: Iterable[
Mapping[str, Any] | CliStderr | str | bytes
],
*,
stderr: bytes = b"",
returncode: int = 0,
repeat_last: bool = False,
) -> FakeClaudeCli
Assemble ONE spawn's output from payloads — the case nobody recorded.
One spawn rather than one-per-payload because that is what the shapes
this is for need: a refusal, a session limit and a malformed final
answer are all a single CLI invocation that went wrong. Several spawns
with different exit codes are FakeClaudeCli([CliRun.of(...), ...]).
See :meth:CliRun.of for how a payload becomes bytes — in particular
that a raw str/bytes is written verbatim, which is how a
truncated or non-JSON line is built.
FakeClock
¶
Deterministic offline ClockPort. start is the initial unix timestamp; now() returns the
current virtual time; sleep(s) advances it by s (no real wall-clock wait).
advance
¶
Test-only escape hatch: move the clock forward without recording a sleep call
(useful for simulating external time passing between agent steps).
FakeCodexCli
¶
FakeCodexCli(
runs: Sequence[CliRun] = (),
*,
repeat_last: bool = False,
)
Deterministic offline codex CLI. Pass it as spawn= to a
:class:~agentkit.agents.cognition.CodexCliCognition.
Holds an ordered list of :class:~agentkit.core.testing.fakes.CliRun s — one
per spawn. That unit is worth being precise about, because it differs
from the Claude double's in the way that matters for writing tests:
codex exec is one-shot, so a spawn is a TURN. drive() consumes one
run; a three-turn :class:~agentkit.agents.cognition.CodexCliSession
consumes three.
Each spawn consumes the next run; asking for one the recording does not
have raises :class:~agentkit.core.testing.ScriptExhausted. repeat_last=True
replays the final run forever, which is the shape a session test that does
not care how many turns it takes wants.
remaining
property
¶
Runs not yet spawned. Negative is impossible; zero after the last spawn is the normal end of a fully-consumed recording.
replay
classmethod
¶
replay(
path: Path | str,
*more: Path | str,
repeat_last: bool = False,
) -> FakeCodexCli
Replay recorded JSONL session file(s), one spawn per file.
The bytes are read now, once, and held immutably — not streamed off
disk per spawn. A recording that vanished or changed between
construction and the spawn three layers down would surface as
tests.evals["error"] inside the cognition's except BaseException,
where a missing fixture is nearly unreadable; and "replays
byte-identically twice" is trivially true of an immutable buffer and
merely probable of a file. Streaming happens on the other side — the
buffer is handed to the consumer one line at a time.
script
classmethod
¶
script(
payloads: Iterable[
Mapping[str, Any] | CliStderr | str | bytes
],
*,
stderr: bytes = b"",
returncode: int = 0,
repeat_last: bool = False,
) -> FakeCodexCli
Assemble ONE spawn's output from payloads — the case nobody recorded.
One spawn rather than one-per-payload because that is what the shapes
this is for need: a refusal, a stream error and a malformed final answer
are all a single CLI invocation that went wrong. Several spawns with
different exit codes are FakeCodexCli([CliRun.of(...), ...]).
See :meth:~agentkit.core.testing.fakes.CliRun.of for how a payload becomes
bytes — in particular that a raw str/bytes is written verbatim,
which is how a truncated or non-JSON line is built.
answering
classmethod
¶
answering(
*texts: str, thread_id: str = DEFAULT_THREAD_ID
) -> FakeCodexCli
One spawn per text, each a complete well-formed turn saying it.
The shorthand for a multi-turn session test, where the conversation's CONTENT is not what is under test — the argv, the thread id, the stop reason or the metering is — and five hand-written payloads per turn are five chances to write a stream the binary would not emit::
cli = FakeCodexCli.answering("first", "second", "third")
Every turn re-announces the same thread_id, which is what a real
resumed run does; a session that failed to thread its id would show up
as a missing resume in invocations[1].argv, not as a mismatched
id here.
argv
¶
One spawn's argv, defaulting to the most recent.
A convenience with a purpose: the flag assertions in this cognition's
tests are all "what did spawn N ask for", and
cli.invocations[-1].argv reads as plumbing at every one of them.
FakeCompactor
dataclass
¶
RequestBuilder test helper. Replaces every transcript with a single sentinel message so a test can detect that compaction ran.
FakeCtx
¶
FakeCtx(*, scope: Scope | None = None)
Minimal Ctx that RECORDS spans + observations.
Use when a test needs to assert on what spans were opened (e.g.,
RequestBuilder stamps agentkit.prompt.version — a test for that
contract needs a tracer that captures attributes). Distinct from
agentkit.core.runtime.NullCtx: NullCtx absorbs operations and
records nothing; FakeCtx records spans for assertion. Both are
valid — pick based on whether you need to assert on what was
recorded.
tracer.spans is also exposed as ctx.spans for tests that
fold the tracer onto the ctx itself. Either access form works.
spans
property
¶
Convenience alias mirroring (name, kind, attrs) tuples on
the ctx itself, so tests can assert on ctx.spans directly
instead of reaching through ctx.trace.spans.
FakeFetch
¶
FakeFetch(fixtures: dict[str, FetchResponse])
Deterministic offline FetchPort. fixtures is {url: FetchResponse}; unknown URLs raise
KeyError(url) so tests fail loud instead of silently falling back to a stub response.
FakeGrounder
dataclass
¶
RequestBuilder test helper. Records (ctx, task) calls; returns a
canned block (empty string allowed to test the skip path).
FakeLLM
¶
FakeLLM(
responses: dict[str, str]
| Callable[..., str]
| str = "{}",
*,
usage: Usage | None = None,
fail_times: int = 0,
fail_exc: BaseException | None = None,
delay: float = 0.0,
turns: list[Turn] | None = None,
repeat_last: bool = False,
)
Deterministic offline LLM. dict {substring_of_user: content} | callable | str for single
answers; FakeLLM.script([Turn(...), …]) replays a multi-step tool loop across chat calls.
The two forms differ ON PURPOSE once they run out of material:
- the single-answer forms (str / dict / callable) have nothing to run out of — they are a rule for turning a request into a reply, so call 1 and call 100 both answer. Repeating IS their meaning.
- a script is a finite, ordered claim about how many turns the run takes.
Asking for turn N+1 contradicts the claim, so it raises
ScriptExhausted. Passrepeat_last=Truefor the deliberate never-terminating script — a single tool-callTurnused to drive a loop untilmax_iterationsor a budget cuts it.
script
classmethod
¶
Replay turns in order, one per chat/stream call, then raise ScriptExhausted.
repeat_last=True replays the final turn forever instead. Spelled out
as a real keyword rather than left to **kw so it shows up in a
signature and an IDE completion — it is the escape hatch a reader
needs at the moment the exception fires, and **kw hides it.
stream
async
¶
stream(
*,
messages: list[Message],
model: str,
tools: list[ToolSchema] | None = None,
response_format: dict[str, Any] | None = None,
temperature: float = 0.0,
max_tokens: int | None = None,
cache_hint: Any = None,
) -> AsyncIterator[Delta]
The streaming primitive: emits content in small chunks then a terminal delta carrying
usage/finish/tool_calls. _enter() raises BEFORE the first delta, so it's a pre-stream failure the
retry middleware can re-invoke (honoring fail_times).
FakeMemory
dataclass
¶
FakeMemory(
name: str = "fake_memory",
responses: dict[str, list[MemoryItem]] = dict(),
default: list[MemoryItem] = list(),
items: list[MemoryItem] | None = None,
)
A MemorySource-Protocol-conforming fake.
Two modes:
responses={"q": [MemoryItem(...), ...], ...}— per-query lookup. Misses fall through todefault(defaults to[]).items=[MemoryItem(...), ...]— static fixture mode.queryignores the query string entirely and returns the firstkitems regardless. Useful when a test doesn't care about retrieval semantics, only that the agent received some items.
Every query is recorded as (query, k, where) on
self.queries; every write batch is appended to
self.writes. Lets tests assert mem.queries[0] == ("foo", 3, None)
or mem.writes == [[MemoryItem("a", "src")]].
FakeSearch
¶
Deterministic offline SearchPort. Pass a list (returned for every query) or a dict {substring_of_query: [SearchHit, ...]} (first substring match wins; unknown queries → []).
FakeTool
dataclass
¶
FakeTool(
name: str = "fake_tool",
description: str = "fake tool for unit tests",
responder: Any = None,
side_effecting: bool = False,
requires_approval: bool = False,
output_schema: dict[str, Any] | None = None,
schema: ToolSchema = _default_schema(),
)
A Tool-Protocol-conforming fake.
Construct with a static responder value (returned verbatim
from every run) OR with a callable responder(args) -> Any
that may be sync or async. Any exception the callable raises
propagates, letting a test exercise the error path.
Every run call is appended to self.calls as the (copied)
args dict, so tests can do
assert tool.calls == [{"x": 1}, {"x": 2}].
The default schema is the permissive {"type": "object"} —
any JSON object accepted. Override per-test if you want the
Protocol's advertised schema to reflect a real shape.
RecordingSpan
¶
A span that records its name / kind / attributes / events. Drop-in for the
production span surface — exposes .set(key, value) and .add_event(name, **fields).
Attributes start from the kwargs passed to span() and are mutated by set().
RecordingTracer
¶
RecordingTracer(
*,
exporter: Callable[[RecordingSpan], None] | None = None,
)
In-process TracePort that records every span. Optional exporter callback
receives each span object on close (mirrors the OTel SpanExporter shape at the
granularity tests care about).
Satisfies the TracePort Protocol structurally:
- span(name, kind, **attrs) — context manager yielding a RecordingSpan
- current_span_id() — returns the open span's TraceContext (synthetic ids) or None
- add_event_to_current_span(name, **fields) — drops an event on the open span
ScriptExhausted
¶
Bases: BaseException
A FakeLLM.script was asked for more turns than it has.
WHY THIS EXISTS, AND WHY IT IS LOUD¶
This class used to be a min(). stream clamped the script index, so a
2-turn script asked for 4 turns answered ['one', 'two', 'two', 'two']
— measured, not hypothetical. A scripted LLM exists to drive an agent
through an EXPECTED number of turns, so "the agent asked for more turns
than the script has" is a statement about the code under test: it looped
when it should have stopped. The clamp turned exactly that finding into a
stable, plausible, wrong answer, and the test went green. The harness
built to catch non-termination was the thing hiding it — the same shape as
a broad except that reports success.
WHY BaseException AND NOT Exception¶
Because the failure it reports is a failure of the code that would be
doing the catching. agentkit has ~20 deliberate except Exception
sites on the path between FakeLLM.stream and the test body — react
reflects invalid output back to the model, _invoke_tool_safe turns every
tool failure into a tool message, resilience classifies pre-stream
failures into retry-vs-fail. Each one is correct for a real provider
fault, and each one would convert "your loop does not terminate" into a
retry or a reflected message and keep going. asyncio.CancelledError was
promoted out of Exception in 3.8 for this exact reason; a test double's
contract violation belongs in the same bucket. pytest reports a
BaseException as a plain failure, so nothing is lost at the top.
Turn
dataclass
¶
One scripted reply for FakeLLM.script — a final answer, or a turn that requests tools.
ScriptedAdapter
¶
A finite sequence of transport facts; exhaustion fails loudly.
ScriptedAdapterExhausted
¶
Bases: BaseException
A runtime requested more transport turns than the script supplied.
codex_turn
¶
codex_turn(
*,
text: str | None = None,
reasoning: str | None = None,
thread_id: str | None = DEFAULT_THREAD_ID,
usage: tuple[int, int, int] | None = None,
items: Iterable[Mapping[str, Any]] = (),
failed: str | None = None,
error: str | None = None,
duration_ms: int | None = None,
) -> tuple[dict[str, Any], ...]
One complete, well-formed codex exec --json turn, as payloads.
Emits the real event order: thread.started (unless thread_id=None,
which is how a test builds the resumed turn of a CLI version that does not
re-announce the thread), turn.started, one item.completed per item,
then exactly one terminal turn.completed or turn.failed.
usage is (input_tokens, cached_input_tokens, output_tokens) in
Codex's own convention — input INCLUSIVE of the cached prefix. Passing it
that way rather than as agentkit's split is deliberate: the subtraction is
the cognition's job, and a builder that pre-split it would make the one
test that exists to pin that subtraction assert against its own arithmetic.
reasoning and text are shorthand for the two items nearly every
test wants; items takes the rest (a command_execution, a
file_change, an mcp_tool_call) as raw item dicts, appended after
them. error inserts a top-level stream error before the terminal event —
the shape the CLI uses for a broken pipe, as opposed to failed, which is
the turn itself going wrong.
make_test_ctx
¶
make_test_ctx(
*,
llm: Any = None,
invoker: Any = None,
scope: Scope | None = None,
budget: Budget | None = None,
store: Any = None,
vector: Any = None,
checkpointer: Any = None,
asker: Any = None,
observer: Any = None,
trace: Any = None,
cancel: CancellationToken | None = None,
chat_middleware: Sequence[Any] = (),
tool_middleware: Sequence[Any] = (),
meters: Sequence[Any] = (),
correlation_id: str = "test-run",
autonomy: AutonomyLiteral = "auto",
) -> RunContext
Build a RunContext for tests with the knobs the call site cares
about and sensible no-op defaults for everything else.
Two ways to wire the LLM seam: pass invoker= (a fully-built
Invoker) OR pass llm= and we'll wrap it with
Invoker(llm=..., chat_middleware=chat_middleware). Tests that
don't need an Invoker at all leave both unset — they get a
RunContext with services.invoker = None, which is fine for
capabilities tests (RequestBuilder, Compactor) that never invoke an LLM.
Scope() is the zero-tenant default. Override with
scope=Scope(org_id, domain_id) for tenant-isolation tests.
asker wires the human-in-the-loop transport
(agents.control.elicitation.Asker). When set, a gated tool call PARKS —
the cognition awaits it in place instead of checkpointing and unwinding.
None (default) keeps the classic suspend-and-resume path.
observer / trace / checkpointer / store / vector
default to None here, which we omit when constructing
Services so its built-in NoopObserver / NoopTrace
factories still kick in — passing None explicitly would clobber
them and break code that calls ctx.trace.span(...) or
ctx.observer.emit(...).
scripted_runtime
¶
scripted_runtime(
turns: Iterable[Turn],
pricing: PriceTable,
*,
mode: Mode = OPENAI_API,
**runtime_options: Any,
) -> CallRuntime
Create a runtime wired to one scripted mode and an explicit price table.