use super::*;
async fn create_session(app: &Router) -> String {
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/sessions")
.header("content-type", "application/json")
.body(Body::from(r#"{"workspace_id":"local"}"#))
.expect("session request"),
)
.await
.expect("session response");
assert_eq!(response.status(), StatusCode::CREATED);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("session body");
let session: Value = serde_json::from_slice(&body).expect("session json");
session["id"].as_str().expect("session id").to_string()
}
async fn assert_advertised_task_controls(app: &Router) {
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/v1/capabilities")
.body(Body::empty())
.expect("capabilities request"),
)
.await
.expect("capabilities response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("capabilities body");
let summary: Value = serde_json::from_slice(&body).expect("capabilities json");
assert!(
summary["capabilities"]
.as_array()
.expect("capability list")
.iter()
.any(|entry| entry["id"] == "tasks"),
"the advertised API surface must include task submission and cancellation: {summary}"
);
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/v1/tools")
.body(Body::empty())
.expect("tools request"),
)
.await
.expect("tools response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("tools body");
let tools: Value = serde_json::from_slice(&body).expect("tools json");
let advertised = tools["data"].as_array().expect("tool list");
for tool_id in ["harn.session.prompt", "harn.session.cancel"] {
assert!(
advertised.iter().any(|entry| entry["id"] == tool_id),
"advertised task control {tool_id} is missing: {tools}"
);
}
}
async fn submit_and_wait(
app: &Router,
events: &mut broadcast::Receiver<ApiEvent>,
session_id: &str,
prompt: &str,
) -> ApiEvent {
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/v1/sessions/{session_id}/tasks"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&json!({"input": prompt})).expect("task json"),
))
.expect("task request"),
)
.await
.expect("task response");
assert_eq!(response.status(), StatusCode::ACCEPTED);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("task body");
let task: Value = serde_json::from_slice(&body).expect("task json");
let task_id = task["id"].as_str().expect("task id");
tokio::time::timeout(std::time::Duration::from_secs(10), async {
loop {
let event = events.recv().await.expect("task terminal event");
if event.task_id.as_deref() == Some(task_id)
&& matches!(event.event.as_str(), "task.completed" | "task.failed")
{
return event;
}
}
})
.await
.unwrap_or_else(|_| panic!("timed out waiting for terminal event for task {task_id}"))
}
#[tokio::test]
async fn default_agents_api_task_reaches_a_model_turn_and_refuses_a_workspace_write() {
let dir = tempfile::tempdir().expect("tempdir");
let script = dir.path().join("agent.harn");
std::fs::write(
&script,
r#"import { agent_loop } from "std/agent/loop"
pipeline main(harness: Harness) {
if prompt == "runtime-control" {
agent_loop(harness, prompt, nil, {provider: "mock", model: "served-proof"})
assert(
len(harness.llm.mock_calls()) == 1,
"the default served task must consume exactly one model call",
)
harness.stdio.println("control-plane-ok")
} else {
harness.fs.write_text("ceiling-probe.txt", "must-not-write")
}
}
"#,
)
.expect("write pipeline");
let mut config = ApiServerConfig::for_pipeline(script.to_string_lossy().to_string());
config.workspace_root = dir.path().to_path_buf();
let server = ApiServer::new(config);
let state = server.state;
let mut events = state.events_tx.subscribe();
let app = api_router(state);
assert_advertised_task_controls(&app).await;
let admitted_session = create_session(&app).await;
let admitted = submit_and_wait(&app, &mut events, &admitted_session, "runtime-control").await;
assert_eq!(
admitted.event, "task.completed",
"the API task must reach and finish runtime-owned session bookkeeping: {}",
admitted.payload
);
let denied_session = create_session(&app).await;
let denied = submit_and_wait(&app, &mut events, &denied_session, "workspace-write").await;
assert_eq!(
denied.event, "task.failed",
"workspace write escaped ceiling"
);
let failure = denied.payload["failure"]["message"]
.as_str()
.unwrap_or_default();
assert!(
failure.contains("exceeds the active effect ceiling"),
"the control must fail at the read-only ceiling, not for an unrelated reason: {failure}"
);
assert!(
!dir.path().join("ceiling-probe.txt").exists(),
"the rejected workspace write reached the filesystem"
);
}