use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use mermaid_cli::domain::{ChatRequest, Cmd, Msg, TurnId};
use mermaid_cli::effect::EffectRunner;
use mermaid_cli::models::{ChatMessage, ProviderContinuation, ReasoningLevel};
use mermaid_cli::providers::ProviderFactory;
use mermaid_cli::providers::model::ModelProvider;
use mermaid_cli::providers::tool::ToolRegistry;
#[path = "harness/stub_model.rs"]
mod stub_model;
use stub_model::{ScriptedModel, Turn};
const STUB: &str = "stub/scripted";
fn request(messages: Vec<ChatMessage>) -> ChatRequest {
ChatRequest {
model_id: STUB.to_string(),
messages,
system_prompt: "You are a coding assistant.".to_string(),
instructions: None,
reasoning: ReasoningLevel::None,
temperature: 0.7,
max_tokens: 4096,
tools: Vec::new(),
..Default::default()
}
}
fn runner_with(model: Arc<ScriptedModel>) -> (EffectRunner, tokio::sync::mpsc::Receiver<Msg>) {
let providers = Arc::new(ProviderFactory::with_seeded_providers(
mermaid_cli::app::Config::default(),
[(STUB.to_string(), model as Arc<dyn ModelProvider>)],
));
EffectRunner::pair_from(PathBuf::from("."), providers, Arc::new(ToolRegistry::new()))
}
async fn wait_for<T>(
rx: &mut tokio::sync::mpsc::Receiver<Msg>,
what: &str,
mut f: impl FnMut(&Msg) -> Option<T>,
) -> T {
tokio::time::timeout(Duration::from_secs(20), async {
while let Some(msg) = rx.recv().await {
if let Some(hit) = f(&msg) {
return hit;
}
}
panic!("the runner closed before {what}");
})
.await
.unwrap_or_else(|_| panic!("timed out waiting for {what}"))
}
#[tokio::test]
async fn a_provider_continuation_rides_the_next_request() {
let model = ScriptedModel::new([Turn::say("Thinking about it.").with_continuation(
ProviderContinuation::Anthropic {
signature: "opaque-thinking-signature".to_string(),
},
)]);
let (mut runner, mut rx) = runner_with(model.clone());
runner.dispatch(Cmd::CallModel {
turn: TurnId(1),
request: request(vec![ChatMessage::user("hello")]),
});
let continuation = wait_for(&mut rx, "the turn to finish", |msg| match msg {
Msg::StreamDone {
provider_continuation,
..
} => Some(provider_continuation.clone()),
_ => None,
})
.await;
assert_eq!(
continuation,
Some(ProviderContinuation::Anthropic {
signature: "opaque-thinking-signature".to_string()
}),
"the effect layer must surface the provider's continuation to the reducer"
);
runner.shutdown().await;
}
#[tokio::test]
async fn a_continuation_attached_to_history_is_sent_back_upstream() {
let model = ScriptedModel::new([Turn::say("Continuing.")]);
let (mut runner, mut rx) = runner_with(model.clone());
let prior = ChatMessage::assistant("Step one done.").with_provider_continuation(
ProviderContinuation::Anthropic {
signature: "carried-signature".to_string(),
},
);
runner.dispatch(Cmd::CallModel {
turn: TurnId(1),
request: request(vec![
ChatMessage::user("go"),
prior,
ChatMessage::user("next"),
]),
});
wait_for(&mut rx, "the turn to finish", |msg| {
matches!(msg, Msg::StreamDone { .. }).then_some(())
})
.await;
let sent = model.requests();
let carried = sent[0]
.messages
.iter()
.filter_map(|m| m.provider_continuation.clone())
.collect::<Vec<_>>();
assert_eq!(
carried,
vec![ProviderContinuation::Anthropic {
signature: "carried-signature".to_string()
}],
"the request that left the process dropped the continuation"
);
runner.shutdown().await;
}
#[tokio::test]
async fn one_turn_emitting_several_tool_calls_reports_them_all() {
let model = ScriptedModel::new([Turn::tools([
(
"read_file".to_string(),
serde_json::json!({"path": "a.txt"}),
),
(
"read_file".to_string(),
serde_json::json!({"path": "b.txt"}),
),
(
"read_file".to_string(),
serde_json::json!({"path": "c.txt"}),
),
])]);
let (mut runner, mut rx) = runner_with(model.clone());
runner.dispatch(Cmd::CallModel {
turn: TurnId(1),
request: request(vec![ChatMessage::user("read the files")]),
});
let mut calls = Vec::new();
wait_for(&mut rx, "the turn to finish", |msg| match msg {
Msg::StreamToolCall { call, .. } => {
calls.push(call.clone());
None
},
Msg::StreamDone { .. } => Some(()),
_ => None,
})
.await;
assert_eq!(calls.len(), 3, "all three calls must reach the reducer");
let paths: Vec<String> = calls
.iter()
.filter_map(|c| {
c.function
.arguments
.get("path")
.and_then(|v| v.as_str())
.map(str::to_string)
})
.collect();
assert_eq!(
paths,
vec!["a.txt", "b.txt", "c.txt"],
"arguments must survive"
);
runner.shutdown().await;
}
#[tokio::test]
async fn cancelling_a_turn_aborts_an_in_flight_model_call() {
let model = ScriptedModel::new([Turn::stall(60)]);
let (mut runner, mut rx) = runner_with(model);
runner.dispatch(Cmd::CallModel {
turn: TurnId(1),
request: request(vec![ChatMessage::user("write me an essay")]),
});
tokio::time::sleep(Duration::from_millis(100)).await;
let cancelled_at = std::time::Instant::now();
runner.dispatch(Cmd::CancelScope(TurnId(1)));
wait_for(&mut rx, "the cancelled turn to unwind", |msg| {
matches!(
msg,
Msg::StreamDone { .. } | Msg::UpstreamError { .. } | Msg::TurnCancelled { .. }
)
.then_some(())
})
.await;
assert!(
cancelled_at.elapsed() < Duration::from_secs(10),
"cancellation took {:?} — the model call did not honor the turn token",
cancelled_at.elapsed()
);
runner.shutdown().await;
}
#[tokio::test]
async fn a_provider_error_reaches_the_user_with_its_reason() {
let model = ScriptedModel::new([Turn::fail("rate limited: retry after 30s")]);
let (mut runner, mut rx) = runner_with(model);
runner.dispatch(Cmd::CallModel {
turn: TurnId(1),
request: request(vec![ChatMessage::user("hello")]),
});
let text = wait_for(&mut rx, "an upstream error", |msg| match msg {
Msg::UpstreamError { error, .. } => Some(format!("{error:?}")),
_ => None,
})
.await;
assert!(
text.contains("rate limited") && text.contains("30s"),
"the provider's own reason must survive to the user: {text}"
);
runner.shutdown().await;
}