// ---------------------------------------------------------------------------
// Testkit constructors — small helpers mirroring the blackbox test helpers.
// ---------------------------------------------------------------------------
///|
/// Build a `ModelCallResult` whose completion is a stop-finish text response
/// with no tool calls. The processed_messages is `None`, signalling to the
/// pump that the modelport did no preprocessing and the transcript should be
/// left as-is.
///
/// **Important:** because ModelCallResult requires `processed_messages` to be
/// a concrete array (not Optional), the default `@runtime.PortRuntime` is
/// responsible for filling it with the input messages when the modelport did
/// not preprocess. Tests that drive ScriptedModel directly through the agent
/// pipeline go through `PortRuntime`, which substitutes the actual input
/// messages when `processed_messages` is empty. See
/// `PortRuntime::call_model` for the substitution logic.
pub fn tk_stop_response(text : String) -> @kernel.ModelCallResult {
let completion : @kernel.Completion = @kernel.Completion(
content=[@kernel.Text(text)],
tool_calls=[],
reasoning=None,
finish_reason=@kernel.Stop,
usage=None,
)
{ completion, processed_messages: [], }
}
///|
/// Build a `ModelCallResult` whose completion requests a single tool call.
pub fn tk_tool_call_response(
tool_name : String,
call_id : String,
args : Json,
) -> @kernel.ModelCallResult {
let call : @kernel.ToolCall = {
call_id: @kernel.CallId::unchecked(call_id),
name: @kernel.ToolName::unchecked(tool_name),
arguments: args,
}
let completion : @kernel.Completion = @kernel.Completion(
content=[],
tool_calls=[call],
reasoning=None,
finish_reason=@kernel.ToolCalls,
usage=None,
)
{ completion, processed_messages: [], }
}
///|
/// Build a user message.
pub fn tk_user_msg(text : String) -> @kernel.Message {
@kernel.UserMessage(content=[@kernel.Text(text)])
}
///|
/// Build a system message.
pub fn tk_system_msg(text : String) -> @kernel.Message {
@kernel.SystemMessage(content=[@kernel.Text(text)])
}
///|
/// Build a ToolDef with an empty object schema. Owner is a placeholder —
/// `Agent::build_catalog` derives the catalog owner at composition time.
/// Policy is `Parallel` (the declared default; composition honors it
/// as-is). Provenance is `None`.
pub fn tk_tool_def(name : String, description : String) -> @kernel.ToolDef {
@kernel.ToolDef(
name=@kernel.ToolName::unchecked(name),
description~,
input_schema=Json::object(Map::from_array([])),
owner=@kernel.OwnerId::unchecked("placeholder"),
policy=@kernel.Parallel,
provenance=None,
)
}
///|
/// Build a successful ToolOutcome.
pub fn tk_ok_result(content : String) -> @kernel.ToolOutcome {
@kernel.Success(content~, structured=None)
}
///|
/// Build a business-error ToolOutcome (provider reports a tool-level failure
/// the model should see, but the run continues).
pub fn tk_error_result(content : String) -> @kernel.ToolOutcome {
@kernel.ToolReportedError(content~, structured=None)
}
///|
/// Build a default universal AgentConfig for scripted tests.
pub fn tk_config() -> AgentConfig {
agent_config(max_tool_rounds=Some(10))
}
///|
/// Anonymous Extension wrapper around a pre-built `ExtensionManifest`.
///
/// Real extensions implement `Extension` directly on their struct (so the
/// compiler knows the concrete type satisfies every port it contributes).
/// But tests, prototypes, and one-off agents often want to compose ports
/// without defining a fresh named struct. `ManifestOnly` fills that role:
/// build a manifest with `tk_ext`, wrap it, and pass it to `Agent::new` as
/// a `&Extension`.
///
/// This is a testkit-only convenience. Production agents should expose a
/// typed `_extension()` factory that returns an `ExtensionManifest`
/// from a real struct, not use `ManifestOnly`.
pub(all) struct ManifestOnly {
cached : @port.ExtensionManifest
}
///|
/// Construct an anonymous Extension from a pre-built manifest.
pub fn ManifestOnly::ManifestOnly(
manifest : @port.ExtensionManifest,
) -> ManifestOnly {
{ cached: manifest, }
}
///|
pub impl @port.Extension for ManifestOnly with fn extension_id(self) -> String {
self.cached.id
}
///|
pub impl @port.Extension for ManifestOnly with fn manifest(self) -> @port.ExtensionManifest {
self.cached
}
///|
/// Expose Extension methods on ManifestOnly for dot-syntax callers and so
/// `&ManifestOnly` can be coerced to `&Extension`.
pub extend ManifestOnly with @port.Extension::{extension_id, manifest}
///|
/// Scripted `MemoryPort` for agent-level tests of the port contract.
/// Inbound side: `inbounds` entries are returned in call order as the full
/// inbound body (a `None` entry plays an empty read); once the script is
/// exhausted every later call returns `None`. Core calls `inbound` at most
/// once per session lifetime per process and ONLY on the session's first
/// turn, so one entry per session is the natural scripting granularity —
/// `call_count`, `received_sessions`, and `received_requests` are the
/// assertion surface for the once-per-session, first-turn-only, and
/// request-text guarantees.
/// Storage side: `store` records `(content, metadata)` and hands back
/// synthetic tickets (`scripted-0`, `scripted-1`, ...); `delete` records
/// the id; `search_script` entries are returned in call order (a `None`
/// entry plays a no-hit search; exhausted -> `None`) and every call records
/// its `(query, top_k)` — `received_stores`, `received_deletes`, and
/// `received_searches` are the write/read assertion surfaces.
pub(all) struct ScriptedMemoryPort {
inbounds : Array[String?]
mut index : Int
mut calls : Int
mut sessions : Array[String]
mut requests : Array[String]
mut stored : Array[(String, Map[String, Json])]
mut deleted : Array[String]
search_script : Array[String?]
mut search_index : Int
mut searches : Array[(String, Int?)]
mut next_ticket : Int
}
///|
pub fn ScriptedMemoryPort::ScriptedMemoryPort(
inbounds~ : Array[String?],
search_script? : Array[String?] = [],
) -> ScriptedMemoryPort {
{
inbounds,
index: 0,
calls: 0,
sessions: [],
requests: [],
stored: [],
deleted: [],
search_script,
search_index: 0,
searches: [],
next_ticket: 0,
}
}
///|
/// Number of `inbound` calls observed so far.
pub fn ScriptedMemoryPort::call_count(self : ScriptedMemoryPort) -> Int {
self.calls
}
///|
/// Session ids `inbound` was called with, in call order.
pub fn ScriptedMemoryPort::received_sessions(
self : ScriptedMemoryPort,
) -> Array[String] {
self.sessions.copy()
}
///|
/// Request texts `inbound` was called with, in call order.
pub fn ScriptedMemoryPort::received_requests(
self : ScriptedMemoryPort,
) -> Array[String] {
self.requests.copy()
}
///|
/// (content, metadata) pairs `store` was called with, in call order.
pub fn ScriptedMemoryPort::received_stores(
self : ScriptedMemoryPort,
) -> Array[(String, Map[String, Json])] {
self.stored.copy()
}
///|
/// Ids `delete` was called with, in call order.
pub fn ScriptedMemoryPort::received_deletes(
self : ScriptedMemoryPort,
) -> Array[String] {
self.deleted.copy()
}
///|
/// (query, top_k) pairs `search` was called with, in call order.
pub fn ScriptedMemoryPort::received_searches(
self : ScriptedMemoryPort,
) -> Array[(String, Int?)] {
self.searches.copy()
}
///|
pub impl @port.MemoryPort for ScriptedMemoryPort with fn inbound(
self : ScriptedMemoryPort,
session_id~ : String,
request~ : String,
) -> String? raise @error.MemoryError {
self.calls = self.calls + 1
self.sessions.push(session_id)
self.requests.push(request)
if self.index < self.inbounds.length() {
let value = self.inbounds[self.index]
self.index = self.index + 1
value
} else {
None
}
}
///|
pub impl @port.MemoryPort for ScriptedMemoryPort with fn store(
self : ScriptedMemoryPort,
content~ : String,
metadata~ : Map[String, Json],
) -> String raise @error.MemoryError {
let ticket = "scripted-\{self.next_ticket}"
self.next_ticket = self.next_ticket + 1
self.stored.push((content, metadata))
ticket
}
///|
pub impl @port.MemoryPort for ScriptedMemoryPort with fn search(
self : ScriptedMemoryPort,
query~ : String,
top_k? : Int,
) -> String? raise @error.MemoryError {
self.searches.push((query, top_k))
if self.search_index < self.search_script.length() {
let value = self.search_script[self.search_index]
self.search_index = self.search_index + 1
value
} else {
None
}
}
///|
pub impl @port.MemoryPort for ScriptedMemoryPort with fn delete(
self : ScriptedMemoryPort,
id : String,
) -> Unit raise @error.MemoryError {
self.deleted.push(id)
}
///|
/// Build a `ManifestOnly` extension from labeled optional arguments.
///
/// Every port argument defaults to empty; pick the ones the test needs. The
/// `model` parameter is `&ModelPort?` because at most one model is allowed
/// per agent — `Some(m)` puts it in the manifest's `models` array, `None`
/// leaves models empty (use a different extension to contribute the model).
///
/// Example:
/// ```moonbit nocheck
/// let model = ScriptedModel(..)
/// let tools = RecordingToolProvider(..)
/// let store = RecordingSessionStore()
/// let observer = RecordingObserver()
/// let agent = Agent(
/// exts=[
/// tk_ext("model", model=Some(model)),
/// tk_ext("tools", tools=[tools]),
/// tk_ext("io", sessions=[store], observers=[observer]),
/// ],
/// config=tk_config(),
/// )
/// ```
pub fn tk_ext(
id~ : String,
model? : &@port.ModelPort? = None,
tools? : Array[&@port.ToolProvider] = [],
sessions? : Array[&@port.SessionStore] = [],
observers? : Array[&@port.Observer] = [],
hooks? : Array[&@port.PipelineHook] = [],
memory? : Array[&@port.MemoryPort] = [],
lifecycle? : Array[&@port.Lifecycle] = [],
commands? : Array[&@port.CommandPort] = [],
ui? : Array[&@port.UiPort] = [],
prompt_contributors? : Array[&@port.SystemPromptContributor] = [],
requires? : Array[@port.Capability] = [],
) -> ManifestOnly {
let models : Array[&@port.ModelPort] = match model {
Some(m) => [m]
None => []
}
ManifestOnly({
id,
models,
tools,
sessions,
observers,
hooks,
memory,
lifecycle,
commands,
ui,
prompt_contributors,
requires,
})
}
///|
/// Build a `CompositionView` for unit-testing `Lifecycle::on_compose`
/// implementations: `requires` plays the role of the fake manifest's
/// declaration, and the composed ports stand in for what the composition
/// would have delivered.
pub fn tk_view(
requires~ : Array[@port.Capability],
model~ : &@port.ModelPort,
ui~ : &@port.UiPort,
) -> @port.CompositionView {
@port.CompositionView::resolve(requires~, model~, ui~)
}
///|
/// RecordingLifecycle — records `Lifecycle` phase invocations for post-hoc
/// assertions. Log entries are `":compose(model=,ui=)"`,
/// `":start"`, `":shutdown"` in call order. Share one
/// `Array[String]` across several fakes (and other recorders writing the
/// same log) to assert interleaving and ordering.
pub(all) struct RecordingLifecycle {
id : String
log : Array[String]
mut seen_model : Bool
mut seen_ui : Bool
}
///|
pub fn RecordingLifecycle::RecordingLifecycle(
id~ : String,
log~ : Array[String],
) -> RecordingLifecycle {
{ id, log, seen_model: false, seen_ui: false, }
}
///|
pub impl @port.Lifecycle for RecordingLifecycle with fn on_compose(self, ctx) {
self.seen_model = ctx.model() is Some(_)
self.seen_ui = ctx.ui() is Some(_)
self.log.push(
"\{self.id}:compose(model=\{self.seen_model},ui=\{self.seen_ui})",
)
}
///|
pub impl @port.Lifecycle for RecordingLifecycle with fn on_start(self) {
self.log.push("\{self.id}:start")
}
///|
pub impl @port.Lifecycle for RecordingLifecycle with fn on_shutdown(self) {
self.log.push("\{self.id}:shutdown")
}
///|
/// Expose Lifecycle methods on RecordingLifecycle for dot-syntax callers.
pub extend RecordingLifecycle with @port.Lifecycle::{
on_compose,
on_start,
on_shutdown,
}