use std::path::Path;
use basis::{
AllowAll, CollectingSink, Event, RunConfig, RunOutcome, TurnOptions, run::prepare_with_session,
};
use mentra::{
Role, RuntimePolicy,
test::{MockRuntime, MockToolCall},
};
fn workspace() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("AGENTS.md"), "house rules").expect("write AGENTS.md");
dir
}
fn config(workspace: &Path, prompt: &str) -> RunConfig {
RunConfig::new(workspace, prompt).with_context(basis::ContextConfig {
file_name: "AGENTS.md".to_string(),
global_dir: None,
walk_parents: false,
})
}
fn mock(replies: &[&str]) -> MockRuntime {
let mut builder = MockRuntime::builder()
.model("mock-model", "openai")
.with_policy(RuntimePolicy::permissive());
for reply in replies {
builder = builder.text(*reply);
}
builder.build().expect("mock runtime builds")
}
async fn user_messages(mock: &MockRuntime, request: usize) -> Vec<String> {
mock.recorded_requests()
.await
.get(request)
.expect("the request was made")
.messages
.iter()
.filter(|message| message.role == Role::User)
.map(|message| message.text())
.collect()
}
#[tokio::test]
async fn a_second_turn_sees_the_first() {
let workspace = workspace();
let mock = mock(&["Nice to meet you.", "You said hello."]);
let session = mock
.runtime()
.create_session("test", mock.model())
.expect("session");
let config = config(workspace.path(), "hello");
let mut prepared =
prepare_with_session(session, &config, "openai", "mock-model").expect("prepared");
let first = prepared
.execute(CollectingSink::new())
.await
.expect("the first turn completes");
assert_eq!(first.final_message.as_deref(), Some("Nice to meet you."));
let second = prepared
.send("what did I say?", CollectingSink::new(), AllowAll)
.await
.expect("the second turn completes");
assert_eq!(second.final_message.as_deref(), Some("You said hello."));
let sent = user_messages(&mock, 1).await;
assert!(
sent.starts_with(&["hello".to_string(), "what did I say?".to_string()]),
"the second turn must send the whole conversation, not just its own prompt: {sent:?}"
);
}
#[tokio::test]
async fn each_turn_gets_its_own_bookends() {
let workspace = workspace();
let mock = mock(&["one", "two"]);
let session = mock
.runtime()
.create_session("test", mock.model())
.expect("session");
let config = config(workspace.path(), "first");
let mut prepared =
prepare_with_session(session, &config, "openai", "mock-model").expect("prepared");
let first = prepared
.execute(CollectingSink::new())
.await
.expect("first turn");
let second = prepared
.send("second", CollectingSink::new(), AllowAll)
.await
.expect("second turn");
for (label, report) in [("first", &first), ("second", &second)] {
let events = report.sink.events();
assert!(
matches!(events.first(), Some(Event::RunStarted { .. })),
"the {label} turn must open with a header"
);
assert!(
matches!(
events.last(),
Some(Event::RunFinished {
outcome: RunOutcome::Ok,
..
})
),
"the {label} turn must close with an outcome"
);
}
assert!(
second
.sink
.events()
.iter()
.all(|event| !matches!(event, Event::AssistantMessage { text } if text == "one")),
"the second turn's stream must not repeat the first turn's message"
);
}
#[tokio::test]
async fn the_session_survives_and_reports_its_history() {
let workspace = workspace();
let mock = mock(&["ack", "ack again"]);
let session = mock
.runtime()
.create_session("test", mock.model())
.expect("session");
let config = config(workspace.path(), "first");
let mut prepared =
prepare_with_session(session, &config, "openai", "mock-model").expect("prepared");
assert!(
prepared.history().is_empty(),
"nothing is committed before a turn runs"
);
let agent_id = prepared.agent_id().to_string();
prepared
.execute(CollectingSink::new())
.await
.expect("first turn");
prepared
.send("second", CollectingSink::new(), AllowAll)
.await
.expect("second turn");
let history = prepared.history();
let said: Vec<String> = history
.iter()
.filter(|message| message.role == Role::User)
.map(|message| message.text())
.collect();
assert_eq!(said, vec!["first".to_string(), "second".to_string()]);
assert_eq!(
prepared.agent_id(),
agent_id,
"the agent id must be stable across turns — it is what resume takes"
);
}
#[tokio::test]
async fn an_empty_follow_up_prompt_is_refused() {
let workspace = workspace();
let mock = mock(&["ok"]);
let session = mock
.runtime()
.create_session("test", mock.model())
.expect("session");
let config = config(workspace.path(), "first");
let mut prepared =
prepare_with_session(session, &config, "openai", "mock-model").expect("prepared");
prepared
.execute(CollectingSink::new())
.await
.expect("first turn");
let error = prepared
.send(" \n\t ", CollectingSink::new(), AllowAll)
.await
.expect_err("an empty follow-up is rejected");
assert!(matches!(error, basis::RunError::EmptyPrompt));
}
#[tokio::test]
async fn a_failed_turn_does_not_end_the_conversation() {
let workspace = workspace();
let mock = MockRuntime::builder()
.model("mock-model", "openai")
.with_policy(RuntimePolicy::permissive())
.failure(mentra::ProviderError::UnsupportedCapability(
"scripted failure".to_string(),
))
.text("recovered")
.build()
.expect("mock runtime builds");
let session = mock
.runtime()
.create_session("test", mock.model())
.expect("session");
let config = config(workspace.path(), "first");
let mut prepared =
prepare_with_session(session, &config, "openai", "mock-model").expect("prepared");
let failed = prepared
.execute(CollectingSink::new())
.await
.expect("the run reports rather than erroring");
assert!(!failed.succeeded());
let recovered = prepared
.send("try again", CollectingSink::new(), AllowAll)
.await
.expect("the session still takes a turn after a failure");
assert!(
recovered.succeeded(),
"a failed turn must not poison the session"
);
assert_eq!(recovered.final_message.as_deref(), Some("recovered"));
}
#[tokio::test]
async fn a_cancelled_turn_ends_rather_than_running() {
let workspace = workspace();
let mock = mock(&["never reached"]);
let session = mock
.runtime()
.create_session("test", mock.model())
.expect("session");
let config = config(workspace.path(), "go");
let mut prepared =
prepare_with_session(session, &config, "openai", "mock-model").expect("prepared");
let (options, cancel) = TurnOptions::cancellable();
cancel.cancel();
let report = prepared
.send_with_options("go", CollectingSink::new(), AllowAll, options)
.await
.expect("a cancelled turn reports rather than erroring");
assert!(
matches!(report.outcome, RunOutcome::Error { .. }),
"a cancelled turn must not report success"
);
assert!(
matches!(report.sink.events().last(), Some(Event::RunFinished { .. })),
"a cancelled turn still closes its stream"
);
}
#[tokio::test]
async fn tool_calls_from_an_earlier_turn_stay_in_the_conversation() {
let workspace = workspace();
let mock = MockRuntime::builder()
.model("mock-model", "openai")
.with_policy(RuntimePolicy::permissive())
.tool_calls(vec![MockToolCall::new(
"files",
serde_json::json!({"operations": [{"op": "list", "path": "."}]}),
)])
.text("listed them")
.text("as I said, I listed them")
.build()
.expect("mock runtime builds");
let session = mock
.runtime()
.create_session_with_config(
"test",
mock.model(),
mentra::agent::AgentConfig {
workspace: mentra::agent::WorkspaceConfig {
base_dir: workspace.path().to_path_buf(),
..Default::default()
},
..Default::default()
},
)
.expect("session");
let config = config(workspace.path(), "list the files");
let mut prepared =
prepare_with_session(session, &config, "openai", "mock-model").expect("prepared");
prepared
.execute(CollectingSink::new())
.await
.expect("first turn");
prepared
.send("what did you do?", CollectingSink::new(), AllowAll)
.await
.expect("second turn");
let requests = mock.recorded_requests().await;
let last = requests.last().expect("a request was made");
let has_tool_use = last.messages.iter().any(|message| {
message
.content
.iter()
.any(|block| matches!(block, mentra::ContentBlock::ToolUse { .. }))
});
let has_tool_result = last.messages.iter().any(|message| {
message
.content
.iter()
.any(|block| matches!(block, mentra::ContentBlock::ToolResult { .. }))
});
assert!(
has_tool_use && has_tool_result,
"a later turn must still see the earlier turn's tool round"
);
}