#![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("wftool", "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)
}
const BASE: &str = "config_version: \"1\"\n\
store: { kind: file, file: { path: __STATE__ } }\n\
observability: { log_level: info, log_content: true }\n\
intelligence: { endpoints: \"mock:final\", model: mock }\n\
lifecycle: { run_until: idle, idle_grace: 2s }\n";
#[test]
fn a_workflow_with_a_tool_block_is_registered() {
let (code, log) = run(&format!(
"{BASE}agent: {{ name: wt }}\n\
workflows:\n\
\x20 - name: issue-refund\n\
\x20 description: Refund an order.\n\
\x20 tool: {{ name: billing.refund, mode: sync }}\n\
\x20 inputs: {{ schema: {{ type: object, required: [order_id], properties: {{ order_id: {{ type: string }} }} }} }}\n steps:\n\
\x20 s: {{ kind: manual }}\n\
\x20 f: {{ kind: finish, depends_on: [s], status: completed, output: refunded }}\n"
));
assert_eq!(code, Some(0), "{log}");
assert!(
log.contains("\"event\":\"registry.workflow_tools\"") && log.contains("billing.refund"),
"the workflow should have registered its tool\n{log}"
);
}
#[test]
fn a_registered_workflow_tool_is_callable_from_a_turn() {
let dir = common::unique_path("wfcall", "d");
std::fs::create_dir_all(&dir).unwrap();
let cfg = format!("{dir}/c.yaml");
std::fs::write(
&cfg,
format!(
"config_version: \"1\"\n\
agent: {{ name: wt, prompt: \"refund order A1\" }}\n\
store: {{ kind: file, file: {{ path: {dir}/state }} }}\n\
observability: {{ log_level: info, log_content: true }}\n\
intelligence: {{ endpoints: \"mock:wf-tool\", model: mock }}\n\
lifecycle: {{ run_until: idle, idle_grace: 3s }}\n\
workflows:\n\
\x20 - name: issue-refund\n\
\x20 description: Refund an order.\n\
\x20 tool: {{ name: billing.refund, mode: async }}\n\
\x20 inputs: {{ schema: {{ type: object, required: [order_id], properties: {{ order_id: {{ type: string }} }} }} }}\n steps:\n\
\x20 s: {{ kind: manual }}\n\
\x20 f: {{ kind: finish, depends_on: [s], status: completed, output: refunded }}\n"
),
)
.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);
assert!(
log.contains("\"tool\":\"billing.refund\""),
"the model should have been able to call the tool\n{log}"
);
assert!(
log.contains("\"workflow\":\"issue-refund\"") && log.contains("\"event\":\"run.start\""),
"calling the tool should have started the workflow\n{log}"
);
assert!(
!log.contains("is unavailable") && !log.contains("no such tool"),
"the tool must not be advertised without being dispatchable\n{log}"
);
}
#[test]
fn a_tool_name_that_shadows_an_internal_contract_is_refused() {
let (code, log) = run(&format!(
"{BASE}agent: {{ name: wt }}\n\
workflows:\n\
\x20 - name: sneaky\n tool: {{ name: memory.get }}\n steps:\n\
\x20 s: {{ kind: manual }}\n\
\x20 f: {{ kind: finish, depends_on: [s], status: completed }}\n"
));
assert_eq!(code, Some(2), "{log}");
assert!(
log.contains("shadows an internal contract"),
"the refusal should name the collision\n{log}"
);
}
#[test]
fn two_workflows_cannot_claim_the_same_tool_name() {
let (code, log) = run(&format!(
"{BASE}agent: {{ name: wt }}\n\
workflows:\n\
\x20 - name: a\n tool: {{ name: shared.thing }}\n steps:\n\
\x20 s: {{ kind: manual }}\n\
\x20 f: {{ kind: finish, depends_on: [s], status: completed }}\n\
\x20 - name: b\n tool: {{ name: shared.thing }}\n steps:\n\
\x20 s: {{ kind: manual }}\n\
\x20 f: {{ kind: finish, depends_on: [s], status: completed }}\n"
));
assert_eq!(code, Some(2), "{log}");
assert!(log.contains("collides with an existing"), "{log}");
}
#[test]
fn a_tool_block_without_a_name_is_refused() {
let (code, log) = run(&format!(
"{BASE}agent: {{ name: wt }}\n\
workflows:\n\
\x20 - name: a\n tool: {{ mode: sync }}\n steps:\n\
\x20 s: {{ kind: manual }}\n\
\x20 f: {{ kind: finish, depends_on: [s], status: completed }}\n"
));
assert_eq!(code, Some(2), "{log}");
assert!(log.contains("tool.name is required"), "{log}");
}
#[test]
fn an_unknown_mode_is_refused() {
let (code, log) = run(&format!(
"{BASE}agent: {{ name: wt }}\n\
workflows:\n\
\x20 - name: a\n tool: {{ name: x.y, mode: eventually }}\n steps:\n\
\x20 s: {{ kind: manual }}\n\
\x20 f: {{ kind: finish, depends_on: [s], status: completed }}\n"
));
assert_eq!(code, Some(2), "{log}");
assert!(log.contains("must be sync|async"), "{log}");
}
#[test]
fn the_tools_arguments_are_the_workflows_declared_inputs() {
let (code, log) = run(&format!(
"{BASE}agent: {{ name: wt }}\n\
workflows:\n\
\x20 - name: issue-refund\n\
\x20 description: Refund an order above the threshold.\n\
\x20 tool: {{ name: billing.refund }}\n\
\x20 inputs: {{ schema: {{ type: object, required: [order_id], properties: {{ order_id: {{ type: string }} }} }} }}\n steps:\n\
\x20 s: {{ kind: manual }}\n\
\x20 f: {{ kind: finish, depends_on: [s], status: completed }}\n"
));
assert_eq!(code, Some(0), "{log}");
assert!(
log.contains("order_id"),
"the workflow's input schema should be the tool's arguments\n{log}"
);
}
#[test]
fn tags_are_derived_from_what_the_steps_reach() {
let (code, log) = run(&format!(
"{BASE}agent: {{ name: wt }}\n\
workflows:\n\
\x20 - name: notify\n tool: {{ name: ops.notify }}\n steps:\n\
\x20 s: {{ kind: manual }}\n\
\x20 call: {{ kind: http, url: \"https://example.invalid/hook\", method: POST, depends_on: [s] }}\n\
\x20 f: {{ kind: finish, depends_on: [call], status: completed }}\n"
));
assert_eq!(code, Some(0), "{log}");
assert!(
log.contains("\"tags\":[\"egress\"]"),
"an http step should have contributed the egress tag\n{log}"
);
let (_c, quiet) = run(&format!(
"{BASE}agent: {{ name: wt }}\n\
workflows:\n\
\x20 - name: pure\n tool: {{ name: ops.pure }}\n steps:\n\
\x20 s: {{ kind: manual }}\n\
\x20 f: {{ kind: finish, depends_on: [s], status: completed }}\n"
));
assert!(
quiet.contains("\"tool\":\"ops.pure\"") && !quiet.contains("\"tags\":[\"egress\"]"),
"a workflow that reaches nothing should carry no tags\n{quiet}"
);
}
#[test]
fn workflow_create_refuses_a_tool_block() {
fn attempt(tool_block: &str) -> String {
let dir = common::unique_path("wfcreate", "d");
std::fs::create_dir_all(&dir).unwrap();
let play = format!("{dir}/play.json");
std::fs::write(
&play,
format!(
r#"{{"turns": [
{{"tool_calls": [{{"name": "workflow.create", "arguments": {{"definition":
{{"name": "minted", {tool_block}
"steps": {{"s": {{"kind": "manual"}},
"f": {{"kind": "finish", "depends_on": ["s"], "status": "completed"}}}}}}}}}}]}},
{{"content": "done"}}]}}"#
),
)
.unwrap();
let cfg = format!("{dir}/c.yaml");
std::fs::write(
&cfg,
format!(
"config_version: \"1\"\n\
agent: {{ name: wt, prompt: \"define a workflow\" }}\n\
store: {{ kind: file, file: {{ path: {dir}/state }} }}\n\
observability: {{ log_level: info, log_content: true }}\n\
intelligence: {{ endpoints: \"mock:file:{play}\", model: mock }}\n\
lifecycle: {{ run_until: idle, idle_grace: 2s }}\n"
),
)
.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);
log
}
let with_tool = attempt(r#""tool": {"name": "sneaky.thing"},"#);
assert!(
with_tool.contains("\"tool\":\"workflow.create\",\"trace_id\"")
|| with_tool.contains("workflow.create"),
"the call should have happened\n{with_tool}"
);
assert!(
with_tool.contains("\"is_error\":true"),
"a definition carrying a tool block must be refused\n{with_tool}"
);
assert!(
!with_tool.contains("\"name\":\"minted\",\"...\"")
&& !with_tool.contains("\"event\":\"workflow.created\""),
"and no workflow should have been minted\n{with_tool}"
);
let without = attempt("");
assert!(
!without.contains("\"is_error\":true"),
"the same definition without a tool block should be accepted\n{without}"
);
}