use super::super::*;
use super::contracts::{
GraphContract, assert_all_processes_terminal, assert_failed_code_block_present,
assert_graph_lineage_connected, assert_labeled_resource_operation,
assert_no_duplicate_label_step, assert_no_false_finishted_success,
assert_no_forbidden_error_text, assert_subagent_bridge_exec_graphs,
};
use super::harness::{
AgentScenario, lashlang_block, run_agent_durable_input_request_scenario,
run_agent_session_turn_process_scenario, run_agent_turn_scenario,
run_agent_turn_scenario_without_success_assertions,
};
use std::collections::BTreeSet;
#[derive(Clone, Copy, Debug)]
struct AgentScenarioCoverage {
test_name: &'static str,
declared_test: fn() -> Result<()>,
scenario_name: &'static str,
owned_boundary: &'static str,
}
macro_rules! agent_scenario_coverage {
($test_fn:ident, $scenario_name:literal, $owned_boundary:literal) => {
AgentScenarioCoverage {
test_name: stringify!($test_fn),
declared_test: $test_fn,
scenario_name: $scenario_name,
owned_boundary: $owned_boundary,
}
};
}
const FOREGROUND_LABELED_TOOL_CALL: AgentScenarioCoverage = agent_scenario_coverage!(
agent_scenario_foreground_labeled_tool_call,
"foreground labeled tool call",
"Facade root turn, app tool execution, label graph, final value, and remote DTO round trip."
);
const STARTED_PROCESS_LABELED_TOOL_CALL: AgentScenarioCoverage = agent_scenario_coverage!(
agent_scenario_started_process_labeled_tool_call,
"started process labeled tool call",
"Started Lashlang process calling an app tool with process graph completion."
);
const DURABLE_INPUT_REQUEST: AgentScenarioCoverage = agent_scenario_coverage!(
agent_scenario_process_durable_input_request_tool,
"durable input suspension",
"Live durable input suspension, external resolution, process event, and final value."
);
const SHELL_RESULTS_ARE_DATA: AgentScenarioCoverage = agent_scenario_coverage!(
agent_scenario_shell_nonzero_and_pipeline_results_are_data,
"shell nonzero and pipeline results are data",
"Shell failures and pipelines remain data at the facade boundary."
);
const SHELL_OUTPUT_VARIABLE: AgentScenarioCoverage = agent_scenario_coverage!(
agent_scenario_shell_output_survives_print_projection_in_variable,
"shell output survives print projection in variable",
"Large shell output survives print projection and remains addressable."
);
const STARTED_PROCESS_SUBAGENT: AgentScenarioCoverage = agent_scenario_coverage!(
agent_scenario_started_process_labeled_subagent_spawn,
"started process labeled subagent spawn",
"Started process spawns a subagent and records child session execution graphs."
);
const NESTED_PROCESS_START_AWAIT: AgentScenarioCoverage = agent_scenario_coverage!(
agent_scenario_nested_process_start_await,
"nested process start await",
"Nested process start/await produces deterministic process ids and graph lineage."
);
const SESSION_TURN_PROCESS_CHILD: AgentScenarioCoverage = agent_scenario_coverage!(
agent_scenario_session_turn_process_child,
"session turn process child",
"Host session-turn process API creates and awaits a child session turn."
);
const FAILED_CHILD_PRESERVES_GRAPH: AgentScenarioCoverage = agent_scenario_coverage!(
agent_scenario_failed_child_preserves_failure_graph,
"failed child preserves failure graph",
"Child failure path preserves failure graph and avoids provider-exhaustion false failures."
);
const PARALLEL_SPAWN_AND_JOIN: AgentScenarioCoverage = agent_scenario_coverage!(
agent_scenario_parallel_spawn_and_join,
"parallel process spawn and join",
"Parallel process starts join deterministically with unique process ids."
);
const TUPLE_VALUES_AS_JSON_ARRAYS: AgentScenarioCoverage = agent_scenario_coverage!(
agent_scenario_tuple_values_finish_as_json_arrays,
"tuple values finish as json arrays",
"Facade final values preserve tuple-to-JSON array projection."
);
const AGENT_SCENARIO_COVERAGE: &[AgentScenarioCoverage] = &[
FOREGROUND_LABELED_TOOL_CALL,
STARTED_PROCESS_LABELED_TOOL_CALL,
DURABLE_INPUT_REQUEST,
SHELL_RESULTS_ARE_DATA,
SHELL_OUTPUT_VARIABLE,
STARTED_PROCESS_SUBAGENT,
NESTED_PROCESS_START_AWAIT,
SESSION_TURN_PROCESS_CHILD,
FAILED_CHILD_PRESERVES_GRAPH,
PARALLEL_SPAWN_AND_JOIN,
TUPLE_VALUES_AS_JSON_ARRAYS,
];
#[test]
fn agent_scenario_coverage_metadata_is_unique_and_complete() {
assert_eq!(AGENT_SCENARIO_COVERAGE.len(), 11);
let mut names = BTreeSet::new();
for coverage in AGENT_SCENARIO_COVERAGE {
let _declared_test = coverage.declared_test;
assert!(
coverage.test_name.starts_with("agent_scenario_"),
"unexpected Agent Scenario test name {}",
coverage.test_name
);
assert!(!coverage.scenario_name.trim().is_empty());
assert!(!coverage.owned_boundary.trim().is_empty());
assert!(
names.insert(coverage.test_name),
"duplicate Agent Scenario coverage metadata for {}",
coverage.test_name
);
}
}
#[test]
fn agent_scenario_foreground_labeled_tool_call() -> Result<()> {
run_async_test_on_stack_budget("agent-scenario-foreground-tool", || async {
let case = AgentScenario::new(
FOREGROUND_LABELED_TOOL_CALL.scenario_name,
"Call the app lookup tool and finish its value.",
)
.response(lashlang_block(
r#"
@label(title: "Lookup app state")
value = await tools.app_lookup({})?
finish value"#,
))
.expected_final_value(serde_json::json!({ "ok": true }))
.tool_provider(Arc::new(AppTools))
.labeled_resource("Lookup app state");
let run = run_agent_turn_scenario(case).await?;
assert_eq!(run.prompt_captures.len(), 1);
Ok(())
})
}
#[test]
fn agent_scenario_started_process_labeled_tool_call() -> Result<()> {
run_async_test_on_stack_budget("agent-scenario-started-process-tool", || async {
run_agent_turn_scenario(
AgentScenario::new(
STARTED_PROCESS_LABELED_TOOL_CALL.scenario_name,
"Start a process that calls the app lookup tool.",
)
.response(lashlang_block(
r#"
process lookup(tools: Tools) {
@label(title: "Lookup app state in process")
value = await tools.app_lookup({})?
finish value
}
handle = start lookup(tools: tools)
result = (await handle)?
finish result"#,
))
.expected_final_value(serde_json::json!({ "ok": true }))
.tool_provider(Arc::new(AppTools))
.labeled_resource("Lookup app state in process")
.completed_process("lookup")
.min_completed_process_graphs(1),
)
.await?;
Ok(())
})
}
#[test]
fn agent_scenario_process_durable_input_request_tool() -> Result<()> {
run_async_test_on_stack_budget("agent-scenario-durable-input-request", || async {
run_agent_durable_input_request_scenario().await
})
}
#[test]
fn agent_scenario_shell_nonzero_and_pipeline_results_are_data() -> Result<()> {
run_async_test_on_stack_budget("agent-scenario-shell-results-are-data", || async {
run_agent_turn_scenario(
AgentScenario::new(
SHELL_RESULTS_ARE_DATA.scenario_name,
"Run shell commands and report their result metadata.",
)
.response(lashlang_block(
r#"
pipe = await shell.exec({ cmd: "yes line | head -n 3", login: false })?
missing = await shell.exec({ cmd: "test -f /tmp/agent-scenario-definitely-missing-file", login: false })?
finish {
pipe_exit: pipe.exit_code,
pipe_output: pipe.output,
missing_exit: missing.exit_code,
missing_status: missing.status
}"#,
))
.expected_final_value(serde_json::json!({
"pipe_exit": 0,
"pipe_output": "line\nline\nline\n",
"missing_exit": 1,
"missing_status": "completed"
}))
.tool_provider(Arc::new(lash_tools::shell::shell_provider(
lash_tools::shell::StandardShell::new(),
))),
)
.await?;
Ok(())
})
}
#[test]
fn agent_scenario_shell_output_survives_print_projection_in_variable() -> Result<()> {
run_async_test_on_stack_budget("agent-scenario-shell-output-variable", || async {
run_agent_turn_scenario(
AgentScenario::new(
SHELL_OUTPUT_VARIABLE.scenario_name,
"Run a large shell command, inspect it, then report retained metadata.",
)
.responses([
lashlang_block(
r#"
big = await shell.exec({ cmd: "yes x | head -c 60000", login: false })?
print big.output"#,
),
lashlang_block(
r#"
finish {
chars: len(big.output),
tail: slice(big.output, 59996, null),
has_full_output_path: big.full_output_path == null ? false : len(big.full_output_path) > 0
}"#,
),
])
.expected_final_value(serde_json::json!({
"chars": 60000,
"tail": "x\nx\n",
"has_full_output_path": true
}))
.tool_provider(Arc::new(lash_tools::shell::shell_provider(
lash_tools::shell::StandardShell::new(),
))),
)
.await?;
Ok(())
})
}
#[test]
fn agent_scenario_started_process_labeled_subagent_spawn() -> Result<()> {
run_async_test_on_stack_budget("agent-scenario-started-process-subagent", || async {
run_agent_turn_scenario(
AgentScenario::new(
STARTED_PROCESS_SUBAGENT.scenario_name,
"Run a Lashlang process that spawns a subagent and returns its value.",
)
.responses([
lashlang_block(
r#"
process spawn_child() {
@label(title: "Spawn subagent with web search")
result = await agents.spawn({
capability: "default",
task: "Finish `{ len: len(chunk) }` using the seeded `chunk` variable.",
seed: { chunk: ["a", "b"] },
output: Type { len: int }
})?
finish result
}
handle = start spawn_child()
result = (await handle)?
finish result"#,
),
lashlang_block("finish { len: len(chunk) }"),
])
.expected_final_value(serde_json::json!({ "len": 2 }))
.install_subagents()
.labeled_resource("Spawn subagent with web search")
.completed_process("spawn_child")
.min_completed_child_session_exec_graphs(1)
.min_completed_process_graphs(1),
)
.await?;
Ok(())
})
}
#[test]
fn agent_scenario_nested_process_start_await() -> Result<()> {
run_async_test_on_stack_budget("agent-scenario-nested-process", || async {
let run = run_agent_turn_scenario(
AgentScenario::new(
NESTED_PROCESS_START_AWAIT.scenario_name,
"Start a parent process that starts and awaits a child process.",
)
.response(lashlang_block(
r#"
process child() {
finish { child: "done" }
}
process parent() {
@label(title: "Start nested child process")
handle = start child()
result = (await handle)?
finish { parent: result.child }
}
handle = start parent()
result = (await handle)?
finish result"#,
))
.expected_final_value(serde_json::json!({ "parent": "done" }))
.labeled_node("Start nested child process")
.completed_process("parent")
.completed_process("child")
.min_completed_process_graphs(2),
)
.await?;
assert_lashlang_process_ids_unique_for_labels(&run.final_process_list, ["parent", "child"]);
Ok(())
})
}
#[test]
fn agent_scenario_session_turn_process_child() -> Result<()> {
run_async_test_on_stack_budget("agent-scenario-session-turn-process", || async {
run_agent_session_turn_process_scenario().await
})
}
#[test]
fn agent_scenario_failed_child_preserves_failure_graph() -> Result<()> {
run_async_test_on_stack_budget("agent-scenario-failed-child", || async {
let run = run_agent_turn_scenario_without_success_assertions(
AgentScenario::new(
FAILED_CHILD_PRESERVES_GRAPH.scenario_name,
"Spawn a child that fails and preserve its execution graph.",
)
.responses([
lashlang_block(
r#"
@label(title: "Spawn failing subagent")
result = await agents.spawn({
capability: "default",
task: "Fail with reason child boom.",
seed: {},
output: Type { reason: str }
})?
finish result"#,
),
lashlang_block(r#"await task.fail({ reason: "child boom" })?"#),
lashlang_block(r#"await task.fail({ reason: "parent observed child failure" })?"#),
])
.install_subagents()
.max_turns(1),
)
.await?;
assert_failed_code_block_present(&run.streamed_events);
assert_no_forbidden_error_text(&run.streamed_events);
assert!(
!format!("{:#?}", run.streamed_events)
.contains("scripted agent scenario provider exhausted"),
"failed-child scenario must fail through the child task.fail path, not provider exhaustion"
);
assert_no_false_finishted_success(&run);
assert_all_processes_terminal(&run.final_process_list);
let contract = GraphContract::from_graphs(&run.graph_snapshots);
assert_labeled_resource_operation(
&contract,
"Spawn failing subagent",
crate::tracing::TraceLashlangNodeStatus::Failed,
);
assert_no_duplicate_label_step(&contract, "Spawn failing subagent");
assert_graph_lineage_connected(&contract, &run.final_process_list);
assert_subagent_bridge_exec_graphs(&run, crate::tracing::TraceLashlangStatus::Completed);
Ok(())
})
}
#[test]
fn agent_scenario_parallel_spawn_and_join() -> Result<()> {
run_async_test_on_stack_budget("agent-scenario-parallel-spawn-join", || async {
let run = run_agent_turn_scenario(
AgentScenario::new(
PARALLEL_SPAWN_AND_JOIN.scenario_name,
"Start two processes, await both, and finish their joined result.",
)
.response(lashlang_block(
r#"
process child(value: str) {
finish value
}
@label(title: "Start left process")
left = start child(value: "left")
@label(title: "Start right process")
right = start child(value: "right")
left_value = (await left)?
right_value = (await right)?
finish { joined: [left_value, right_value] }"#,
))
.expected_final_value(serde_json::json!({ "joined": ["left", "right"] }))
.labeled_node("Start left process")
.labeled_node("Start right process")
.completed_process("child")
.min_completed_process_graphs(2),
)
.await?;
assert_lashlang_process_ids_unique_for_labels(&run.final_process_list, ["child", "child"]);
Ok(())
})
}
#[test]
fn agent_scenario_tuple_values_finish_as_json_arrays() -> Result<()> {
run_async_test_on_stack_budget("agent-scenario-tuple-values", || async {
run_agent_turn_scenario(
AgentScenario::new(
TUPLE_VALUES_AS_JSON_ARRAYS.scenario_name,
"Use tuple values and finish the derived result.",
)
.response(lashlang_block(
r#"
pair = "left", "right"
tail = slice(pair, 1, null)
seen = []
for item in pair {
seen = push(seen, item)
}
finish {
first: pair[0],
tail: tail,
seen: seen,
tuple: pair,
nested: { pair: pair }
}"#,
))
.expected_final_value(serde_json::json!({
"first": "left",
"tail": ["right"],
"seen": ["left", "right"],
"tuple": ["left", "right"],
"nested": { "pair": ["left", "right"] }
})),
)
.await?;
Ok(())
})
}
fn assert_lashlang_process_ids_unique_for_labels<const N: usize>(
processes: &[lash_core::ProcessHandleSummary],
expected_labels: [&str; N],
) {
let mut ids = BTreeSet::new();
let mut labels = Vec::new();
for process in processes {
let Some(kind) = process.descriptor.kind.as_deref() else {
continue;
};
if kind != lash_lashlang_runtime::LASHLANG_ENGINE_KIND {
continue;
}
assert!(
process.process_id.starts_with("process:lashlang:sha256:"),
"lashlang process `{}` did not use a deterministic process id",
process.process_id
);
assert!(
ids.insert(process.process_id.as_str()),
"duplicate lashlang process id `{}`",
process.process_id
);
labels.push(process.descriptor.label.as_deref().unwrap_or("<missing>"));
}
labels.sort_unstable();
let mut expected = expected_labels;
expected.sort_unstable();
assert_eq!(labels, expected);
}