// ---------------------------------------------------------------------------
// ScriptedModel — returns responses/errors/stream chunks by call sequence.
// ---------------------------------------------------------------------------
///|
/// A single scripted model interaction. R3 M3.7: each step produces a
/// `ModelCallResult` (the canonical chat return type) instead of the deleted
/// `ModelResponse`. `Stream` carries the chunk sequence plus the final
/// completion; chunks are emitted to the callback during the call.
pub(all) enum ScriptedModelStep {
Respond(@kernel.ModelCallResult)
Stream(
chunks~ : Array[@types.StreamChunk],
response~ : @kernel.ModelCallResult
)
Fail(@error.ModelError)
}
///|
fn testkit_snapshot_model_step(step : ScriptedModelStep) -> ScriptedModelStep {
match step {
Respond(result) => Respond(testkit_snapshot_model_call_result(result))
Stream(chunks~, response~) =>
Stream(
chunks=chunks.copy(),
response=testkit_snapshot_model_call_result(response),
)
Fail(error) => Fail(error)
}
}
///|
/// ModelPort fake that plays a fixed script. Each `chat` call consumes one
/// step in order. When the script is exhausted the next call fails with
/// `ModelError::Transport("scripted_model_exhausted at call ")` — it never
/// silently repeats the last response.
pub(all) struct ScriptedModel {
steps : Array[ScriptedModelStep]
mut index : Int
mut calls : Int
received_messages : Array[Array[@kernel.Message]]
received_tools : Array[Array[@kernel.ToolDef]]
received_options : Array[@types.ChatOptions]
received_chunks : Array[@types.StreamChunk]
}
///|
pub fn ScriptedModel::ScriptedModel(
steps : Array[ScriptedModelStep],
) -> ScriptedModel {
{
steps: steps.map(testkit_snapshot_model_step),
index: 0,
calls: 0,
received_messages: [],
received_tools: [],
received_options: [],
received_chunks: [],
}
}
///|
/// Number of chat calls observed so far.
pub fn ScriptedModel::call_count(self : ScriptedModel) -> Int {
self.calls
}
///|
/// Fabricated `InvocationScope` for tests that drive a `ModelPort` directly
/// (no reducer in play, so `effect_id` is `None`).
pub fn tk_scope(
session_id? : String = "tk_session",
run_id? : String = "tk_run",
pressure? : @kernel.ContextPressure? = None,
) -> @kernel.InvocationScope {
{
session_id: @kernel.SessionId::unchecked(session_id),
run_id: @kernel.RunId::unchecked(run_id),
effect_id: None,
pressure,
}
}
///|
/// Direct (non-trait) chat entry point for testkit self-tests.
/// Trait methods on concrete types must be dispatched through a `&ModelPort`
/// reference; this wrapper lets tests call chat without an Agent.
pub async fn ScriptedModel::chat_direct(
self : ScriptedModel,
messages : Array[@kernel.Message],
tools : Array[@kernel.ToolDef],
options : @types.ChatOptions,
) -> @kernel.ModelCallResult raise @error.ModelError {
(self as &@port.ModelPort).chat(
tk_scope(),
messages[:],
tools,
options,
@types.NoStream,
)
}
///|
/// Snapshot of all chat options received, in call order.
pub fn ScriptedModel::options_received(
self : ScriptedModel,
) -> Array[@types.ChatOptions] {
self.received_options.copy()
}
///|
fn ScriptedModel::take_step(
self : ScriptedModel,
messages : ArrayView[@kernel.Message],
tools : Array[@kernel.ToolDef],
options : @types.ChatOptions,
) -> ScriptedModelStep raise @error.ModelError {
self.calls = self.calls + 1
self.received_messages.push(snapshot_messages(messages.to_owned()))
self.received_tools.push(tools.map(testkit_snapshot_tool_def))
self.received_options.push(options)
if self.index < self.steps.length() {
let step = self.steps[self.index]
self.index = self.index + 1
step
} else {
raise @error.ModelError::Transport(
"scripted_model_exhausted at call \{self.calls} (script had \{self.steps.length()} steps)",
)
}
}
///|
pub impl @port.ModelPort for ScriptedModel with fn chat(
self,
_scope : @kernel.InvocationScope,
messages : ArrayView[@kernel.Message],
tools : Array[@kernel.ToolDef],
options : @types.ChatOptions,
stream : @types.StreamMode,
) -> @kernel.ModelCallResult raise @error.ModelError {
match self.take_step(messages, tools, options) {
Respond(result) => testkit_snapshot_model_call_result(result)
Stream(chunks~, response~) => {
match stream {
@types.Stream(cb) =>
for chunk in chunks {
self.received_chunks.push(chunk)
cb(chunk)
}
@types.NoStream => ()
}
testkit_snapshot_model_call_result(response)
}
Fail(e) => raise e
}
}
///|
pub impl @port.ModelPort for ScriptedModel with fn compact(
_self,
_scope : @kernel.InvocationScope,
_messages : ArrayView[@kernel.Message],
_options : @types.ChatOptions,
_trigger : @kernel.CompactTrigger,
) -> @kernel.CompactResult raise @error.ModelError {
raise @error.ModelError::ResponseParse(
"ScriptedModel does not implement compact",
)
}
///|
/// ScriptedModel declares no supported reasoning_effort values. Tests that
/// need to exercise the ConfigWarning path on validation can construct a
/// custom model impl that returns a non-empty `ProviderConfig`.
pub impl @port.ModelPort for ScriptedModel with fn provider_config(_self) -> @port.ProviderConfig {
@port.ProviderConfig::empty()
}
// ---------------------------------------------------------------------------
// ScopeRecordingModel — records the InvocationScope of every model-side call.
// ---------------------------------------------------------------------------
///|
/// ModelPort fake that records the `InvocationScope` received by every
/// `chat`/`compact` call. Chat behaviour is scripted like `ScriptedModel`;
/// `compact` returns `compact_result` when set, otherwise raises
/// `ResponseParse` (same contract as `ScriptedModel`). Use it to pin the
/// scope-flow contract end to end.
pub struct ScopeRecordingModel {
scripted : ScriptedModel
compact_result : @kernel.CompactResult?
chat_scopes : Array[@kernel.InvocationScope]
compact_scopes : Array[@kernel.InvocationScope]
}
///|
pub fn ScopeRecordingModel::ScopeRecordingModel(
steps : Array[ScriptedModelStep],
compact_result? : @kernel.CompactResult,
) -> ScopeRecordingModel {
{
scripted: ScriptedModel(steps),
compact_result,
chat_scopes: [],
compact_scopes: [],
}
}
///|
/// Scopes observed by `chat`, in call order.
pub fn ScopeRecordingModel::chat_scopes(
self : ScopeRecordingModel,
) -> Array[@kernel.InvocationScope] {
self.chat_scopes.copy()
}
///|
/// Scopes observed by `compact`, in call order.
pub fn ScopeRecordingModel::compact_scopes(
self : ScopeRecordingModel,
) -> Array[@kernel.InvocationScope] {
self.compact_scopes.copy()
}
///|
pub impl @port.ModelPort for ScopeRecordingModel with fn chat(
self,
scope : @kernel.InvocationScope,
messages : ArrayView[@kernel.Message],
tools : Array[@kernel.ToolDef],
options : @types.ChatOptions,
stream : @types.StreamMode,
) -> @kernel.ModelCallResult raise @error.ModelError {
self.chat_scopes.push(scope)
(self.scripted as &@port.ModelPort).chat(
scope, messages, tools, options, stream,
)
}
///|
pub impl @port.ModelPort for ScopeRecordingModel with fn compact(
self,
scope : @kernel.InvocationScope,
_messages : ArrayView[@kernel.Message],
_options : @types.ChatOptions,
_trigger : @kernel.CompactTrigger,
) -> @kernel.CompactResult raise @error.ModelError {
self.compact_scopes.push(scope)
match self.compact_result {
Some(result) => result
None =>
raise @error.ModelError::ResponseParse(
"ScopeRecordingModel has no compact_result",
)
}
}
///|
pub impl @port.ModelPort for ScopeRecordingModel with fn provider_config(_self) -> @port.ProviderConfig {
@port.ProviderConfig::empty()
}