#![cfg(all(unix, feature = "workflow"))]
mod common;
use std::process::{Command, Stdio};
fn run(cfg_text: &str) -> (Option<i32>, String) {
let dir = common::unique_path("msg", "d");
std::fs::create_dir_all(&dir).unwrap();
let cfg = format!("{dir}/c.yaml");
std::fs::write(&cfg, cfg_text.replace("__STATE__", &format!("{dir}/state"))).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_agentd"))
.args(["--config", &cfg])
.stdin(Stdio::null())
.stdout(Stdio::null())
.output()
.expect("run");
let log = String::from_utf8_lossy(&out.stderr).to_string();
let _ = std::fs::remove_dir_all(&dir);
(out.status.code(), log)
}
fn run_daemon(cfg_text: &str, settle: std::time::Duration) -> String {
let dir = common::unique_path("msgd", "d");
std::fs::create_dir_all(&dir).unwrap();
let cfg = format!("{dir}/c.yaml");
let errf = format!("{dir}/err.log");
std::fs::write(&cfg, cfg_text.replace("__STATE__", &format!("{dir}/state"))).unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_agentd"))
.args(["--config", &cfg])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::from(std::fs::File::create(&errf).unwrap()))
.spawn()
.expect("spawn");
std::thread::sleep(settle);
let _ = child.kill();
let _ = child.wait();
let log = std::fs::read_to_string(&errf).unwrap_or_default();
let _ = std::fs::remove_dir_all(&dir);
log
}
const BASE: &str = "config_version: \"1\"\n\
store: { kind: file, file: { path: __STATE__ } }\n\
observability: { log_level: info, log_content: true }\n";
#[test]
fn a_message_step_delivers_into_a_conversation_and_starts_a_turn() {
let (code, log) = run(&format!(
"{BASE}agent: {{ name: m }}\n\
intelligence: {{ endpoints: \"mock:final\", model: mock }}\n\
lifecycle: {{ run_until: idle, idle_grace: 2s }}\n\
workflows:\n\
\x20 - name: greet\n steps:\n\
\x20 s: {{ kind: once }}\n\
\x20 m: {{ kind: message, to: root, text: \"look at this\", depends_on: [s] }}\n\
\x20 f: {{ kind: finish, depends_on: [m], status: completed }}\n"
));
assert_eq!(code, Some(0), "{log}");
assert!(
log.contains("\"kind\":\"a2a_message\""),
"the step should have produced a message event\n{log}"
);
assert!(
log.contains("\"event\":\"turn.done\""),
"the delivered message should have caused a turn\n{log}"
);
}
#[test]
fn to_new_opens_its_own_conversation() {
let (code, log) = run(&format!(
"{BASE}agent: {{ name: m }}\n\
intelligence: {{ endpoints: \"mock:final\", model: mock }}\n\
lifecycle: {{ run_until: idle, idle_grace: 2s }}\n\
workflows:\n\
\x20 - name: aside\n steps:\n\
\x20 s: {{ kind: once }}\n\
\x20 m: {{ kind: message, to: new, text: \"a private aside\", depends_on: [s] }}\n\
\x20 f: {{ kind: finish, depends_on: [m], status: completed }}\n"
));
assert_eq!(code, Some(0), "{log}");
assert!(
log.contains("\"event\":\"turn.done\"") && !log.contains("\"ctx\":\"root\""),
"the turn should have run in a generated context, not root\n{log}"
);
}
#[test]
fn a_message_loop_is_refused_once_it_runs_out_of_depth() {
let (code, log) = run(&format!(
"{BASE}intelligence: {{ endpoints: \"mock:wf-once\", model: mock }}\n\
agent: {{ name: m }}\n\
limits: {{ max_message_depth: 1, run: {{ steps: 4 }} }}\n\
lifecycle: {{ run_until: idle, idle_grace: 3s }}\n\
workflows:\n\
\x20 - name: loop\n steps:\n\
\x20 s: {{ kind: once }}\n\
\x20 m: {{ kind: message, to: root, text: \"again\", depends_on: [s] }}\n\
\x20 f: {{ kind: finish, depends_on: [m], status: completed }}\n"
));
assert!(
log.contains("\"event\":\"message.too_deep\""),
"the hop cap should have refused the chain\n{log}"
);
assert!(
log.contains("max_message_depth"),
"the refusal should name the limit that stopped it\n{log}"
);
assert_eq!(code, Some(1), "{log}");
assert!(
!log.contains("panicked"),
"the refusal must be an ordinary step failure\n{log}"
);
}
#[test]
fn each_hop_inherits_the_last_ones_depth() {
let (_code, log) = run(&format!(
"{BASE}intelligence: {{ endpoints: \"mock:wf-once\", model: mock }}\n\
agent: {{ name: m }}\n\
limits: {{ max_message_depth: 1, run: {{ steps: 4 }} }}\n\
lifecycle: {{ run_until: idle, idle_grace: 3s }}\n\
workflows:\n\
\x20 - name: loop\n steps:\n\
\x20 s: {{ kind: once }}\n\
\x20 m: {{ kind: message, to: root, text: \"again\", depends_on: [s] }}\n\
\x20 f: {{ kind: finish, depends_on: [m], status: completed }}\n"
));
assert!(
log.contains("\"depth\":2") && log.contains("\"max\":1"),
"the second hop should have inherited the first hop's depth\n{log}"
);
}
#[cfg(feature = "a2a")]
#[test]
fn the_depth_survives_a_delivery_that_fires_a_workflow() {
let (_code, log) = run(&format!(
"{BASE}agent: {{ name: m }}\n\
intelligence: {{ endpoints: \"mock:final\", model: mock }}\n\
limits: {{ max_message_depth: 2 }}\n\
lifecycle: {{ run_until: idle, idle_grace: 4s }}\n\
a2a: {{ }}\n\
workflows:\n\
\x20 - name: relay\n steps:\n\
\x20 s: {{ kind: a2a }}\n\
\x20 m: {{ kind: message, to: relayed, text: \"round\", depends_on: [s] }}\n\
\x20 f: {{ kind: finish, depends_on: [m], status: completed }}\n\
\x20 - name: kick\n steps:\n\
\x20 s: {{ kind: once }}\n\
\x20 m: {{ kind: message, to: relayed, text: \"start\", depends_on: [s] }}\n\
\x20 f: {{ kind: finish, depends_on: [m], status: completed }}\n"
));
assert!(
log.contains("\"event\":\"start.a2a.fired\""),
"the delivery should have fired the a2a start\n{log}"
);
assert!(
log.contains("\"event\":\"message.too_deep\"") && log.contains("\"depth\":3"),
"the hop depth must survive the a2a reader\n{log}"
);
}
#[test]
fn an_empty_message_is_refused() {
let (_code, log) = run(&format!(
"{BASE}agent: {{ name: m }}\n\
intelligence: {{ endpoints: \"mock:final\", model: mock }}\n\
lifecycle: {{ run_until: idle, idle_grace: 1s }}\n\
workflows:\n\
\x20 - name: empty\n steps:\n\
\x20 s: {{ kind: once }}\n\
\x20 m: {{ kind: message, to: root, depends_on: [s] }}\n\
\x20 f: {{ kind: finish, depends_on: [m], status: completed }}\n"
));
assert!(
log.contains("one of text or parts is required"),
"an empty message should be refused by the step\n{log}"
);
}
#[test]
fn on_workflow_finished_think_starts_a_turn_where_note_only_appends() {
let wf = "workflows:\n\
\x20 - name: job\n steps:\n\
\x20 s: { kind: once }\n\
\x20 f: { kind: finish, depends_on: [s], status: completed, output: done }\n";
let settle = std::time::Duration::from_secs(3);
let note_log = run_daemon(
&format!(
"{BASE}intelligence: {{ endpoints: \"mock:final\", model: mock }}\n\
agent: {{ name: m, on_workflow_finished: note, wake_on: [workflow_finished] }}\n\
lifecycle: {{ run_until: drained }}\n{wf}"
),
settle,
);
let think_log = run_daemon(
&format!(
"{BASE}intelligence: {{ endpoints: \"mock:final\", model: mock }}\n\
agent: {{ name: m, on_workflow_finished: think, wake_on: [workflow_finished] }}\n\
lifecycle: {{ run_until: drained }}\n{wf}"
),
settle,
);
assert!(
!note_log.contains("\"event\":\"turn.done\""),
"note should append without waking the agent\n{note_log}"
);
assert!(
think_log.contains("\"event\":\"turn.done\""),
"think should actually cause a turn\n{think_log}"
);
}