use std::sync::Arc;
use std::time::Duration;
use everruns_core::llmsim_driver::{LlmSimConfig, OnExhausted, SimError, SimToolCall, SimTurn};
use serde_json::json;
use crate::host_ui::UiCommand;
use crate::runtime::{BuildOptions, BuiltRuntime, ProviderChoice, build_with_options};
use crate::settings::SettingsStore;
const TURN_TIMEOUT: Duration = Duration::from_secs(15);
async fn build_scripted_runtime(config: LlmSimConfig) -> (BuiltRuntime, std::path::PathBuf) {
build_scripted_runtime_with_workspace(config, |_| {}).await
}
async fn build_scripted_runtime_with_workspace(
config: LlmSimConfig,
setup_workspace: impl FnOnce(&std::path::Path),
) -> (BuiltRuntime, std::path::PathBuf) {
build_scripted_runtime_with_workspace_and_options(
config,
setup_workspace,
BuildOptions::default(),
)
.await
}
async fn build_scripted_runtime_with_workspace_and_options(
config: LlmSimConfig,
setup_workspace: impl FnOnce(&std::path::Path),
mut options: BuildOptions,
) -> (BuiltRuntime, std::path::PathBuf) {
let workspace_root = tempfile::tempdir().expect("workspace tempdir").keep();
let sessions_root = tempfile::tempdir().expect("sessions tempdir").keep();
setup_workspace(&workspace_root);
options.llmsim_override = Some(config.with_model("llmsim-yolop"));
let settings_path = sessions_root.join("settings.toml");
let settings = Arc::new(SettingsStore::open(settings_path));
let runtime = build_with_options(
workspace_root.clone(),
ProviderChoice::Sim,
None,
sessions_root,
settings,
options,
)
.await
.expect("build scripted llmsim runtime");
(runtime, workspace_root)
}
async fn run_single_turn(runtime: &BuiltRuntime, user_text: &str) -> everruns_runtime::TurnResult {
let session_id = runtime.handles.session_id;
let input = runtime.model.input_message(user_text.to_string());
tokio::time::timeout(
TURN_TIMEOUT,
runtime.handles.runtime.run_turn(session_id, input),
)
.await
.expect("run_turn timed out")
.expect("run_turn errored")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn scripted_get_config_capabilities_smoke() {
let (runtime, _ws) = build_scripted_runtime(LlmSimConfig::scripted(vec![
SimTurn::ToolCalls(vec![SimToolCall {
name: "get_config".to_string(),
arguments: json!({ "key": "capabilities" }),
id: None,
}]),
SimTurn::Assistant("listed capabilities".to_string()),
]))
.await;
let result = run_single_turn(&runtime, "what capabilities can I configure?").await;
assert!(
result.success,
"get_config capabilities smoke must succeed: {result:?}"
);
assert_eq!(
result.tool_calls_count, 1,
"scripted get_config must run exactly once"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn scripted_assistant_text_returns_success_with_no_tools() {
let (runtime, _ws) = build_scripted_runtime(LlmSimConfig::scripted(vec![SimTurn::Assistant(
"hello from scripted llmsim".to_string(),
)]))
.await;
let result = run_single_turn(&runtime, "ping").await;
assert!(
result.success,
"scripted assistant turn must succeed: {result:?}"
);
assert_eq!(
result.tool_calls_count, 0,
"Assistant-only turn must not run tools"
);
assert!(
result.iterations >= 1,
"agent loop must record at least one iteration: {result:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn scripted_prompt_command_tool_queues_quit_ui_command() {
let (mut runtime, _ws) = build_scripted_runtime_with_workspace_and_options(
LlmSimConfig::scripted(vec![
SimTurn::ToolCalls(vec![SimToolCall {
name: "run_yolop_command".to_string(),
arguments: json!({ "command": "exit" }),
id: None,
}]),
SimTurn::Assistant("exiting".to_string()),
]),
|_| {},
BuildOptions {
client_commands: true,
..BuildOptions::default()
},
)
.await;
let result = run_single_turn(&runtime, "exit").await;
assert!(
result.success,
"prompt command turn must succeed: {result:?}"
);
assert_eq!(
result.tool_calls_count, 1,
"natural-language command should run through the tool entry point"
);
let command = runtime.ui_rx.try_recv().expect("queued UI command");
assert_eq!(command, UiCommand::Quit);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn scripted_tool_call_executes_bash_then_assistant_completes() {
let marker = "scripted_bash_ran.marker";
let cmd = format!("touch {marker}");
let (runtime, workspace) = build_scripted_runtime(LlmSimConfig::scripted(vec![
SimTurn::ToolCalls(vec![SimToolCall {
name: "bash".to_string(),
arguments: json!({ "command": cmd }),
id: None,
}]),
SimTurn::Assistant("did the bash".to_string()),
]))
.await;
let result = run_single_turn(&runtime, "do the thing").await;
assert!(
result.success,
"tool-call + assistant turn must succeed: {result:?}"
);
assert_eq!(
result.tool_calls_count, 1,
"exactly one bash invocation expected"
);
assert!(
workspace.join(marker).exists(),
"BashTool must have run the scripted command and created {marker:?} in {workspace:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn scripted_spawn_background_runs_bash_detached() {
let marker = "spawn_background_bash.marker";
let cmd = format!("printf background-ok > {marker}");
let (runtime, workspace) = build_scripted_runtime(LlmSimConfig::scripted(vec![
SimTurn::ToolCalls(vec![SimToolCall {
name: "spawn_background".to_string(),
arguments: json!({
"tool": "bash",
"args": { "command": cmd },
"title": "write marker",
"signal_on_completion": false
}),
id: None,
}]),
SimTurn::Assistant("background started".to_string()),
]))
.await;
assert!(
runtime
.startup
.tool_names
.contains(&"spawn_background".to_string()),
"spawn_background should be visible when bash supports background: {:?}",
runtime.startup.tool_names
);
let result = run_single_turn(&runtime, "start the background marker write").await;
assert!(
result.success,
"spawn_background turn must succeed: {result:?}"
);
assert_eq!(
result.tool_calls_count, 1,
"the scripted turn should call spawn_background once"
);
tokio::time::timeout(Duration::from_secs(10), async {
loop {
if workspace.join(marker).exists() {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.expect("background bash did not create marker in time");
let marker_text = tokio::fs::read_to_string(workspace.join(marker))
.await
.expect("read marker");
assert_eq!(marker_text, "background-ok");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn workspace_hook_blocks_matching_bash_call() {
let marker = "git_hook_should_block.marker";
let cmd = format!("git status > {marker}");
let (runtime, workspace) = build_scripted_runtime_with_workspace(
LlmSimConfig::scripted(vec![
SimTurn::ToolCalls(vec![SimToolCall {
name: "bash".to_string(),
arguments: json!({ "command": cmd }),
id: None,
}]),
SimTurn::Assistant("git was blocked".to_string()),
]),
|workspace| {
let hooks_dir = workspace.join(".agents");
std::fs::create_dir_all(&hooks_dir).expect("create hooks dir");
std::fs::write(
hooks_dir.join("hooks.json"),
r#"{
"hooks": [
{
"id": "block-git",
"event": "pre_tool_use",
"matcher": {
"tool_name": "bash",
"args_jsonpath": "$.command",
"match_regex": "(^|[;&|()[:space:]])git([[:space:]]|$)"
},
"executor": {
"type": "bash",
"command": "printf '%s\\n' '{\"decision\":\"block\",\"reason\":\"git blocked by test hook\",\"user_message\":\"git is blocked\"}'"
},
"timeout_ms": 1000,
"on_error": "block",
"description": "Block git"
}
]
}"#,
)
.expect("write hooks config");
},
)
.await;
let result = run_single_turn(&runtime, "try git").await;
assert!(result.success, "agent should continue after blocked tool");
assert!(
!workspace.join(marker).exists(),
"pre_tool_use hook must block bash before it creates {marker:?}"
);
assert_eq!(
runtime.startup.hook_count, 1,
"workspace hook should be loaded into startup info"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn scripted_error_turn_marks_run_failed() {
let (runtime, _ws) = build_scripted_runtime(LlmSimConfig::scripted(vec![SimTurn::Error(
SimError::Other("scripted llmsim refused the request".to_string()),
)]))
.await;
let result = run_single_turn(&runtime, "anything").await;
assert!(
!result.success,
"scripted error turn must surface as failure"
);
let error = result
.error
.as_deref()
.expect("failed run_turn must carry an error message");
assert!(
error.contains("scripted llmsim refused"),
"the scripted error message must reach the caller, got: {error}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn scripted_default_repeat_last_lets_second_call_succeed_after_exhaustion() {
let (runtime, _ws) = build_scripted_runtime(LlmSimConfig::scripted(vec![SimTurn::Assistant(
"only turn".to_string(),
)]))
.await;
let first = run_single_turn(&runtime, "first").await;
let second = run_single_turn(&runtime, "second").await;
assert!(first.success, "first run_turn must succeed");
assert!(
second.success,
"RepeatLast must let a second run_turn succeed past exhaustion: {second:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn scripted_on_exhausted_error_fails_second_call() {
let (runtime, _ws) = build_scripted_runtime(
LlmSimConfig::scripted(vec![SimTurn::Assistant("once".to_string())])
.with_on_exhausted(OnExhausted::Error),
)
.await;
let first = run_single_turn(&runtime, "first").await;
assert!(
first.success,
"first run_turn before exhaustion must succeed"
);
let second = run_single_turn(&runtime, "second").await;
assert!(
!second.success,
"OnExhausted::Error must surface as a failed turn after the script is consumed"
);
}