use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{Value, json};
use tempfile::TempDir;
use trusty_common::mcp::{Request, Response, error_codes};
use super::SmDispatcher;
use super::control::{LaunchParams, SessionControl, SessionControlError};
use super::methods::{CODE_NOT_FOUND, CODE_UNAVAILABLE};
use crate::core::sm::SessionManagerConfig;
use crate::core::sm::agent::SessionManagerAgent;
use crate::core::sm::agent::mock::{MockChatProvider, MockResolver};
#[derive(Default)]
struct MockSessionControl {
fail_get_not_found: bool,
fail_stop_kill_backend: bool,
last_launch: std::sync::Mutex<Option<LaunchParams>>,
}
#[async_trait]
impl SessionControl for MockSessionControl {
async fn launch(&self, params: LaunchParams) -> Result<Value, SessionControlError> {
*self.last_launch.lock().expect("lock") = Some(params);
Ok(json!({ "session_id": "11111111-1111-1111-1111-111111111111" }))
}
async fn list(&self) -> Result<Value, SessionControlError> {
Ok(json!({ "sessions": [] }))
}
async fn get(&self, session_id: &str) -> Result<Value, SessionControlError> {
if self.fail_get_not_found {
return Err(SessionControlError::NotFound(session_id.to_string()));
}
Ok(json!({ "session": { "id": session_id, "state": "active" } }))
}
async fn send(&self, _id: &str, _text: &str) -> Result<Value, SessionControlError> {
Ok(json!({ "ok": true }))
}
async fn stop(&self, _id: &str) -> Result<Value, SessionControlError> {
if self.fail_stop_kill_backend {
return Err(SessionControlError::Backend("tmux kill failed".into()));
}
Ok(json!({ "ok": true }))
}
async fn resume(&self, _id: &str) -> Result<Value, SessionControlError> {
Ok(json!({ "ok": true }))
}
async fn kill(&self, _id: &str) -> Result<Value, SessionControlError> {
if self.fail_stop_kill_backend {
return Err(SessionControlError::Backend(
"decommission tmux failed".into(),
));
}
Ok(json!({ "ok": true }))
}
}
fn enabled_config() -> SessionManagerConfig {
SessionManagerConfig {
enabled: true,
..SessionManagerConfig::default()
}
}
fn agent_with_provider(
cfg: SessionManagerConfig,
data_root: &std::path::Path,
) -> Arc<SessionManagerAgent> {
let provider = MockChatProvider::new("SM plan: delegate to a session", 0.0021);
let resolver = Arc::new(MockResolver::with_provider(provider));
Arc::new(SessionManagerAgent::for_test(
cfg,
resolver,
data_root.to_path_buf(),
))
}
fn agent_degraded(
cfg: SessionManagerConfig,
data_root: &std::path::Path,
) -> Arc<SessionManagerAgent> {
let resolver = Arc::new(MockResolver::degraded());
Arc::new(SessionManagerAgent::for_test(
cfg,
resolver,
data_root.to_path_buf(),
))
}
fn dispatcher_with(
agent: Arc<SessionManagerAgent>,
cfg: SessionManagerConfig,
data_root: &std::path::Path,
control: Arc<MockSessionControl>,
) -> SmDispatcher {
let sessions: Arc<dyn SessionControl> = control;
#[cfg(feature = "sm-memory")]
let goals = Some(test_goal_store(data_root));
#[cfg(not(feature = "sm-memory"))]
let goals = None;
SmDispatcher::new(agent, cfg, data_root.to_path_buf(), sessions, goals)
}
#[cfg(feature = "sm-memory")]
fn test_goal_store(
data_root: &std::path::Path,
) -> std::sync::Arc<tokio::sync::Mutex<crate::core::sm::SmGoalStore>> {
use crate::core::sm::{GoalMemory, SmGoalStore};
struct NoopMem;
#[async_trait]
impl GoalMemory for NoopMem {
async fn remember_goal(&self, _json: String, _tag: &str) -> Result<(), String> {
Ok(())
}
async fn list_goals(&self, _tag: &str) -> Result<Vec<String>, String> {
Ok(Vec::new())
}
}
let store = SmGoalStore::new(Arc::new(NoopMem), data_root.to_path_buf());
std::sync::Arc::new(tokio::sync::Mutex::new(store))
}
fn req(id: i64, method: &str, params: Value) -> Request {
Request {
jsonrpc: Some("2.0".into()),
id: Some(json!(id)),
method: method.into(),
params: Some(params),
}
}
fn ok_result(resp: &Response, id: i64) -> Value {
assert_eq!(resp.jsonrpc, "2.0", "framing: jsonrpc 2.0");
assert_eq!(resp.id, Some(json!(id)), "framing: id echoed");
assert!(
resp.error.is_none(),
"expected result, got error: {:?}",
resp.error
);
resp.result.clone().expect("result present")
}
fn err_code(resp: &Response, id: i64, code: i32) -> String {
assert_eq!(resp.id, Some(json!(id)), "framing: id echoed on error");
let e = resp.error.as_ref().expect("error present");
assert_eq!(e.code, code, "error code");
e.message.clone()
}
#[tokio::test]
async fn chat_round_trips() {
let tmp = TempDir::new().unwrap();
let cfg = enabled_config();
let agent = agent_with_provider(cfg.clone(), tmp.path());
let d = dispatcher_with(
agent,
cfg,
tmp.path(),
Arc::new(MockSessionControl::default()),
);
let resp = d
.dispatch(req(
1,
"sm.chat",
json!({ "message": "decompose login", "conv_id": "c-1" }),
))
.await;
let result = ok_result(&resp, 1);
assert_eq!(result["reply"], "SM plan: delegate to a session");
assert_eq!(result["conv_id"], "c-1");
let cost = result["cost"].as_f64().expect("cost is a number");
assert!((cost - 0.0021).abs() < 1e-9, "per-call cost returned");
}
#[tokio::test]
async fn chat_degraded_is_unavailable() {
let tmp = TempDir::new().unwrap();
let cfg = enabled_config();
let agent = agent_degraded(cfg.clone(), tmp.path());
let d = dispatcher_with(
agent,
cfg,
tmp.path(),
Arc::new(MockSessionControl::default()),
);
let resp = d
.dispatch(req(2, "sm.chat", json!({ "message": "hi" })))
.await;
err_code(&resp, 2, CODE_UNAVAILABLE);
}
#[tokio::test]
async fn chat_missing_message_is_invalid_params() {
let tmp = TempDir::new().unwrap();
let cfg = enabled_config();
let agent = agent_with_provider(cfg.clone(), tmp.path());
let d = dispatcher_with(
agent,
cfg,
tmp.path(),
Arc::new(MockSessionControl::default()),
);
let resp = d.dispatch(req(3, "sm.chat", json!({}))).await;
err_code(&resp, 3, error_codes::INVALID_PARAMS);
}
#[tokio::test]
async fn blank_required_param_is_distinct_invalid_params() {
let tmp = TempDir::new().unwrap();
let cfg = enabled_config();
let agent = agent_with_provider(cfg.clone(), tmp.path());
let d = dispatcher_with(
agent,
cfg,
tmp.path(),
Arc::new(MockSessionControl::default()),
);
let resp = d
.dispatch(req(7, "sm.chat", json!({ "message": " " })))
.await;
let msg = err_code(&resp, 7, error_codes::INVALID_PARAMS);
assert!(
msg.contains("must not be blank"),
"blank value gets a distinct message, got: {msg}"
);
}
#[tokio::test]
async fn health_round_trips() {
let tmp = TempDir::new().unwrap();
let cfg = enabled_config();
let agent = agent_with_provider(cfg.clone(), tmp.path());
let d = dispatcher_with(
agent,
cfg,
tmp.path(),
Arc::new(MockSessionControl::default()),
);
let resp = d.dispatch(req(4, "sm.health", json!({}))).await;
let result = ok_result(&resp, 4);
assert_eq!(result["ok"], true);
assert_eq!(result["degraded"], false);
assert_eq!(result["provider"], "anthropic");
assert_eq!(
result["model_tiers"]["orchestration"],
"anthropic/claude-sonnet-4-6"
);
}
#[tokio::test]
async fn health_degraded_reports_degraded() {
let tmp = TempDir::new().unwrap();
let cfg = enabled_config();
let agent = agent_degraded(cfg.clone(), tmp.path());
let d = dispatcher_with(
agent,
cfg,
tmp.path(),
Arc::new(MockSessionControl::default()),
);
let resp = d.dispatch(req(5, "sm.health", json!({}))).await;
let result = ok_result(&resp, 5);
assert_eq!(result["ok"], false);
assert_eq!(result["degraded"], true);
assert_eq!(result["provider"], "none");
}
#[tokio::test]
async fn launch_round_trips() {
let tmp = TempDir::new().unwrap();
let cfg = enabled_config();
let agent = agent_with_provider(cfg.clone(), tmp.path());
let control = Arc::new(MockSessionControl::default());
let d = dispatcher_with(agent, cfg, tmp.path(), control.clone());
let resp = d
.dispatch(req(
6,
"sm.sessions.launch",
json!({ "workdir": "/repo", "prompt": "fix bug", "model": "tcode" }),
))
.await;
let result = ok_result(&resp, 6);
assert_eq!(result["session_id"], "11111111-1111-1111-1111-111111111111");
let seen = control
.last_launch
.lock()
.unwrap()
.clone()
.expect("launch seen");
assert_eq!(seen.workdir, "/repo");
assert_eq!(seen.prompt.as_deref(), Some("fix bug"));
assert_eq!(seen.model.as_deref(), Some("tcode"));
}
#[tokio::test]
async fn session_verbs_round_trip() {
let tmp = TempDir::new().unwrap();
let cfg = enabled_config();
let agent = agent_with_provider(cfg.clone(), tmp.path());
let d = dispatcher_with(
agent,
cfg,
tmp.path(),
Arc::new(MockSessionControl::default()),
);
let sid = "22222222-2222-2222-2222-222222222222";
let list = d.dispatch(req(10, "sm.sessions.list", json!({}))).await;
assert!(ok_result(&list, 10)["sessions"].is_array());
let get = d
.dispatch(req(11, "sm.sessions.get", json!({ "session_id": sid })))
.await;
assert!(ok_result(&get, 11)["session"].is_object());
let send = d
.dispatch(req(
12,
"sm.sessions.send",
json!({ "session_id": sid, "text": "y" }),
))
.await;
assert_eq!(ok_result(&send, 12)["ok"], true);
let stop = d
.dispatch(req(13, "sm.sessions.stop", json!({ "session_id": sid })))
.await;
assert_eq!(ok_result(&stop, 13)["ok"], true);
let resume = d
.dispatch(req(14, "sm.sessions.resume", json!({ "session_id": sid })))
.await;
assert_eq!(ok_result(&resume, 14)["ok"], true);
let kill = d
.dispatch(req(15, "sm.sessions.kill", json!({ "session_id": sid })))
.await;
assert_eq!(ok_result(&kill, 15)["ok"], true);
}
#[tokio::test]
async fn get_unknown_session_is_not_found() {
let tmp = TempDir::new().unwrap();
let cfg = enabled_config();
let agent = agent_with_provider(cfg.clone(), tmp.path());
let control = Arc::new(MockSessionControl {
fail_get_not_found: true,
..MockSessionControl::default()
});
let d = dispatcher_with(agent, cfg, tmp.path(), control);
let resp = d
.dispatch(req(
16,
"sm.sessions.get",
json!({ "session_id": "33333333-3333-3333-3333-333333333333" }),
))
.await;
err_code(&resp, 16, CODE_NOT_FOUND);
}
#[tokio::test]
async fn stop_kill_backend_failure_is_internal_not_found() {
let tmp = TempDir::new().unwrap();
let cfg = enabled_config();
let agent = agent_with_provider(cfg.clone(), tmp.path());
let control = Arc::new(MockSessionControl {
fail_stop_kill_backend: true,
..MockSessionControl::default()
});
let d = dispatcher_with(agent, cfg, tmp.path(), control);
let sid = "44444444-4444-4444-4444-444444444444";
let stop = d
.dispatch(req(17, "sm.sessions.stop", json!({ "session_id": sid })))
.await;
err_code(&stop, 17, error_codes::INTERNAL_ERROR);
let kill = d
.dispatch(req(18, "sm.sessions.kill", json!({ "session_id": sid })))
.await;
err_code(&kill, 18, error_codes::INTERNAL_ERROR);
}
#[test]
fn malformed_json_is_parse_error() {
let line = "{ this is not json ";
let resp = match serde_json::from_str::<Request>(line) {
Ok(_) => panic!("should not parse"),
Err(e) => Response::err(
None,
error_codes::PARSE_ERROR,
format!("invalid JSON-RPC: {e}"),
),
};
assert_eq!(resp.error.as_ref().unwrap().code, error_codes::PARSE_ERROR);
assert!(resp.id.is_none(), "parse error has null id");
}
#[tokio::test]
async fn unknown_method_is_method_not_found() {
let tmp = TempDir::new().unwrap();
let cfg = enabled_config();
let agent = agent_with_provider(cfg.clone(), tmp.path());
let d = dispatcher_with(
agent,
cfg,
tmp.path(),
Arc::new(MockSessionControl::default()),
);
let resp = d.dispatch(req(20, "sm.bogus", json!({}))).await;
err_code(&resp, 20, error_codes::METHOD_NOT_FOUND);
}
#[tokio::test]
async fn notification_is_suppressed() {
let tmp = TempDir::new().unwrap();
let cfg = enabled_config();
let agent = agent_with_provider(cfg.clone(), tmp.path());
let d = dispatcher_with(
agent,
cfg,
tmp.path(),
Arc::new(MockSessionControl::default()),
);
let notif = Request {
jsonrpc: Some("2.0".into()),
id: None,
method: "sm.health".into(),
params: None,
};
let resp = d.dispatch(notif).await;
assert!(resp.suppress, "notification must be suppressed");
}
#[tokio::test]
async fn line_framing_round_trips_one_response() {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let tmp = TempDir::new().unwrap();
let cfg = enabled_config();
let agent = agent_with_provider(cfg.clone(), tmp.path());
let d = Arc::new(dispatcher_with(
agent,
cfg,
tmp.path(),
Arc::new(MockSessionControl::default()),
));
let request_line = serde_json::to_string(&req(99, "sm.health", json!({}))).unwrap();
let (mut client_tx, server_rx) = tokio::io::duplex(8192);
let (server_tx, client_rx) = tokio::io::duplex(8192);
let dispatcher = d.clone();
let loop_fut = run_stdio_loop_over(
move |r| {
let dispatcher = dispatcher.clone();
async move { dispatcher.dispatch(r).await }
},
server_rx,
server_tx,
);
client_tx
.write_all(format!("{request_line}\n").as_bytes())
.await
.unwrap();
drop(client_tx);
loop_fut.await.expect("loop returns Ok on EOF");
let mut lines = BufReader::new(client_rx).lines();
let first = lines.next_line().await.unwrap().expect("one response line");
let parsed: Value = serde_json::from_str(&first).expect("response is valid JSON");
assert_eq!(parsed["jsonrpc"], "2.0");
assert_eq!(parsed["id"], 99);
assert_eq!(parsed["result"]["ok"], true);
assert!(
lines.next_line().await.unwrap().is_none(),
"exactly one response line"
);
}
async fn run_stdio_loop_over<F, Fut, R, W>(
dispatcher: F,
reader: R,
mut writer: W,
) -> anyhow::Result<()>
where
F: Fn(Request) -> Fut,
Fut: std::future::Future<Output = Response>,
R: tokio::io::AsyncRead + Unpin,
W: tokio::io::AsyncWrite + Unpin,
{
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let mut lines = BufReader::new(reader).lines();
while let Some(line) = lines.next_line().await? {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let response = match serde_json::from_str::<Request>(trimmed) {
Ok(r) => dispatcher(r).await,
Err(e) => Response::err(None, error_codes::PARSE_ERROR, format!("{e}")),
};
if response.suppress {
continue;
}
let serialised = serde_json::to_string(&response)?;
writer.write_all(serialised.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
}
Ok(())
}
#[tokio::test]
async fn scripted_chat_launch_get_stop_sequence() {
let tmp = TempDir::new().unwrap();
let cfg = enabled_config();
let agent = agent_with_provider(cfg.clone(), tmp.path());
let d = dispatcher_with(
agent,
cfg,
tmp.path(),
Arc::new(MockSessionControl::default()),
);
let chat = d
.dispatch(req(40, "sm.chat", json!({ "message": "ship feature X" })))
.await;
let chat_result = ok_result(&chat, 40);
let conv_id = chat_result["conv_id"]
.as_str()
.expect("conv_id")
.to_string();
assert!(!conv_id.is_empty());
let launch = d
.dispatch(req(
41,
"sm.sessions.launch",
json!({ "workdir": "/repo", "prompt": "X" }),
))
.await;
let session_id = ok_result(&launch, 41)["session_id"]
.as_str()
.unwrap()
.to_string();
let get = d
.dispatch(req(
42,
"sm.sessions.get",
json!({ "session_id": session_id }),
))
.await;
assert!(ok_result(&get, 42)["session"].is_object());
let stop = d
.dispatch(req(
43,
"sm.sessions.stop",
json!({ "session_id": session_id }),
))
.await;
assert_eq!(ok_result(&stop, 43)["ok"], true);
let create = d
.dispatch(req(
44,
"sm.goals.create",
json!({ "description": "ship X" }),
))
.await;
#[cfg(feature = "sm-memory")]
{
let goal = ok_result(&create, 44);
let goal_id = goal["goal"]["id"].as_str().unwrap().to_string();
let upd = d
.dispatch(req(
45,
"sm.goals.update",
json!({ "id": goal_id, "note": "kicked off" }),
))
.await;
assert!(
!ok_result(&upd, 45)["goal"]["notes"]
.as_array()
.unwrap()
.is_empty()
);
}
#[cfg(not(feature = "sm-memory"))]
{
err_code(&create, 44, CODE_UNAVAILABLE);
}
}
#[tokio::test]
async fn goals_feature_branches() {
let tmp = TempDir::new().unwrap();
let cfg = enabled_config();
let agent = agent_with_provider(cfg.clone(), tmp.path());
let d = dispatcher_with(
agent,
cfg,
tmp.path(),
Arc::new(MockSessionControl::default()),
);
#[cfg(feature = "sm-memory")]
{
let create = d
.dispatch(req(
50,
"sm.goals.create",
json!({ "description": "g1", "acceptance": ["pr merged"] }),
))
.await;
assert_eq!(ok_result(&create, 50)["goal"]["description"], "g1");
let list = d.dispatch(req(51, "sm.goals.list", json!({}))).await;
let goals = ok_result(&list, 51)["goals"].as_array().unwrap().clone();
assert_eq!(goals.len(), 1, "the created goal is listed");
}
#[cfg(not(feature = "sm-memory"))]
{
let list = d.dispatch(req(52, "sm.goals.list", json!({}))).await;
err_code(&list, 52, CODE_UNAVAILABLE);
}
}
#[tokio::test]
async fn context_get_feature_branches() {
let tmp = TempDir::new().unwrap();
let cfg = enabled_config();
let agent = agent_with_provider(cfg.clone(), tmp.path());
let d = dispatcher_with(
agent,
cfg,
tmp.path(),
Arc::new(MockSessionControl::default()),
);
#[cfg(feature = "sm-memory")]
{
let _ = d
.dispatch(req(
60,
"sm.chat",
json!({ "message": "hi", "conv_id": "ctx-1" }),
))
.await;
let ctx = d
.dispatch(req(61, "sm.context.get", json!({ "conv_id": "ctx-1" })))
.await;
let result = ok_result(&ctx, 61);
assert!(result["recent_rounds"].is_array());
assert!(result["total_rounds"].as_u64().unwrap() >= 1);
assert!(result["token_estimate"].is_number());
assert!(result.get("compressed_context").is_some());
}
#[cfg(not(feature = "sm-memory"))]
{
let ctx = d
.dispatch(req(62, "sm.context.get", json!({ "conv_id": "ctx-1" })))
.await;
err_code(&ctx, 62, CODE_UNAVAILABLE);
}
}
#[cfg(feature = "sm-memory")]
fn agent_with_decision(
cfg: SessionManagerConfig,
data_root: &std::path::Path,
decision_json: &str,
) -> Arc<SessionManagerAgent> {
let provider = MockChatProvider::new(decision_json, 0.0);
let resolver = Arc::new(MockResolver::with_provider(provider));
Arc::new(SessionManagerAgent::for_test(
cfg,
resolver,
data_root.to_path_buf(),
))
}
#[cfg(feature = "sm-memory")]
#[derive(Default)]
struct EvidenceControl {
evidence: String,
sends: std::sync::Mutex<Vec<(String, String)>>,
next: std::sync::atomic::AtomicUsize,
}
#[cfg(feature = "sm-memory")]
#[async_trait]
impl SessionControl for EvidenceControl {
async fn launch(&self, _params: LaunchParams) -> Result<Value, SessionControlError> {
let n = self.next.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
Ok(json!({ "session_id": format!("s-{n}") }))
}
async fn list(&self) -> Result<Value, SessionControlError> {
Ok(json!({ "sessions": [] }))
}
async fn get(&self, _id: &str) -> Result<Value, SessionControlError> {
Ok(json!({ "session": { "state": "running", "pane": self.evidence } }))
}
async fn send(&self, id: &str, text: &str) -> Result<Value, SessionControlError> {
self.sends
.lock()
.expect("lock")
.push((id.to_string(), text.to_string()));
Ok(json!({ "ok": true }))
}
async fn stop(&self, _id: &str) -> Result<Value, SessionControlError> {
Ok(json!({ "ok": true }))
}
async fn resume(&self, _id: &str) -> Result<Value, SessionControlError> {
Ok(json!({ "ok": true }))
}
async fn kill(&self, _id: &str) -> Result<Value, SessionControlError> {
Ok(json!({ "ok": true }))
}
}
#[cfg(feature = "sm-memory")]
fn dispatcher_with_dyn_control(
agent: Arc<SessionManagerAgent>,
cfg: SessionManagerConfig,
data_root: &std::path::Path,
sessions: Arc<dyn SessionControl>,
) -> SmDispatcher {
let goals = Some(test_goal_store(data_root));
SmDispatcher::new(agent, cfg, data_root.to_path_buf(), sessions, goals)
}
#[cfg(feature = "sm-memory")]
#[tokio::test]
async fn delegate_end_to_end_launch_observe_verify_close() {
let tmp = TempDir::new().unwrap();
let cfg = enabled_config();
let decision = r#"{"action":"delegate","tasks":[{"workdir":"/repo","prompt":"open a PR"}]}"#;
let agent = agent_with_decision(cfg.clone(), tmp.path(), decision);
let control = Arc::new(EvidenceControl {
evidence: "Opened PR https://github.com/acme/repo/pull/7".to_string(),
..EvidenceControl::default()
});
let sessions: Arc<dyn SessionControl> = control.clone();
let d = dispatcher_with_dyn_control(agent, cfg, tmp.path(), sessions);
let resp = d
.dispatch(req(
70,
"sm.delegate",
json!({ "message": "open the PR for me" }),
))
.await;
let result = ok_result(&resp, 70);
let launched = result["launched"].as_array().expect("launched array");
assert_eq!(launched.len(), 1, "one session launched");
assert_eq!(
result["goal_done"], true,
"gate passed with evidence ⇒ Done"
);
assert_eq!(
result["goal_status"], "Done",
"goal_status reflects the closed goal"
);
let sends: Vec<(String, String)> = { control.sends.lock().expect("lock").clone() };
assert_eq!(sends.len(), 1, "task delivered to the session");
assert_eq!(sends[0].1, "open a PR");
let goal_id = result["goal_id"].as_str().expect("goal_id").to_string();
let list = d.dispatch(req(71, "sm.goals.list", json!({}))).await;
let goals = ok_result(&list, 71);
let found = goals["goals"]
.as_array()
.unwrap()
.iter()
.find(|g| g["id"] == goal_id)
.expect("goal present");
assert_eq!(found["status"], "done");
}
#[cfg(feature = "sm-memory")]
#[tokio::test]
async fn delegate_gate_blocks_without_evidence_over_wire() {
let tmp = TempDir::new().unwrap();
let cfg = enabled_config();
let decision = r#"{"action":"delegate","tasks":[{"workdir":"/r","prompt":"do work"}]}"#;
let agent = agent_with_decision(cfg.clone(), tmp.path(), decision);
let d = dispatcher_with(
agent,
cfg,
tmp.path(),
Arc::new(MockSessionControl::default()),
);
let resp = d
.dispatch(req(
72,
"sm.delegate",
json!({ "message": "ship the feature" }),
))
.await;
let result = ok_result(&resp, 72);
assert_eq!(result["launched"].as_array().unwrap().len(), 1);
assert_eq!(
result["goal_done"], false,
"no evidence ⇒ gate blocks Done over the wire"
);
assert_eq!(
result["goal_status"], "InProgress",
"an in-flight goal reports InProgress, distinguishing it from blocked/failed"
);
}
#[cfg(feature = "sm-memory")]
#[tokio::test]
async fn delegate_refuses_direct_work_over_wire() {
let tmp = TempDir::new().unwrap();
let cfg = enabled_config();
let decision = r#"{"action":"do_work","summary":"I'll just edit the file"}"#;
let agent = agent_with_decision(cfg.clone(), tmp.path(), decision);
let d = dispatcher_with(
agent,
cfg,
tmp.path(),
Arc::new(MockSessionControl::default()),
);
let resp = d
.dispatch(req(73, "sm.delegate", json!({ "message": "add a flag" })))
.await;
let result = ok_result(&resp, 73);
assert!(result["launched"].as_array().unwrap().is_empty());
let reply = result["reply"].as_str().unwrap().to_ascii_lowercase();
assert!(reply.contains("launch a session"), "redirects to launch");
}
#[cfg(feature = "sm-memory")]
#[tokio::test]
async fn delegate_degraded_is_unavailable() {
let tmp = TempDir::new().unwrap();
let cfg = enabled_config();
let agent = agent_degraded(cfg.clone(), tmp.path());
let d = dispatcher_with(
agent,
cfg,
tmp.path(),
Arc::new(MockSessionControl::default()),
);
let resp = d
.dispatch(req(74, "sm.delegate", json!({ "message": "anything" })))
.await;
err_code(&resp, 74, CODE_UNAVAILABLE);
}
#[cfg(not(feature = "sm-memory"))]
#[tokio::test]
async fn delegate_unavailable_without_feature() {
let tmp = TempDir::new().unwrap();
let cfg = enabled_config();
let agent = agent_with_provider(cfg.clone(), tmp.path());
let d = dispatcher_with(
agent,
cfg,
tmp.path(),
Arc::new(MockSessionControl::default()),
);
let resp = d
.dispatch(req(75, "sm.delegate", json!({ "message": "anything" })))
.await;
err_code(&resp, 75, CODE_UNAVAILABLE);
}
#[test]
fn no_stdout_writes_in_sm_paths() {
use std::fs;
use std::path::Path;
let crate_root = Path::new(env!("CARGO_MANIFEST_DIR"));
let roots = [
crate_root.join("src/daemon/sm_stdio"),
crate_root.join("src/core/sm"),
];
fn collect_rs(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_rs(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
let mut files = Vec::new();
for root in &roots {
collect_rs(root, &mut files);
}
assert!(
!files.is_empty(),
"expected to find SM source files to scan"
);
let mut offenders = Vec::new();
for path in &files {
if path.ends_with("daemon/sm_stdio/tests.rs") {
continue;
}
let Ok(src) = fs::read_to_string(path) else {
continue;
};
for (lineno, line) in src.lines().enumerate() {
let code = line.trim_start();
if code.starts_with("//") || code.starts_with("*") {
continue;
}
if contains_macro(line, b"println!") || contains_macro(line, b"print!") {
offenders.push(format!("{}:{}", path.display(), lineno + 1));
}
}
}
assert!(
offenders.is_empty(),
"stdout-write macro found in SM paths (stdout must stay JSON-RPC-only): {offenders:?}"
);
}
fn contains_macro(line: &str, needle: &[u8]) -> bool {
let bytes = line.as_bytes();
let mut i = 0;
while let Some(pos) = find_subslice(&bytes[i..], needle) {
let abs = i + pos;
let prev_ok =
abs == 0 || !(bytes[abs - 1].is_ascii_alphanumeric() || bytes[abs - 1] == b'_');
if prev_ok {
return true;
}
i = abs + 1;
}
false
}
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}