diff --git a/Cargo.lock b/Cargo.lock index 6b54ec2..48a70e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -141,7 +141,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -282,7 +282,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -508,7 +508,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -544,6 +544,7 @@ dependencies = [ "jmespath", "keyring", "libc", + "mmdflux", "num-bigint", "ordered-float", "percent-encoding", @@ -560,6 +561,7 @@ dependencies = [ "serial_test", "sha2", "tempfile", + "terminal_size", "thiserror 2.0.18", "tokio", "tokio-tungstenite", @@ -745,7 +747,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1204,7 +1206,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.118", ] [[package]] @@ -1223,7 +1225,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1366,6 +1368,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mmdflux" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c78ba54627b01d793871184731714c11b41796326207c1fb8fd378e118800721" +dependencies = [ + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", + "unicode-width", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -1569,7 +1586,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1650,6 +1667,59 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8db75153506001cb36ed924f08cadc862eb3f8c4d0d534139f29074769d7660" +dependencies = [ + "memchr", + "pest_stack_guard", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20aa17e11fb35664b67d5214a0afac57a360d231b6ec53d6eba0700b27d933b4" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626ec5afccf44ccbfeb2b232316510bb55b4bf34d891afcb0fb07d2b17d7fe9d" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "pest_meta" +version = "2.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3f45b6caa65c1b08186832745159c2529994146340a7bf44168095469720f5d" +dependencies = [ + "pest", +] + +[[package]] +name = "pest_stack_guard" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97e6cd2217057fa86b6eaa512917e58089d875b957a31e35e7ab729977e0a5f2" +dependencies = [ + "psm", + "stacker", +] + [[package]] name = "petgraph" version = "0.8.3" @@ -1678,7 +1748,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1726,6 +1796,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810" +dependencies = [ + "cc", +] + [[package]] name = "quick-xml" version = "0.41.0" @@ -2170,7 +2249,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2233,7 +2312,7 @@ checksum = "aafbefbe175fa9bf03ca83ef89beecff7d2a95aaacd5732325b90ac8c3bd7b90" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2295,7 +2374,7 @@ checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2405,6 +2484,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2434,6 +2526,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2451,7 +2554,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2467,6 +2570,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2493,7 +2606,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2504,7 +2617,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2596,7 +2709,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2743,7 +2856,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2842,6 +2955,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicase" version = "2.9.0" @@ -2863,6 +2982,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -2990,7 +3115,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -3152,7 +3277,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3163,7 +3288,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3429,7 +3554,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -3450,7 +3575,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3470,7 +3595,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -3491,7 +3616,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3524,7 +3649,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ab9f5b7..1054168 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,11 @@ clap = { version = "4", features = ["derive", "string", "env"] } clap_complete = "4" clap_mangen = "0.2" hmac = "0.12" +# Draws `agents workflow diagram` in the terminal: Mermaid in, box-drawing text +# out, in pure Rust. The default `cli` feature is mmdflux's own binary. +mmdflux = { version = "2.6.1", default-features = false } +# The terminal's width, so diagrams can shorten long IDs to fit it. +terminal_size = "0.4" dotenvy = "0.15" futures-util = "0.3" httpdate = "1" diff --git a/README.md b/README.md index 2bb306e..b9992fe 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,9 @@ elevenlabs agents widget embed # Run the tests attached to an agent (polls to completion; exits non-zero on failure) elevenlabs agents test + +# Draw an agent's workflow in the terminal, or print it as Mermaid (local files only, no API key) +elevenlabs agents workflow diagram [--format ascii|mermaid|mermaid-md] ``` ### Two surfaces in one namespace @@ -217,7 +220,7 @@ elevenlabs agents test | | Workflow commands | API commands | |---|---|---| -| **What** | `init`, `add`, `status`, `push`, `pull`, `test`, `templates`, `widget embed` | `create`, `get`, `list`, `update`, `delete`, `duplicate`, `run_tests`, and subgroups like `branches`, `tools`, `tests`, `conversations` | +| **What** | `init`, `add`, `status`, `push`, `pull`, `test`, `templates`, `widget embed`, `workflow diagram` | `create`, `get`, `list`, `update`, `delete`, `duplicate`, `run_tests`, and subgroups like `branches`, `tools`, `tests`, `conversations` | | **Operates on** | Your local project files, syncing them with ElevenLabs | The API directly — one command, one request | | **Arguments** | Positional, e.g. `agents test ` | Flags, e.g. `agents get --agent-id ` | | **Output** | Progress text | The API response (`--format json\|table\|yaml\|csv`) | @@ -229,6 +232,84 @@ The API commands own the primitive names, so the workflow only adds verbs the AP > **Migrating from v0 (`@elevenlabs/cli`):** v0's `agents list` showed local config and `agents delete ` took a positional and cleaned up locally. In v1 both names belong to the API surface — use `agents status` for the local view, and `--agent-id` for delete. v0's `agents widget ` is now `agents widget embed `, since `agents widget` is an API subgroup. +### Visualizing workflows + +Workflows are stored as `workflow.nodes` / `workflow.edges` in the agent config, which is lossless but hard to read, and harder to review as a diff. `agents workflow diagram` turns one into a flowchart: + +```bash +# Draw it in the terminal: a config file, or an id or name from agents.json +elevenlabs agents workflow diagram Support +elevenlabs agents workflow diagram agent_configs/support.json +elevenlabs agents workflow diagram --all + +# Print it as Mermaid source, to save, or to paste into docs or a PR comment +elevenlabs agents workflow diagram Support --format mermaid > docs/support-flow.mmd +elevenlabs agents workflow diagram Support --format mermaid-md # wrapped in a ```mermaid fence +elevenlabs agents workflow diagram --all --format mermaid-md # every agent and branch, one section each +``` + +It reads local files only: no API key, no network, so it also runs in CI on forks. + +The default format, `ascii`, draws boxes and arrows (in colour when stdout is a terminal and `NO_COLOR` is unset). To keep a workflow narrow enough to read, each edge condition is drawn as a numbered key on its edge and written out in full in a legend underneath: + +``` + ╭───────╮ + │ Start │ + ╰───────╯ + │ + │ + │ + │ + ╭───╮ + │ 1 │ + ╰───╯ + │ + │ + ▼ + ┌───────────────────┐ + ║ Tool ║ + ║ tool_lookup_order ║ + └───────────────────┘ + │ │ + └───┐ ┌──┘ + │ │ + │ │ + ╭───╮ ╭───╮ + │ 2 │ │ 3 │ + ╰───╯ ╰───╯ + │ │ + │ │ + ▼ │ +╭───────────╮ ▼ +│ Say │ ╭───────────────╮ +│ Apologize │ │ End (success) │ +╰───────────╯ ╰───────────────╯ + + 1 The caller has given an order number + 2 failure + 3 success +``` + +A few things keep a busy workflow readable, so lines don't cross or run together: + +- A step back (a backward condition) ends in a stub, such as `6 ↩`. The legend says where it goes: `6 Changes topic (back to 'Ask for the order number')`. +- An ending that several paths lead to (an end, or an agent or phone transfer) is drawn once for each path. +- When a node's branches would still cross, its box is widened or the branches are reordered, whichever draws without crossings. +- When the drawing is wider than the terminal (or `COLUMNS`), long IDs are shortened, as in `tool_01j…sfb67`. If it still doesn't fit, a note says so; `--format mermaid` has the full IDs. + +`--format mermaid` prints [Mermaid](https://mermaid.js.org/), which GitHub renders in Markdown files, issues and PR comments. Each node type has its own shape and colour (start/end, say, tool, update state, subagent, and agent or phone transfers); types the CLI doesn't know yet are drawn as plain dashed boxes. Edge labels summarise the condition: its label if it has one, otherwise the LLM condition text (truncated), `success`/`failure` for tool results, or `expression`. A backward condition is drawn as a dotted arrow in the other direction. Nodes and edges are emitted sorted by ID, so the output changes only when the workflow does and diffs cleanly. + +```mermaid +flowchart TD + n_apologize("Say · Apologize") + n_done(["End (success)"]) + n_lookup[["Tool · tool_lookup_order"]] + n_start_node(["Start"]) + n_lookup -->|"failure"| n_apologize + n_lookup -->|"success"| n_done + n_start_node -->|"The caller has given an order number"| n_lookup +``` + ### Tools Manage the webhook and client tools your agents reference. Tools are tracked in `tools.json` with configs under `tool_configs/`. diff --git a/cli/elevenlabs/workflow/agents.rs b/cli/elevenlabs/workflow/agents.rs index 87f8cdf..8f76bb9 100644 --- a/cli/elevenlabs/workflow/agents.rs +++ b/cli/elevenlabs/workflow/agents.rs @@ -67,7 +67,7 @@ pub fn register(app: CliApp) -> CliApp { /// Load `agents.json`, erroring with v0's "run init first" hint when it /// is missing. -fn require_agents() -> Result { +pub(super) fn require_agents() -> Result { if !Path::new(project::AGENTS_FILE).exists() { return Err(CliError::Validation( "agents.json not found. Run 'elevenlabs agents init' first.".to_string(), @@ -78,7 +78,7 @@ fn require_agents() -> Result { /// Read an agent's display name from its config file. Ports v0's /// `getAgentName` (Unknown when unreadable, Unnamed when nameless). -fn agent_display_name(config_path: &str) -> String { +pub(super) fn agent_display_name(config_path: &str) -> String { match project::read_value_in_project(config_path) { Ok(value) => value .get("name") @@ -89,6 +89,28 @@ fn agent_display_name(config_path: &str) -> String { } } +/// Find an agent in `agents.json` by ID, falling back to the display name in +/// its config file. +/// +/// ID first so existing scripts keep resolving identically. `init`'s +/// next-steps tell users to pass the name they just created, and an agent ID +/// is not something anyone has to hand at that point. +pub(super) fn find_agent<'a>( + config: &'a project::AgentsConfig, + key: &str, +) -> Option<&'a project::AgentDefinition> { + config + .agents + .iter() + .find(|a| a.id.as_deref() == Some(key)) + .or_else(|| { + config + .agents + .iter() + .find(|a| agent_display_name(&a.config) == key) + }) +} + // ── init ──────────────────────────────────────────────────────────── #[derive(clap::Args)] @@ -453,26 +475,12 @@ struct TestArgs { fn handle_test(args: TestArgs, ctx: &AppContext) -> Result<(), CliError> { let _scope = api::command_scope("agents.test"); let config = require_agents()?; - // ID first so existing scripts keep resolving identically, then fall back - // to the display name in the config file. `init`'s next-steps tell users - // to pass the name they just created, and an agent ID is not something - // anyone has to hand at that point. - let agent = config - .agents - .iter() - .find(|a| a.id.as_deref() == Some(args.agent.as_str())) - .or_else(|| { - config - .agents - .iter() - .find(|a| agent_display_name(&a.config) == args.agent) - }) - .ok_or_else(|| { - CliError::Validation(format!( - "No agent with ID or name '{}' in agents.json", - args.agent - )) - })?; + let agent = find_agent(&config, &args.agent).ok_or_else(|| { + CliError::Validation(format!( + "No agent with ID or name '{}' in agents.json", + args.agent + )) + })?; let config_path = Path::new(&agent.config); if !config_path.exists() { diff --git a/cli/elevenlabs/workflow/diagram.rs b/cli/elevenlabs/workflow/diagram.rs new file mode 100644 index 0000000..cd76d1f --- /dev/null +++ b/cli/elevenlabs/workflow/diagram.rs @@ -0,0 +1,1559 @@ +//! `agents workflow diagram`: an agent's workflow as a flowchart, drawn in +//! the terminal or printed as Mermaid source (`--format`). +//! +//! Workflows are stored as raw wire JSON (`workflow.nodes` / `workflow.edges`, +//! both maps keyed by ID), which round-trips losslessly but is unreadable in +//! a diff. GitHub and most docs tooling render Mermaid natively, so this gives +//! reviewers a picture of the conversation flow with no extra tooling. +//! By default it draws the flowchart in the terminal via mmdflux, a pure-Rust +//! Mermaid renderer, so a workflow can be read without leaving the shell. +//! +//! Local files only: no API key and no network, so it runs in CI on forks. +//! Rendering works on [`serde_json::Value`] rather than the generated types so +//! a node or condition type this build doesn't know still renders (generically) +//! instead of failing to deserialize. +//! +//! Output is deterministic — nodes and edges are emitted sorted by ID — so a +//! diagram only changes when the workflow does. + +use std::collections::{BTreeMap, BTreeSet}; +use std::io::IsTerminal; +use std::path::Path; + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::error::CliError; +use fern_cli_sdk::openapi::AppContext; +use serde_json::Value; + +use super::agents::{agent_display_name, find_agent, require_agents}; +use super::{api, project}; + +/// Register the `agents workflow` command group. +pub fn register(app: CliApp) -> CliApp { + app.command_under_typed_with( + &["agents", "workflow"], + clap::Command::new("diagram").about( + "Draw an agent's workflow in the terminal, or print it as Mermaid (local files only)", + ), + handle_diagram, + ) +} + +#[derive(clap::Args)] +struct DiagramArgs { + /// An agent config file, or an agent ID or name from agents.json. + #[arg(required_unless_present = "all")] + target: Option, + /// Draw every agent (and registered branch) in agents.json. + #[arg(long, conflicts_with = "target")] + all: bool, + /// What to print: a drawing for the terminal, Mermaid source, or Mermaid + /// in a ```mermaid fence, ready to paste into Markdown. + #[arg(long, value_enum, default_value_t = Format::Ascii)] + format: Format, +} + +#[derive(Clone, Copy, PartialEq, clap::ValueEnum)] +enum Format { + /// Boxes and arrows drawn with box-drawing characters. + Ascii, + /// Mermaid source, which GitHub and most docs tools render. + Mermaid, + /// Mermaid source in a ```mermaid fence. + MermaidMd, +} + +fn handle_diagram(args: DiagramArgs, _ctx: &AppContext) -> Result<(), CliError> { + let _scope = api::command_scope("agents.workflow.diagram"); + let Some(target) = &args.target else { + return diagram_all(args.format); + }; + let workflow = load_one(target)?; + match args.format { + Format::Ascii => { + let color = std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none(); + print!( + "{}", + render_terminal(&workflow.workflow, color, terminal_width())? + ); + } + Format::Mermaid => print!("{}", render_mermaid(&workflow.workflow)), + Format::MermaidMd => print!("{}", fence(&render_mermaid(&workflow.workflow))), + } + Ok(()) +} + +fn diagram_all(format: Format) -> Result<(), CliError> { + let color = std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none(); + let width = terminal_width(); + for (i, workflow) in load_all()?.iter().enumerate() { + if i > 0 { + println!(); + } + match format { + Format::Ascii => { + println!("── {} ({})\n", workflow.title, workflow.config_path); + print!("{}", render_terminal(&workflow.workflow, color, width)?); + } + Format::Mermaid => { + // Several flowcharts in one stream are not one Mermaid + // document; the comment marks where each starts. mermaid-md + // is the form meant for this. + println!("%% {} ({})", workflow.title, workflow.config_path); + print!("{}", render_mermaid(&workflow.workflow)); + } + Format::MermaidMd => { + println!("### {}\n", workflow.title); + print!("{}", fence(&render_mermaid(&workflow.workflow))); + } + } + } + Ok(()) +} + +// ── Loading ───────────────────────────────────────────────────────── + +/// An agent's workflow, read from its config file. +struct LoadedWorkflow { + /// The agent's display name, plus the branch for a branch config. + title: String, + config_path: String, + workflow: Value, +} + +/// Load one workflow from a config file path, or from an agent ID or name in +/// agents.json. Errors when the config has no workflow. +fn load_one(target: &str) -> Result { + // A path the user typed is read as-is; one taken from agents.json goes + // through the project containment checks like every other index entry. + let (config, config_path) = if Path::new(target).is_file() { + (project::read_value(Path::new(target))?, target.to_string()) + } else { + let registry = require_agents()?; + let agent = find_agent(®istry, target).ok_or_else(|| { + CliError::Validation(format!( + "'{target}' is not a config file, or an agent ID or name in agents.json" + )) + })?; + ( + project::read_value_in_project(&agent.config)?, + agent.config.clone(), + ) + }; + let workflow = workflow_of(&config).cloned().ok_or_else(|| { + CliError::Validation(format!( + "{config_path} has no workflow to draw (no workflow.nodes)" + )) + })?; + let title = config + .get("name") + .and_then(Value::as_str) + .unwrap_or(target) + .to_string(); + Ok(LoadedWorkflow { + title, + config_path, + workflow, + }) +} + +/// Load every agent's (and registered branch's) workflow from agents.json, +/// skipping, with a note on stderr, configs that are unreadable or have none. +fn load_all() -> Result, CliError> { + let registry = require_agents()?; + // agents.json order, then each agent's branches by name: both stable. + let mut entries: Vec<(String, String)> = Vec::new(); + for agent in ®istry.agents { + let name = agent_display_name(&agent.config); + entries.push((name.clone(), agent.config.clone())); + for (branch, def) in agent.branches.iter().flatten() { + entries.push((format!("{name} (branch '{branch}')"), def.config.clone())); + } + } + + let mut loaded = Vec::new(); + for (title, config_path) in entries { + let config = match project::read_value_in_project(&config_path) { + Ok(v) => v, + Err(e) => { + eprintln!("Skipping '{title}': {e}"); + continue; + } + }; + let Some(workflow) = workflow_of(&config).cloned() else { + eprintln!("Skipping '{title}': no workflow ({config_path})"); + continue; + }; + loaded.push(LoadedWorkflow { + title, + config_path, + workflow, + }); + } + if loaded.is_empty() { + eprintln!("No agents in agents.json have a workflow to draw."); + } + Ok(loaded) +} + +/// The config's workflow, if it has any nodes to draw. +fn workflow_of(config: &Value) -> Option<&Value> { + let workflow = config.get("workflow")?; + let has_nodes = workflow + .get("nodes") + .and_then(Value::as_object) + .is_some_and(|nodes| !nodes.is_empty()); + has_nodes.then_some(workflow) +} + +fn fence(diagram: &str) -> String { + format!("```mermaid\n{diagram}```\n") +} + +// ── Rendering ─────────────────────────────────────────────────────── + +/// Longest node label, in characters, before it is cut with an ellipsis. +const NODE_LABEL_MAX: usize = 60; +/// Longest edge label. LLM conditions are full sentences; the diagram only +/// needs enough to tell edges apart. +const EDGE_LABEL_MAX: usize = 40; +/// Line width node labels are wrapped to in the terminal. Side-by-side nodes +/// are what make a diagram wide, so they are kept narrow. +const TERMINAL_WRAP: usize = 24; + +/// Who the Mermaid source is for. +#[derive(Clone, Copy, PartialEq)] +enum Target { + /// mermaid.js (GitHub, docs): one-line labels, entity-escaped, with edge + /// conditions inline. + Export, + /// mmdflux, drawing in a terminal: plain-text labels wrapped onto lines, + /// and edge conditions replaced by numbered keys explained in a legend. + /// Inline conditions are what make a terminal diagram too wide to read. + Terminal, +} + +/// Mermaid source, plus the legend lines for [`Target::Terminal`]. +struct Built { + source: String, + legend: Vec, + /// Nodes with more than one link leaving them, and how many, in flow + /// order: what [`Layout::fan_order`] can reorder. + fans: Vec<(String, usize)>, +} + +/// Choices that change how the terminal drawing is laid out, not what it +/// says. +#[derive(Clone, Default)] +struct Layout { + /// Cut long IDs in node labels, for a narrow terminal. + short_ids: bool, + /// The order to draw a node's outgoing links in, by node ID, as indexes + /// into its links in flow order. mmdflux places a fan-out's branches in + /// the order they are declared. + fan_order: BTreeMap>, + /// Least width of a node's box, by node ID. mmdflux spreads the links + /// leaving a box across its bottom edge; in a narrow box they leave + /// close together and bend on the same row, so they cross. + min_width: BTreeMap, +} + +/// How each node type is drawn: Mermaid shape delimiters and its class. +/// Class names are prefixed because `end` is a Mermaid keyword. +struct Style { + open: &'static str, + close: &'static str, + class: &'static str, +} + +fn style_for(node_type: &str, target: Target) -> Style { + let (open, close, class) = match node_type { + "start" => ("([", "])", "t_start"), + "end" => ("([", "])", "t_end"), + "say" => ("(", ")", "t_say"), + "tool" => ("[[", "]]", "t_tool"), + // mmdflux has no parallelogram and prints its delimiters as text; a + // cylinder (a data store) suits a state update just as well. + "update_state" if target == Target::Terminal => ("[(", ")]", "t_state"), + "update_state" => ("[/", "/]", "t_state"), + "override_agent" => ("[", "]", "t_subagent"), + "standalone_agent" | "phone_number" => ("{{", "}}", "t_transfer"), + _ => ("[", "]", "t_unknown"), + }; + Style { open, close, class } +} + +/// `classDef` lines in emission order. Light fills with dark text read on +/// both GitHub's light and dark themes. +const CLASS_DEFS: &[(&str, &str)] = &[ + ("t_start", "fill:#d1fae5,stroke:#059669,color:#064e3b"), + ("t_end", "fill:#fee2e2,stroke:#dc2626,color:#7f1d1d"), + ("t_say", "fill:#e0f2fe,stroke:#0284c7,color:#0c4a6e"), + ("t_tool", "fill:#fef3c7,stroke:#d97706,color:#78350f"), + ("t_state", "fill:#ede9fe,stroke:#7c3aed,color:#4c1d95"), + ("t_subagent", "fill:#f1f5f9,stroke:#475569,color:#0f172a"), + ("t_transfer", "fill:#fce7f3,stroke:#db2777,color:#831843"), + ( + "t_unknown", + "fill:#f3f4f6,stroke:#6b7280,stroke-dasharray:4 2,color:#111827", + ), + // Terminal only: the numbered condition keys. + ("t_key", "fill:#f3f4f6,stroke:#6b7280,color:#111827"), +]; + +/// Render a workflow (`{"nodes": {...}, "edges": {...}}`) as a Mermaid +/// flowchart. Always ends with a newline. +pub(super) fn render_mermaid(workflow: &Value) -> String { + build(workflow, Target::Export, &Layout::default()).source +} + +/// Draw a workflow as box-drawing text for a terminal, followed by a legend +/// of the edge conditions. `color` adds ANSI colour from the node classes. +/// When the drawing is wider than `width`, it is redrawn with long IDs +/// shortened; if even that does not fit, a note on stderr says so. +fn render_terminal( + workflow: &Value, + color: bool, + width: Option, +) -> Result { + // Measure without colour: ANSI codes are not columns. + let (mut built, mut out) = least_tangled(workflow, false)?; + if let Some(width) = width.filter(|w| text_width(&out) > *w) { + (built, out) = least_tangled(workflow, true)?; + if text_width(&out) > width { + eprintln!( + "This diagram is {} columns wide, more than the {width} available. \ + 'elevenlabs agents workflow diagram --format mermaid' prints it as Mermaid instead.", + text_width(&out) + ); + } + } + if color { + out = draw(&built.source, true)?; + } + if !out.ends_with('\n') { + out.push('\n'); + } + if !built.legend.is_empty() { + out.push('\n'); + for line in &built.legend { + out.push_str(line); + out.push('\n'); + } + } + Ok(out) +} + +/// Largest fan-out whose orders are all tried: 5! = 120 drawings. +const FAN_SEARCH_MAX: usize = 5; +/// Box widths tried for a fan-out that still tangles, in characters per link. +const FAN_WIDTHS: [usize; 3] = [8, 10, 12]; + +/// The terminal drawing with the fewest tangled lines, and its source. +/// +/// Which order of a fan-out's branches mmdflux draws most clearly depends on +/// where each branch leads, so it is found by drawing them: each fan-out in +/// turn (in flow order) tries a few wider boxes, then, if it is still +/// tangled, every order of its branches, keeping the best before moving to +/// the next. Ties keep the flow order and natural width. Stops as soon as a +/// drawing has no tangles. +fn least_tangled(workflow: &Value, short_ids: bool) -> Result<(Built, String), CliError> { + let mut layout = Layout { + short_ids, + ..Layout::default() + }; + let built = build(workflow, Target::Terminal, &layout); + let text = draw(&built.source, false)?; + let mut fewest = tangles(&text); + let fans = built.fans.clone(); + let mut best = (built, text); + for (node, count) in fans { + if fewest == 0 { + break; + } + if count > FAN_SEARCH_MAX { + continue; + } + // A wider box first, so its links leave further apart: three + // drawings, against up to 120 for the orders. + let mut kept = None; + for per_link in FAN_WIDTHS { + let mut candidate = layout.clone(); + candidate.min_width.insert(node.clone(), per_link * count); + let built = build(workflow, Target::Terminal, &candidate); + let text = draw(&built.source, false)?; + let found = tangles(&text); + if found < fewest { + fewest = found; + kept = Some(per_link * count); + best = (built, text); + } + } + if let Some(width) = kept { + layout.min_width.insert(node.clone(), width); + } + if fewest == 0 { + break; + } + let mut kept = None; + for order in permutations(count).into_iter().skip(1) { + let mut candidate = layout.clone(); + candidate.fan_order.insert(node.clone(), order.clone()); + let built = build(workflow, Target::Terminal, &candidate); + let text = draw(&built.source, false)?; + let found = tangles(&text); + if found < fewest { + fewest = found; + kept = Some(order); + best = (built, text); + } + } + if let Some(order) = kept { + layout.fan_order.insert(node, order); + } + } + Ok(best) +} + +/// Every order of `0..n`, starting with `0..n` itself. +fn permutations(n: usize) -> Vec> { + fn extend(prefix: &mut Vec, n: usize, all: &mut Vec>) { + if prefix.len() == n { + all.push(prefix.clone()); + return; + } + for i in 0..n { + if !prefix.contains(&i) { + prefix.push(i); + extend(prefix, n, all); + prefix.pop(); + } + } + } + let mut all = Vec::new(); + extend(&mut Vec::new(), n, &mut all); + all +} + +/// Characters where lines cross (`┼`), or meet and share a stretch (`├`, +/// `┬`, ...). Boxes never use them, so each one is a tangle a reader has to +/// unpick to see what connects to what. +const TANGLES: &[char] = &[ + '┼', '╋', '╂', '┿', '╪', '╫', '╬', '├', '┤', '┬', '┴', '┣', '┫', '┳', '┻', '┠', '┨', '┯', '┷', + '┝', '┥', '┰', '┸', +]; + +fn tangles(text: &str) -> usize { + text.chars().filter(|c| TANGLES.contains(c)).count() +} + +/// Mermaid source drawn as box-drawing text by mmdflux. +fn draw(source: &str, color: bool) -> Result { + let mut config = mmdflux::RenderConfig::default(); + if color { + config.text_color_mode = mmdflux::TextColorMode::Ansi; + } + mmdflux::render_diagram(source, mmdflux::OutputFormat::Text, &config).map_err(|e| { + CliError::Other(anyhow::anyhow!( + "Could not draw this workflow in the terminal ({e}). \ + 'elevenlabs agents workflow diagram --format mermaid' prints it as Mermaid instead." + )) + }) +} + +fn text_width(text: &str) -> usize { + text.lines().map(|l| l.chars().count()).max().unwrap_or(0) +} + +/// The width to fit drawings to: `COLUMNS` when set (so scripts and CI can +/// ask for a width), otherwise the terminal's when stdout is one. `None` +/// means draw at full size. +fn terminal_width() -> Option { + if let Some(columns) = std::env::var("COLUMNS") + .ok() + .and_then(|c| c.parse().ok()) + .filter(|c| *c > 0) + { + return Some(columns); + } + if !std::io::stdout().is_terminal() { + return None; + } + terminal_size::terminal_size_of(std::io::stdout()).map(|(w, _)| w.0 as usize) +} + +fn build(workflow: &Value, target: Target, layout: &Layout) -> Built { + let empty = serde_json::Map::new(); + let nodes = workflow + .get("nodes") + .and_then(Value::as_object) + .unwrap_or(&empty); + let edges = workflow + .get("edges") + .and_then(Value::as_object) + .unwrap_or(&empty); + let nodes: BTreeMap<&str, &Value> = nodes.iter().map(|(k, v)| (k.as_str(), v)).collect(); + let edges: BTreeMap<&str, &Value> = edges.iter().map(|(k, v)| (k.as_str(), v)).collect(); + + // Endpoints an edge names but no node defines. Drawn as unknown nodes so + // the dangling edge is visible rather than silently dropped. + let missing: BTreeSet<&str> = edges + .values() + .flat_map(|e| [str_field(e, "source"), str_field(e, "target")]) + .flatten() + .filter(|id| !nodes.contains_key(id)) + .collect(); + + let ids = MermaidIds::new(nodes.keys().copied().chain(missing.iter().copied())); + + // Export is sorted by edge ID, so it diffs cleanly. The terminal follows + // the flow, so keys are numbered roughly top to bottom. + let ordered: Vec<&Value> = match target { + Target::Export => edges.values().copied().collect(), + Target::Terminal => flow_order(&nodes, &edges), + }; + let mut links = Vec::new(); + for edge in ordered { + let (Some(from), Some(to)) = (str_field(edge, "source"), str_field(edge, "target")) else { + continue; + }; + let forward = condition(edge, "forward_condition"); + let backward = condition(edge, "backward_condition"); + // An edge with neither condition is still a connection; draw it + // forward. One with only a backward condition is drawn backward only. + if forward.is_some() || backward.is_none() { + links.push(Link { + from, + to, + label: forward.and_then(condition_label), + backward: false, + }); + } + if let Some(cond) = backward { + links.push(Link { + from: to, + to: from, + label: condition_label(cond), + backward: true, + }); + } + } + let fans = reorder_fans(&mut links, &layout.fan_order); + let copies = match target { + Target::Export => BTreeMap::new(), + Target::Terminal => sink_copies(&links, &nodes, &ids), + }; + + let mut out = String::from("flowchart TD\n"); + let mut classes: BTreeMap<&'static str, Vec> = BTreeMap::new(); + + for (id, node) in &nodes { + let node_type = str_field(node, "type").unwrap_or("unknown"); + let style = style_for(node_type, target); + let mut label = node_label(id, node_type, node); + if layout.short_ids { + label.text = shorten_ids(&label.text); + } + let mut label = label.render(target); + if let Some(width) = layout.min_width.get(*id) { + label = widen(&label, *width); + } + let drawn = std::iter::once(ids.get(id).to_string()).chain( + copies + .values() + .filter(|(of, _)| of == id) + .map(|(_, c)| c.clone()), + ); + for drawn_id in drawn { + out.push_str(&node_line( + &drawn_id, + style.open, + &label, + style.close, + target, + )); + classes.entry(style.class).or_default().push(drawn_id); + } + } + for id in &missing { + out.push_str(&format!(" %% no node defines '{}'\n", comment_safe(id))); + let label = NodeLabel { + kind: None, + text: format!("missing: {id}"), + }; + out.push_str(&format!(" {}[{}]\n", ids.get(id), label.render(target))); + classes + .entry("t_unknown") + .or_default() + .push(ids.get(id).to_string()); + } + + let mut legend = Vec::new(); + let mut keys = Vec::new(); + for (i, link) in links.iter().enumerate() { + let from = ids.get(link.from); + let to = copies + .get(&i) + .map(|(_, copy)| copy.as_str()) + .unwrap_or_else(|| ids.get(link.to)); + let head = if link.backward { "-.->" } else { "-->" }; + // One link with its condition. For export the (truncated) condition + // is the edge label. In the terminal it becomes a numbered key node + // sitting on the edge, explained in full in the legend: as an edge + // label, mmdflux can place a key beside the wrong edge of a fan-out, + // while a node has to sit on its own edge's path. + match (&link.label, target) { + (None, Target::Export) => out.push_str(&format!(" {from} {head} {to}\n")), + (Some(label), Target::Export) => out.push_str(&format!( + " {from} {head}|{}| {to}\n", + quote(&truncate(label, EDGE_LABEL_MAX)) + )), + // A step back ends in a stub naming where it goes, in the legend. + // Drawn in full it runs back up past everything in between, which + // is where the lines of a busy workflow cross. + (label, Target::Terminal) if link.backward => { + let n = legend.len() + 1; + let back_to = nodes.get(link.to).map_or_else( + || link.to.to_string(), + |node| { + let node_type = str_field(node, "type").unwrap_or("unknown"); + node_label(link.to, node_type, node).text + }, + ); + let back_to = format!("back to '{}'", truncate(&back_to, NODE_LABEL_MAX)); + legend.push(match label { + Some(label) => format!("{n:>3} {} ({back_to})", one_line(label)), + None => format!("{n:>3} {}{}", back_to[..1].to_uppercase(), &back_to[1..]), + }); + // `k_` cannot collide with the `n_` node IDs. + let key = format!("k_{n}"); + out.push_str(&format!(" {from} -.- {key}([\"{n} ↩\"])\n")); + keys.push(key); + } + (None, Target::Terminal) => out.push_str(&format!(" {from} --> {to}\n")), + (Some(label), Target::Terminal) => { + let n = legend.len() + 1; + legend.push(format!("{n:>3} {}", one_line(label))); + let key = format!("k_{n}"); + out.push_str(&format!(" {from} --- {key}([\"{n}\"]) --> {to}\n")); + keys.push(key); + } + } + } + if !keys.is_empty() { + classes.insert("t_key", keys); + } + + for (class, style) in CLASS_DEFS { + if let Some(members) = classes.get(class) { + out.push_str(&format!(" classDef {class} {style}\n")); + let mut members = members.clone(); + members.sort_unstable(); + out.push_str(&format!(" class {} {class}\n", members.join(","))); + } + } + Built { + source: out, + legend, + fans, + } +} + +/// Put each node's outgoing links in the order `fan_order` gives, keeping +/// them in the same slots of `links`. Returns the nodes with more than one +/// outgoing link and their count, in flow order. +fn reorder_fans( + links: &mut [Link], + fan_order: &BTreeMap>, +) -> Vec<(String, usize)> { + let mut slots: Vec<(String, Vec)> = Vec::new(); + for (i, link) in links.iter().enumerate() { + match slots.iter_mut().find(|(from, _)| from == link.from) { + Some((_, indexes)) => indexes.push(i), + None => slots.push((link.from.to_string(), vec![i])), + } + } + for (from, indexes) in &slots { + let Some(order) = fan_order.get(from).filter(|o| o.len() == indexes.len()) else { + continue; + }; + let moved: Vec = order.iter().map(|&j| links[indexes[j]].clone()).collect(); + for (slot, link) in indexes.iter().zip(moved) { + links[*slot] = link; + } + } + slots + .into_iter() + .filter(|(_, indexes)| indexes.len() > 1) + .map(|(from, indexes)| (from, indexes.len())) + .collect() +} + +/// A quoted terminal label (lines joined by `
`) padded with spaces +/// around its first line to at least `width` characters. mmdflux centres +/// every line, so the box just gets wider. +fn widen(label: &str, width: usize) -> String { + let inner = label.trim_matches('"'); + let widest = inner + .split("
") + .map(|l| l.chars().count()) + .max() + .unwrap_or(0); + if widest >= width { + return label.to_string(); + } + let pad = " ".repeat((width - widest).div_ceil(2)); + let (first, rest) = inner + .split_once("
") + .map_or((inner, None), |(f, r)| (f, Some(r))); + match rest { + Some(rest) => format!("\"{pad}{first}{pad}
{rest}\""), + None => format!("\"{pad}{first}{pad}\""), + } +} + +/// One connection to draw, between workflow node IDs. A backward condition +/// is its own link, from the edge's target back to its source. +#[derive(Clone)] +struct Link<'a> { + from: &'a str, + to: &'a str, + label: Option, + backward: bool, +} + +/// Terminal only: extra boxes for nodes the conversation ends at (an end, a +/// transfer) that more than one link leads to, by link index: `(node ID, +/// Mermaid ID of the copy)`. The first link keeps the node itself. +/// +/// A shared ending sits below everything that leads to it, so links from +/// high up run down the side of the diagram and cross the ones in between. +/// Nothing leaves an ending, so a copy per link reads the same and keeps each +/// path a straight line. +fn sink_copies( + links: &[Link], + nodes: &BTreeMap<&str, &Value>, + ids: &MermaidIds, +) -> BTreeMap { + let leaves: BTreeSet<&str> = links.iter().map(|l| l.from).collect(); + let mut seen: BTreeMap<&str, usize> = BTreeMap::new(); + let mut copies = BTreeMap::new(); + for (i, link) in links.iter().enumerate() { + if link.backward || leaves.contains(link.to) || !nodes.contains_key(link.to) { + continue; + } + let count = seen.entry(link.to).or_default(); + *count += 1; + if *count > 1 { + // `c` cannot collide with the `n_` node or `k_` key IDs. + let copy = format!("c{count}_{}", ids.get(link.to)); + copies.insert(i, (link.to.to_string(), copy)); + } + } + copies +} + +/// Collapse whitespace onto one line. +fn one_line(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +/// Longest word kept whole by [`shorten_ids`]. Names are shorter; IDs such +/// as `tool_01jxcrhpk0ehdvnxw3mw4sfb67` are longer. +const SHORT_ID_MIN: usize = 16; + +/// `text` with every ID-like word longer than [`SHORT_ID_MIN`] cut to its +/// start and end, `tool_01j…sfb67`, for a terminal too narrow for the drawing. +/// Brackets and commas around a word are kept. +fn shorten_ids(text: &str) -> String { + text.split(' ') + .map(|word| { + let core = word.trim_matches(|c: char| !(c.is_ascii_alphanumeric() || c == '_')); + let id_like = core.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'); + let chars: Vec = core.chars().collect(); + if !id_like || chars.len() <= SHORT_ID_MIN { + return word.to_string(); + } + let head: String = chars[..8].iter().collect(); + let tail: String = chars[chars.len() - 5..].iter().collect(); + word.replacen(core, &format!("{head}…{tail}"), 1) + }) + .collect::>() + .join(" ") +} + +/// Edges in the order the conversation reaches them: by the breadth-first +/// depth of their source from the start node(s), then by edge ID. Edges from +/// unreachable nodes come last. Deterministic, like the ID order. +fn flow_order<'a>( + nodes: &BTreeMap<&str, &Value>, + edges: &BTreeMap<&'a str, &'a Value>, +) -> Vec<&'a Value> { + let mut depth: BTreeMap<&str, usize> = BTreeMap::new(); + let mut queue = std::collections::VecDeque::new(); + for (id, node) in nodes { + if str_field(node, "type") == Some("start") { + depth.insert(id, 0); + queue.push_back(*id); + } + } + while let Some(id) = queue.pop_front() { + let next = depth[id] + 1; + for edge in edges.values() { + if str_field(edge, "source") != Some(id) { + continue; + } + if let Some(to) = str_field(edge, "target") { + if !depth.contains_key(to) { + depth.insert(to, next); + queue.push_back(to); + } + } + } + } + let mut ordered: Vec<(&str, &Value)> = edges.iter().map(|(id, e)| (*id, *e)).collect(); + ordered.sort_by_key(|(id, edge)| { + let source = str_field(edge, "source").and_then(|s| depth.get(s)); + (source.copied().unwrap_or(usize::MAX), *id) + }); + ordered.into_iter().map(|(_, edge)| edge).collect() +} + +/// One node's line: `id`, then `label` (already quoted) inside the shape's +/// delimiters. +/// +/// mmdflux ends a round `( )` node at the first `)`, even inside the quoted +/// label, and then fails to parse the rest ("Say · Confirm (booking)"). Other +/// shapes are unaffected, and so is mermaid.js. So in the terminal, a round +/// node's brackets become same-width lookalikes. +fn node_line(id: &str, open: &str, label: &str, close: &str, target: Target) -> String { + let label = if target == Target::Terminal && open == "(" { + label.replace('(', "❨").replace(')', "❩") + } else { + label.to_string() + }; + format!(" {id}{open}{label}{close}\n") +} + +/// A node's label: the kind of node (absent for start/end, which say it +/// themselves) and the text that identifies this one. +struct NodeLabel { + kind: Option, + text: String, +} + +impl NodeLabel { + /// The quoted label for `target`. Export puts it on one line, `kind · + /// text`; the terminal stacks the kind over the text, wrapped narrow. + fn render(&self, target: Target) -> String { + match target { + Target::Export => { + let line = match &self.kind { + Some(kind) => format!("{kind} · {}", self.text), + None => self.text.clone(), + }; + quote(&truncate(&line, NODE_LABEL_MAX)) + } + Target::Terminal => { + let mut lines: Vec = self.kind.iter().cloned().collect(); + lines.extend(wrap(&truncate(&self.text, NODE_LABEL_MAX), TERMINAL_WRAP)); + let lines: Vec = lines.iter().map(|l| plain(l)).collect(); + format!("\"{}\"", lines.join("
")) + } + } + } +} + +/// A node's label: its own `label` when it has one, otherwise a summary +/// derived from its type. Tagged with the kind, so types are tellable apart +/// without colour. +fn node_label(id: &str, node_type: &str, node: &Value) -> NodeLabel { + let own = str_field(node, "label").filter(|l| !l.trim().is_empty()); + let (kind, text) = match node_type { + "start" => (None, "Start".to_string()), + "end" => match str_field(node, "outcome") { + Some(outcome) => (None, format!("End ({outcome})")), + None => (None, "End".to_string()), + }, + "say" => { + let summary = own.or_else(|| { + let message = node.get("message")?; + str_field(message, "text").or_else(|| str_field(message, "prompt")) + }); + (Some("Say"), summary.unwrap_or(id).to_string()) + } + "tool" => { + let tools: Vec<&str> = node + .get("tools") + .and_then(Value::as_array) + .map(|ts| ts.iter().filter_map(|t| str_field(t, "tool_id")).collect()) + .unwrap_or_default(); + let text = if tools.is_empty() { + id.to_string() + } else { + tools.join(", ") + }; + (Some("Tool"), text) + } + "update_state" => (Some("Update state"), own.unwrap_or(id).to_string()), + "override_agent" => (Some("Subagent"), own.unwrap_or(id).to_string()), + "standalone_agent" => { + let agent = str_field(node, "agent_id").unwrap_or(id); + let text = match str_field(node, "branch_id") { + Some(branch) => format!("{agent} ({branch})"), + None => agent.to_string(), + }; + (Some("Agent transfer"), text) + } + "phone_number" => { + let destination = node + .get("transfer_destination") + .and_then(|d| str_field(d, "phone_number").or_else(|| str_field(d, "sip_uri"))); + ( + Some("Phone transfer"), + destination.unwrap_or(id).to_string(), + ) + } + other => (Some(other), own.unwrap_or(id).to_string()), + }; + NodeLabel { + kind: kind.map(String::from), + text, + } +} + +/// A present, non-null condition object. +fn condition<'a>(edge: &'a Value, key: &str) -> Option<&'a Value> { + edge.get(key).filter(|c| c.is_object()) +} + +/// A short summary of an edge condition, or `None` when there is nothing +/// worth printing (an unlabelled unconditional edge). +fn condition_label(cond: &Value) -> Option { + if let Some(label) = str_field(cond, "label").filter(|l| !l.trim().is_empty()) { + return Some(label.to_string()); + } + match str_field(cond, "type") { + Some("llm") => str_field(cond, "condition") + .filter(|c| !c.trim().is_empty()) + .map(String::from), + Some("result") => Some( + if cond.get("successful").and_then(Value::as_bool) == Some(true) { + "success" + } else { + "failure" + } + .to_string(), + ), + Some("expression") => Some("expression".to_string()), + Some("unconditional") | None => None, + Some(other) => Some(other.to_string()), + } +} + +fn str_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value.get(key).and_then(Value::as_str) +} + +/// Collapse whitespace and cut to `max` characters, ending in `…` when cut. +fn truncate(text: &str, max: usize) -> String { + let collapsed = text.split_whitespace().collect::>().join(" "); + if collapsed.chars().count() <= max { + return collapsed; + } + let cut: String = collapsed.chars().take(max - 1).collect(); + format!("{}…", cut.trim_end()) +} + +/// Quote label text for Mermaid, entity-encoding everything that would end +/// the string or be read as syntax. `#` goes first: it starts every entity. +fn quote(text: &str) -> String { + let mut out = String::with_capacity(text.len() + 2); + out.push('"'); + for c in text.chars() { + match c { + '#' => out.push_str("#35;"), + '"' => out.push_str("#quot;"), + '<' => out.push_str("#lt;"), + '>' => out.push_str("#gt;"), + '|' => out.push_str("#124;"), + '`' => out.push_str("#96;"), + '\n' | '\r' => out.push(' '), + c => out.push(c), + } + } + out.push('"'); + out +} + +/// Label text for mmdflux, which prints quoted text literally but does not +/// decode Mermaid's `#…;` entities. Only a quote (ends the string) and angle +/// brackets (read as tags, like the `
` that wraps lines) need replacing, +/// and lookalikes keep the text readable. +fn plain(text: &str) -> String { + text.chars() + .map(|c| match c { + '"' => '\'', + '<' => '‹', + '>' => '›', + '\n' | '\r' => ' ', + c => c, + }) + .collect() +} + +/// Greedy word wrap to `width` characters. A word longer than the width +/// gets a line of its own rather than being split. +fn wrap(text: &str, width: usize) -> Vec { + let mut lines: Vec = Vec::new(); + let mut line = String::new(); + for word in text.split_whitespace() { + if !line.is_empty() && line.chars().count() + 1 + word.chars().count() > width { + lines.push(std::mem::take(&mut line)); + } + if !line.is_empty() { + line.push(' '); + } + line.push_str(word); + } + if !line.is_empty() { + lines.push(line); + } + lines +} + +/// A node ID for a `%%` comment: one line, nothing else to escape. +fn comment_safe(id: &str) -> String { + id.replace(['\n', '\r'], " ") +} + +/// Mermaid-safe node IDs for workflow node IDs. +/// +/// Workflow IDs are arbitrary strings; Mermaid IDs must be plain words and +/// must not be keywords (`end`) or start with `o`/`x` right after a link +/// (`A---oB` is a circle edge). Prefixing `n_` and replacing everything else +/// with `_` covers all of it; a numeric suffix separates IDs that collapse to +/// the same word. IDs are assigned in sorted order so they are stable. +struct MermaidIds<'a> { + map: BTreeMap<&'a str, String>, +} + +impl<'a> MermaidIds<'a> { + fn new(ids: impl Iterator) -> Self { + let sorted: BTreeSet<&str> = ids.collect(); + let mut map = BTreeMap::new(); + let mut taken = BTreeSet::new(); + for id in sorted { + let base: String = format!("n_{id}") + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + let mut candidate = base.clone(); + let mut n = 2; + while !taken.insert(candidate.clone()) { + candidate = format!("{base}_{n}"); + n += 1; + } + map.insert(id, candidate); + } + MermaidIds { map } + } + + fn get(&self, id: &str) -> &str { + // Every id rendered was passed to `new`. + self.map.get(id).map(String::as_str).unwrap_or("n_") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn render(workflow: Value) -> String { + render_mermaid(&workflow) + } + + #[test] + fn every_known_node_type_gets_its_own_shape_and_class() { + let out = render(json!({ + "nodes": { + "a_start": { "type": "start" }, + "b_say": { "type": "say", "label": "Greet", "message": { "type": "literal", "text": "Hi" } }, + "c_tool": { "type": "tool", "tools": [{ "tool_id": "tool_1" }, { "tool_id": "tool_2" }] }, + "d_state": { "type": "update_state", "label": "Save name", "updates": [] }, + "e_sub": { "type": "override_agent", "label": "Billing" }, + "f_agent": { "type": "standalone_agent", "agent_id": "agent_9", "branch_id": "agtbrch_1" }, + "g_phone": { "type": "phone_number", "transfer_destination": { "type": "phone", "phone_number": "+15550100" } }, + "h_end": { "type": "end", "outcome": "success" } + }, + "edges": {} + })); + assert!(out.contains(r#"n_a_start(["Start"])"#), "{out}"); + assert!(out.contains(r#"n_b_say("Say · Greet")"#), "{out}"); + assert!( + out.contains(r#"n_c_tool[["Tool · tool_1, tool_2"]]"#), + "{out}" + ); + assert!( + out.contains(r#"n_d_state[/"Update state · Save name"/]"#), + "{out}" + ); + assert!(out.contains(r#"n_e_sub["Subagent · Billing"]"#), "{out}"); + assert!( + out.contains(r#"n_f_agent{{"Agent transfer · agent_9 (agtbrch_1)"}}"#), + "{out}" + ); + assert!( + out.contains(r#"n_g_phone{{"Phone transfer · +15550100"}}"#), + "{out}" + ); + assert!(out.contains(r#"n_h_end(["End (success)"])"#), "{out}"); + for class in [ + "t_start", + "t_end", + "t_say", + "t_tool", + "t_state", + "t_subagent", + "t_transfer", + ] { + assert!( + out.contains(&format!("classDef {class} ")), + "{class}: {out}" + ); + } + assert!( + out.contains("class n_f_agent,n_g_phone t_transfer"), + "{out}" + ); + // Only classes in use are defined. + assert!(!out.contains("t_unknown"), "{out}"); + } + + #[test] + fn an_unknown_node_type_renders_generically() { + let out = render(json!({ + "nodes": { "x": { "type": "future_thing", "label": "Later" } }, + "edges": {} + })); + assert!(out.contains(r#"n_x["future_thing · Later"]"#), "{out}"); + assert!(out.contains("class n_x t_unknown"), "{out}"); + } + + #[test] + fn a_say_node_without_a_label_falls_back_to_its_message() { + let out = render(json!({ + "nodes": { "s": { "type": "say", "label": "", "message": { "type": "prompt", "prompt": "Ask for the order number" } } }, + "edges": {} + })); + assert!(out.contains(r#""Say · Ask for the order number""#), "{out}"); + } + + #[test] + fn edge_labels_summarize_each_condition_kind() { + let out = render(json!({ + "nodes": { + "a": { "type": "start" }, "b": { "type": "end" }, "c": { "type": "end" }, + "d": { "type": "end" }, "e": { "type": "end" }, "f": { "type": "end" } + }, + "edges": { + "e1": { "source": "a", "target": "b", "forward_condition": { "type": "llm", "condition": "User wants a refund" } }, + "e2": { "source": "a", "target": "c", "forward_condition": { "type": "result", "successful": false } }, + "e3": { "source": "a", "target": "d", "forward_condition": { "type": "expression", "expression": { "type": "and_operator", "children": [] } } }, + "e4": { "source": "a", "target": "e", "forward_condition": { "type": "unconditional" } }, + "e5": { "source": "a", "target": "f", "forward_condition": { "type": "llm", "label": "Refund", "condition": "long text" } } + } + })); + assert!( + out.contains(r#"n_a -->|"User wants a refund"| n_b"#), + "{out}" + ); + assert!(out.contains(r#"n_a -->|"failure"| n_c"#), "{out}"); + assert!(out.contains(r#"n_a -->|"expression"| n_d"#), "{out}"); + assert!(out.contains("n_a --> n_e\n"), "{out}"); + // The human label wins over the raw condition. + assert!(out.contains(r#"n_a -->|"Refund"| n_f"#), "{out}"); + } + + #[test] + fn a_backward_condition_draws_a_reverse_dotted_arrow() { + let out = render(json!({ + "nodes": { "a": { "type": "say", "label": "A" }, "b": { "type": "say", "label": "B" } }, + "edges": { + "e": { + "source": "a", "target": "b", + "forward_condition": { "type": "llm", "condition": "go on" }, + "backward_condition": { "type": "llm", "condition": "go back" } + } + } + })); + assert!(out.contains(r#"n_a -->|"go on"| n_b"#), "{out}"); + assert!(out.contains(r#"n_b -.->|"go back"| n_a"#), "{out}"); + } + + #[test] + fn a_null_backward_condition_draws_nothing_backward() { + let out = render(json!({ + "nodes": { "a": { "type": "start" }, "b": { "type": "end" } }, + "edges": { "e": { "source": "a", "target": "b", "forward_condition": { "type": "unconditional" }, "backward_condition": null } } + })); + assert!(!out.contains("-.->"), "{out}"); + } + + #[test] + fn label_text_cannot_break_out_of_the_mermaid_string() { + let out = render(json!({ + "nodes": { + "a": { "type": "say", "label": "Say \"hi\" | #1\nthen `wait`" }, + "b": { "type": "end" } + }, + "edges": { "e": { "source": "a", "target": "b", "forward_condition": { "type": "llm", "condition": "x|y \"z\"" } } } + })); + assert!( + out.contains(r#"n_a("Say · Say #quot;hi#quot; #124; #lt;b#gt;#35;1#lt;/b#gt; then #96;wait#96;")"#), + "{out}" + ); + assert!( + out.contains(r#"n_a -->|"x#124;y #quot;z#quot;"| n_b"#), + "{out}" + ); + } + + #[test] + fn long_conditions_are_truncated_with_an_ellipsis() { + let long = "The user has clearly said that they would like to speak to a human agent now"; + let out = render(json!({ + "nodes": { "a": { "type": "start" }, "b": { "type": "end" } }, + "edges": { "e": { "source": "a", "target": "b", "forward_condition": { "type": "llm", "condition": long } } } + })); + let label = out + .lines() + .find(|l| l.contains("-->")) + .and_then(|l| l.split('"').nth(1)) + .unwrap(); + assert!(label.ends_with('…'), "{label}"); + assert_eq!(label.chars().count(), EDGE_LABEL_MAX, "{label}"); + } + + #[test] + fn node_ids_are_made_mermaid_safe_and_kept_distinct() { + let out = render(json!({ + "nodes": { + "end": { "type": "end" }, + "node-1": { "type": "say", "label": "A" }, + "node_1": { "type": "say", "label": "B" }, + "o": { "type": "start" } + }, + "edges": { "e": { "source": "o", "target": "end" } } + })); + assert!(out.contains(r#"n_end(["End"])"#), "{out}"); + assert!(out.contains(r#"n_node_1("Say · A")"#), "{out}"); + assert!(out.contains(r#"n_node_1_2("Say · B")"#), "{out}"); + assert!(out.contains("n_o --> n_end\n"), "{out}"); + } + + #[test] + fn an_edge_to_a_missing_node_is_drawn_not_dropped() { + let out = render(json!({ + "nodes": { "a": { "type": "start" } }, + "edges": { "e": { "source": "a", "target": "ghost" } } + })); + assert!(out.contains("%% no node defines 'ghost'"), "{out}"); + assert!(out.contains(r#"n_ghost["missing: ghost"]"#), "{out}"); + assert!(out.contains("n_a --> n_ghost"), "{out}"); + } + + #[test] + fn output_does_not_depend_on_map_order() { + let forward: Value = serde_json::from_str( + r#"{"nodes":{"a":{"type":"start"},"b":{"type":"say","label":"B"},"c":{"type":"end"}}, + "edges":{"e1":{"source":"a","target":"b"},"e2":{"source":"b","target":"c"}}}"#, + ) + .unwrap(); + let reversed: Value = serde_json::from_str( + r#"{"edges":{"e2":{"source":"b","target":"c"},"e1":{"source":"a","target":"b"}}, + "nodes":{"c":{"type":"end"},"b":{"type":"say","label":"B"},"a":{"type":"start"}}}"#, + ) + .unwrap(); + assert_eq!(render_mermaid(&forward), render_mermaid(&reversed)); + } + + #[test] + fn a_workflow_without_nodes_has_nothing_to_draw() { + assert!(workflow_of(&json!({})).is_none()); + assert!(workflow_of(&json!({ "workflow": null })).is_none()); + assert!(workflow_of(&json!({ "workflow": { "nodes": {}, "edges": {} } })).is_none()); + assert!( + workflow_of(&json!({ "workflow": { "nodes": { "a": { "type": "start" } } } })) + .is_some() + ); + } + + #[test] + fn markdown_output_is_a_mermaid_fence() { + assert_eq!(fence("flowchart TD\n"), "```mermaid\nflowchart TD\n```\n"); + } + + fn terminal(workflow: Value) -> Built { + build(&workflow, Target::Terminal, &Layout::default()) + } + + #[test] + fn terminal_conditions_become_numbered_keys_with_a_legend() { + let built = terminal(json!({ + "nodes": { "a": { "type": "start" }, "b": { "type": "end" }, "c": { "type": "end" } }, + "edges": { + "e1": { "source": "a", "target": "b", "forward_condition": { "type": "llm", "condition": "The caller wants a refund for their whole order, not just part of it" } }, + "e2": { "source": "a", "target": "c", "forward_condition": { "type": "unconditional" } } + } + })); + assert!( + built.source.contains(r#"n_a --- k_1(["1"]) --> n_b"#), + "{}", + built.source + ); + assert!(built.source.contains("n_a --> n_c\n"), "{}", built.source); + assert!(built.source.contains("class k_1 t_key"), "{}", built.source); + // The legend has the whole condition, not the export's truncation. + assert_eq!( + built.legend, + [" 1 The caller wants a refund for their whole order, not just part of it"] + ); + } + + #[test] + fn terminal_steps_back_end_in_a_stub_naming_where_they_go() { + let built = terminal(json!({ + "nodes": { "a": { "type": "say", "label": "A" }, "b": { "type": "say", "label": "B" }, "c": { "type": "say", "label": "C" } }, + "edges": { + "e": { + "source": "a", "target": "b", + "forward_condition": { "type": "llm", "condition": "on" }, + "backward_condition": { "type": "llm", "condition": "back" } + }, + "f": { "source": "b", "target": "c", "backward_condition": { "type": "unconditional" } } + } + })); + assert!( + built.source.contains("n_b -.- k_2([\"2 ↩\"])\n"), + "{}", + built.source + ); + // The stub is not linked back to where it goes. + assert!(!built.source.contains("-.->"), "{}", built.source); + assert_eq!(built.legend[1], " 2 back (back to 'A')"); + assert_eq!(built.legend[2], " 3 Back to 'B'"); + } + + #[test] + fn terminal_endings_are_drawn_once_per_path_into_them() { + let workflow = json!({ + "nodes": { + "s": { "type": "start" }, + "t": { "type": "tool" }, + "x": { "type": "say", "label": "X" }, + "done": { "type": "end" } + }, + "edges": { + "e1": { "source": "s", "target": "t" }, + "e2": { "source": "t", "target": "done", "forward_condition": { "type": "result", "successful": true } }, + "e3": { "source": "t", "target": "x", "forward_condition": { "type": "result", "successful": false } }, + "e4": { "source": "x", "target": "done" }, + // x has a way out, so it is never copied. + "e5": { "source": "s", "target": "x" } + } + }); + let built = build(&workflow, Target::Terminal, &Layout::default()); + assert!( + built.source.contains("c2_n_done([\"End\"])"), + "{}", + built.source + ); + assert!( + built.source.contains("n_x --> c2_n_done"), + "{}", + built.source + ); + assert!(!built.source.contains("c2_n_x"), "{}", built.source); + assert!( + built.source.contains("class c2_n_done,n_done t_end"), + "{}", + built.source + ); + assert!(!render_mermaid(&workflow).contains("c2_")); + } + + #[test] + fn long_ids_are_shortened_but_names_are_not() { + assert_eq!( + shorten_ids("tool_01jxcrhpk0ehdvnxw3mw4sfb67, tool_x"), + "tool_01j…sfb67, tool_x" + ); + assert_eq!( + shorten_ids("agent_7801k5billing3x9q2m4 (agtbrch_4401k9betabilling6t3)"), + "agent_78…9q2m4 (agtbrch_…ng6t3)" + ); + assert_eq!( + shorten_ids("Remember the order status"), + "Remember the order status" + ); + } + + #[test] + fn widen_pads_the_first_line_only_when_narrower() { + assert_eq!(widen("\"Say
Hello\"", 9), "\" Say
Hello\""); + assert_eq!(widen("\"Hello\"", 3), "\"Hello\""); + } + + #[test] + fn a_busy_workflow_draws_without_crossing_lines() { + let config: Value = + serde_json::from_str(include_str!("fixtures/diagram/busy.json")).unwrap(); + let workflow = workflow_of(&config).unwrap(); + for width in [None, Some(104)] { + let drawn = render_terminal(workflow, false, width).expect("draws"); + let tangled: Vec = drawn.chars().filter(|c| TANGLES.contains(c)).collect(); + assert!(tangled.is_empty(), "{width:?}: {tangled:?}\n{drawn}"); + if let Some(width) = width { + assert!(text_width(&drawn) <= width, "{drawn}"); + assert!(drawn.contains("tool_01j…sfb67"), "{drawn}"); + } + } + } + + #[test] + fn terminal_labels_are_plain_text_wrapped_under_their_kind() { + let built = terminal(json!({ + "nodes": { + "s": { "type": "say", "label": "Read back the \"booking\"
to the caller" }, + "u": { "type": "update_state", "label": "Save", "updates": [] } + }, + "edges": {} + })); + // mmdflux does not decode entities, so none may appear. + assert!(!built.source.contains("#quot;"), "{}", built.source); + assert!( + built + .source + .contains(r#"n_s("Say
Read back the 'booking'
‹details› to the caller")"#), + "{}", + built.source + ); + // No parallelogram in mmdflux; a cylinder instead. + assert!( + built.source.contains(r#"n_u[("Update state
Save")]"#), + "{}", + built.source + ); + } + + #[test] + fn terminal_keys_are_numbered_in_flow_order() { + // By edge ID the start edge ("z_first") would be numbered last. + let built = terminal(json!({ + "nodes": { "s": { "type": "start" }, "t": { "type": "tool" }, "a": { "type": "end" }, "b": { "type": "end" } }, + "edges": { + "a_fail": { "source": "t", "target": "a", "forward_condition": { "type": "result", "successful": false } }, + "b_ok": { "source": "t", "target": "b", "forward_condition": { "type": "result", "successful": true } }, + "z_first": { "source": "s", "target": "t", "forward_condition": { "type": "llm", "condition": "go" } } + } + })); + assert_eq!(built.legend, [" 1 go", " 2 failure", " 3 success"]); + } + + #[test] + fn brackets_in_a_say_node_still_draw_in_the_terminal() { + let workflow = json!({ + "nodes": { + "a": { "type": "start" }, + "b": { "type": "say", "label": "Confirm (booking) details" }, + "c": { "type": "say", "label": "a ) b" } + }, + "edges": { "e": { "source": "a", "target": "b" } } + }); + let built = build(&workflow, Target::Terminal, &Layout::default()); + assert!( + built.source.contains("Confirm ❨booking❩"), + "{}", + built.source + ); + render_terminal(&workflow, false, None).expect("draws"); + // mermaid.js handles brackets, so export keeps them. + assert!(render_mermaid(&workflow).contains("Confirm (booking)")); + } + + #[test] + fn wrap_keeps_long_words_whole() { + assert_eq!(wrap("a bb ccc", 4), ["a bb", "ccc"]); + assert_eq!( + wrap("system__is_text_only x", 8), + ["system__is_text_only", "x"] + ); + assert!(wrap(" ", 8).is_empty()); + } + + /// Every fixture must draw: an mmdflux parse error would only show up as + /// a failed command. + #[test] + fn every_fixture_draws_in_the_terminal() { + for input in [ + include_str!("fixtures/diagram/linear.json"), + include_str!("fixtures/diagram/tool_branches.json"), + include_str!("fixtures/diagram/transfers.json"), + include_str!("fixtures/diagram/llm_loop.json"), + include_str!("fixtures/diagram/busy.json"), + ] { + let config: Value = serde_json::from_str(input).unwrap(); + let workflow = workflow_of(&config).unwrap(); + let drawn = render_terminal(workflow, false, None).expect("draws"); + assert!(drawn.contains("Start"), "{drawn}"); + assert!( + !drawn.contains('\u{1b}'), + "no colour was asked for: {drawn}" + ); + let legend = build(workflow, Target::Terminal, &Layout::default()).legend; + for line in &legend { + assert!(drawn.contains(line.as_str()), "legend line missing: {line}"); + } + } + } + + /// Representative workflows, rendered in full. A change here is a change + /// to every diagram users have committed, so it should be deliberate. + #[test] + fn fixtures_render_to_their_snapshots() { + const FIXTURES: &[(&str, &str, &str)] = &[ + ( + "linear", + include_str!("fixtures/diagram/linear.json"), + include_str!("fixtures/diagram/linear.mmd"), + ), + ( + "tool_branches", + include_str!("fixtures/diagram/tool_branches.json"), + include_str!("fixtures/diagram/tool_branches.mmd"), + ), + ( + "transfers", + include_str!("fixtures/diagram/transfers.json"), + include_str!("fixtures/diagram/transfers.mmd"), + ), + ( + "llm_loop", + include_str!("fixtures/diagram/llm_loop.json"), + include_str!("fixtures/diagram/llm_loop.mmd"), + ), + ( + "busy", + include_str!("fixtures/diagram/busy.json"), + include_str!("fixtures/diagram/busy.mmd"), + ), + ]; + for (name, input, expected) in FIXTURES { + let config: Value = serde_json::from_str(input).expect(name); + let workflow = workflow_of(&config).expect(name); + assert_eq!(render_mermaid(workflow), *expected, "fixture {name}"); + } + } +} diff --git a/cli/elevenlabs/workflow/fixtures/diagram/busy.json b/cli/elevenlabs/workflow/fixtures/diagram/busy.json new file mode 100644 index 0000000..b2af676 --- /dev/null +++ b/cli/elevenlabs/workflow/fixtures/diagram/busy.json @@ -0,0 +1,248 @@ +{ + "name": "Acme Concierge", + "workflow": { + "nodes": { + "start_node": { + "type": "start", + "edge_order": [ + "e_start" + ] + }, + "triage": { + "type": "override_agent", + "label": "Triage (intent)", + "additional_prompt": "Work out what the caller needs.", + "additional_tool_ids": [ + "tool_01jxcrhpk5ehftmr24qxs2y20x" + ], + "additional_knowledge_base": [ + { + "type": "file", + "id": "Hn5sQ2wE8rTy4uI7oP3a", + "name": "Troubleshooting FAQ", + "usage_mode": "auto" + } + ] + }, + "verify": { + "type": "say", + "label": "Ask for the order number", + "message": { + "type": "prompt", + "prompt": "Ask for the order number and confirm it back." + } + }, + "lookup": { + "type": "tool", + "tools": [ + { + "tool_id": "tool_01jxcrhpk0ehdvnxw3mw4sfb67" + }, + { + "tool_id": "tool_7201m0hwz3xkf6c2bq9rj5yg8n4t" + } + ] + }, + "save_order": { + "type": "update_state", + "label": "Remember order status", + "updates": [] + }, + "refunds": { + "type": "override_agent", + "label": "Refund desk", + "additional_tool_ids": [ + "tool_01jxcrhpk3er4ve8nxcfprz04x" + ], + "conversation_config": { + "agent": { + "prompt": { + "tool_ids": [ + "tool_01jxcrhpk7ej4b7sjt8xqmwsec" + ], + "knowledge_base": [ + { + "type": "file", + "id": "F7wzgVuiIUIKYAH2JH5s", + "name": "Returns policy", + "usage_mode": "auto" + } + ] + } + } + } + }, + "refund_tool": { + "type": "tool", + "tools": [ + { + "tool_id": "tool_01jxcrhpk3er4ve8nxcfprz04x" + } + ] + }, + "sorry": { + "type": "say", + "label": "Apologise and offer a callback", + "message": { + "type": "literal", + "text": "Sorry, I couldn't find that order (yet)." + } + }, + "to_billing_beta": { + "type": "standalone_agent", + "agent_id": "agent_7801k5billing3x9q2m4", + "branch_id": "agtbrch_4401k9betabilling6t3", + "transfer_message": "Connecting you to billing." + }, + "to_tech": { + "type": "standalone_agent", + "agent_id": "agent_7801k5techsupp7w2e6r1" + }, + "to_human": { + "type": "phone_number", + "transfer_destination": { + "type": "phone", + "phone_number": "+15550100" + }, + "transfer_type": "conference" + }, + "resolved": { + "type": "end", + "outcome": "success" + }, + "gave_up": { + "type": "end", + "outcome": "failure" + } + }, + "edges": { + "e_start": { + "source": "start_node", + "target": "triage", + "forward_condition": { + "type": "unconditional" + } + }, + "e_order": { + "source": "triage", + "target": "verify", + "forward_condition": { + "type": "llm", + "condition": "The caller asks about an existing order, a delivery or a return.", + "label": "Order question" + } + }, + "e_billing": { + "source": "triage", + "target": "to_billing_beta", + "forward_condition": { + "type": "expression", + "label": "Beta billing customer", + "expression": { + "type": "eq_operator", + "left": { + "type": "dynamic_variable", + "name": "plan" + }, + "right": { + "type": "string_literal", + "value": "beta" + } + } + } + }, + "e_tech": { + "source": "triage", + "target": "to_tech", + "forward_condition": { + "type": "llm", + "condition": "The caller can't log in or the app isn't working." + } + }, + "e_human": { + "source": "triage", + "target": "to_human", + "forward_condition": { + "type": "llm", + "condition": "The caller asks for a person, or is very upset." + } + }, + "e_verify": { + "source": "verify", + "target": "lookup", + "forward_condition": { + "type": "llm", + "condition": "The caller has given an order number." + }, + "backward_condition": { + "type": "llm", + "condition": "The caller wants to talk about something else.", + "label": "Changes topic" + } + }, + "e_found": { + "source": "lookup", + "target": "save_order", + "forward_condition": { + "type": "result", + "successful": true + } + }, + "e_missing": { + "source": "lookup", + "target": "sorry", + "forward_condition": { + "type": "result", + "successful": false + } + }, + "e_refund_q": { + "source": "save_order", + "target": "refunds", + "forward_condition": { + "type": "llm", + "condition": "The caller wants a refund." + } + }, + "e_done": { + "source": "save_order", + "target": "resolved", + "forward_condition": { + "type": "llm", + "condition": "The caller has what they need." + } + }, + "e_refund": { + "source": "refunds", + "target": "refund_tool", + "forward_condition": { + "type": "llm", + "condition": "The caller confirmed the refund amount." + } + }, + "e_refunded": { + "source": "refund_tool", + "target": "resolved", + "forward_condition": { + "type": "result", + "successful": true + } + }, + "e_refund_fail": { + "source": "refund_tool", + "target": "to_human", + "forward_condition": { + "type": "result", + "successful": false + } + }, + "e_sorry": { + "source": "sorry", + "target": "gave_up", + "forward_condition": { + "type": "unconditional" + } + } + } + } +} diff --git a/cli/elevenlabs/workflow/fixtures/diagram/busy.mmd b/cli/elevenlabs/workflow/fixtures/diagram/busy.mmd new file mode 100644 index 0000000..4e58146 --- /dev/null +++ b/cli/elevenlabs/workflow/fixtures/diagram/busy.mmd @@ -0,0 +1,43 @@ +flowchart TD + n_gave_up(["End (failure)"]) + n_lookup[["Tool · tool_01jxcrhpk0ehdvnxw3mw4sfb67, tool_7201m0hwz3xkf6…"]] + n_refund_tool[["Tool · tool_01jxcrhpk3er4ve8nxcfprz04x"]] + n_refunds["Subagent · Refund desk"] + n_resolved(["End (success)"]) + n_save_order[/"Update state · Remember order status"/] + n_sorry("Say · Apologise and offer a callback") + n_start_node(["Start"]) + n_to_billing_beta{{"Agent transfer · agent_7801k5billing3x9q2m4 (agtbrch_4401k9…"}} + n_to_human{{"Phone transfer · +15550100"}} + n_to_tech{{"Agent transfer · agent_7801k5techsupp7w2e6r1"}} + n_triage["Subagent · Triage (intent)"] + n_verify("Say · Ask for the order number") + n_triage -->|"Beta billing customer"| n_to_billing_beta + n_save_order -->|"The caller has what they need."| n_resolved + n_lookup -->|"success"| n_save_order + n_triage -->|"The caller asks for a person, or is ver…"| n_to_human + n_lookup -->|"failure"| n_sorry + n_triage -->|"Order question"| n_verify + n_refunds -->|"The caller confirmed the refund amount."| n_refund_tool + n_refund_tool -->|"failure"| n_to_human + n_save_order -->|"The caller wants a refund."| n_refunds + n_refund_tool -->|"success"| n_resolved + n_sorry --> n_gave_up + n_start_node --> n_triage + n_triage -->|"The caller can't log in or the app isn'…"| n_to_tech + n_verify -->|"The caller has given an order number."| n_lookup + n_lookup -.->|"Changes topic"| n_verify + classDef t_start fill:#d1fae5,stroke:#059669,color:#064e3b + class n_start_node t_start + classDef t_end fill:#fee2e2,stroke:#dc2626,color:#7f1d1d + class n_gave_up,n_resolved t_end + classDef t_say fill:#e0f2fe,stroke:#0284c7,color:#0c4a6e + class n_sorry,n_verify t_say + classDef t_tool fill:#fef3c7,stroke:#d97706,color:#78350f + class n_lookup,n_refund_tool t_tool + classDef t_state fill:#ede9fe,stroke:#7c3aed,color:#4c1d95 + class n_save_order t_state + classDef t_subagent fill:#f1f5f9,stroke:#475569,color:#0f172a + class n_refunds,n_triage t_subagent + classDef t_transfer fill:#fce7f3,stroke:#db2777,color:#831843 + class n_to_billing_beta,n_to_human,n_to_tech t_transfer diff --git a/cli/elevenlabs/workflow/fixtures/diagram/linear.json b/cli/elevenlabs/workflow/fixtures/diagram/linear.json new file mode 100644 index 0000000..dc21f9e --- /dev/null +++ b/cli/elevenlabs/workflow/fixtures/diagram/linear.json @@ -0,0 +1,22 @@ +{ + "name": "Linear greeter", + "conversation_config": {}, + "platform_settings": {}, + "tags": [], + "workflow": { + "nodes": { + "start_node": { "type": "start", "position": { "x": 0, "y": 0 }, "edge_order": ["start_to_greet"] }, + "greet": { + "type": "say", + "label": "Greet caller", + "message": { "type": "literal", "text": "Hi, thanks for calling!" }, + "edge_order": ["greet_to_end"] + }, + "end_node": { "type": "end", "outcome": "success" } + }, + "edges": { + "start_to_greet": { "source": "start_node", "target": "greet", "forward_condition": { "type": "unconditional" } }, + "greet_to_end": { "source": "greet", "target": "end_node", "forward_condition": { "type": "unconditional" } } + } + } +} diff --git a/cli/elevenlabs/workflow/fixtures/diagram/linear.mmd b/cli/elevenlabs/workflow/fixtures/diagram/linear.mmd new file mode 100644 index 0000000..815c9ba --- /dev/null +++ b/cli/elevenlabs/workflow/fixtures/diagram/linear.mmd @@ -0,0 +1,12 @@ +flowchart TD + n_end_node(["End (success)"]) + n_greet("Say · Greet caller") + n_start_node(["Start"]) + n_greet --> n_end_node + n_start_node --> n_greet + classDef t_start fill:#d1fae5,stroke:#059669,color:#064e3b + class n_start_node t_start + classDef t_end fill:#fee2e2,stroke:#dc2626,color:#7f1d1d + class n_end_node t_end + classDef t_say fill:#e0f2fe,stroke:#0284c7,color:#0c4a6e + class n_greet t_say diff --git a/cli/elevenlabs/workflow/fixtures/diagram/llm_loop.json b/cli/elevenlabs/workflow/fixtures/diagram/llm_loop.json new file mode 100644 index 0000000..7b0d88d --- /dev/null +++ b/cli/elevenlabs/workflow/fixtures/diagram/llm_loop.json @@ -0,0 +1,21 @@ +{ + "name": "Booking", + "workflow": { + "nodes": { + "start_node": { "type": "start" }, + "ask_date": { "type": "override_agent", "label": "Ask for a date" }, + "confirm": { "type": "say", "label": "Confirm \"booking\" | details", "message": { "type": "prompt", "prompt": "Read back the booking." } }, + "end_node": { "type": "end" } + }, + "edges": { + "e_start": { "source": "start_node", "target": "ask_date", "forward_condition": { "type": "unconditional" } }, + "e_confirm": { + "source": "ask_date", + "target": "confirm", + "forward_condition": { "type": "llm", "condition": "The caller has chosen a date and a time for the booking" }, + "backward_condition": { "type": "llm", "label": "Wants another date", "condition": "The caller wants to change the date." } + }, + "e_end": { "source": "confirm", "target": "end_node", "forward_condition": { "type": "unconditional" }, "backward_condition": null } + } + } +} diff --git a/cli/elevenlabs/workflow/fixtures/diagram/llm_loop.mmd b/cli/elevenlabs/workflow/fixtures/diagram/llm_loop.mmd new file mode 100644 index 0000000..377f6f2 --- /dev/null +++ b/cli/elevenlabs/workflow/fixtures/diagram/llm_loop.mmd @@ -0,0 +1,17 @@ +flowchart TD + n_ask_date["Subagent · Ask for a date"] + n_confirm("Say · Confirm #quot;booking#quot; #124; details") + n_end_node(["End"]) + n_start_node(["Start"]) + n_ask_date -->|"The caller has chosen a date and a time…"| n_confirm + n_confirm -.->|"Wants another date"| n_ask_date + n_confirm --> n_end_node + n_start_node --> n_ask_date + classDef t_start fill:#d1fae5,stroke:#059669,color:#064e3b + class n_start_node t_start + classDef t_end fill:#fee2e2,stroke:#dc2626,color:#7f1d1d + class n_end_node t_end + classDef t_say fill:#e0f2fe,stroke:#0284c7,color:#0c4a6e + class n_confirm t_say + classDef t_subagent fill:#f1f5f9,stroke:#475569,color:#0f172a + class n_ask_date t_subagent diff --git a/cli/elevenlabs/workflow/fixtures/diagram/tool_branches.json b/cli/elevenlabs/workflow/fixtures/diagram/tool_branches.json new file mode 100644 index 0000000..d7bb566 --- /dev/null +++ b/cli/elevenlabs/workflow/fixtures/diagram/tool_branches.json @@ -0,0 +1,22 @@ +{ + "name": "Order lookup", + "workflow": { + "nodes": { + "start_node": { "type": "start" }, + "collect": { "type": "override_agent", "label": "Collect order number", "additional_prompt": "Ask for the order number." }, + "lookup": { "type": "tool", "tools": [{ "tool_id": "tool_lookup_order" }] }, + "save": { "type": "update_state", "label": "Remember order status", "updates": [] }, + "apologize": { "type": "say", "label": "Apologize", "message": { "type": "prompt", "prompt": "Apologize and offer a callback." } }, + "done": { "type": "end", "outcome": "success" }, + "failed": { "type": "end", "outcome": "failure" } + }, + "edges": { + "e_start": { "source": "start_node", "target": "collect", "forward_condition": { "type": "unconditional" } }, + "e_collect": { "source": "collect", "target": "lookup", "forward_condition": { "type": "llm", "label": "Has order number", "condition": "The caller has given an order number." } }, + "e_ok": { "source": "lookup", "target": "save", "forward_condition": { "type": "result", "successful": true } }, + "e_fail": { "source": "lookup", "target": "apologize", "forward_condition": { "type": "result", "successful": false } }, + "e_saved": { "source": "save", "target": "done", "forward_condition": { "type": "unconditional" } }, + "e_sorry": { "source": "apologize", "target": "failed", "forward_condition": { "type": "unconditional" } } + } + } +} diff --git a/cli/elevenlabs/workflow/fixtures/diagram/tool_branches.mmd b/cli/elevenlabs/workflow/fixtures/diagram/tool_branches.mmd new file mode 100644 index 0000000..a5c50e2 --- /dev/null +++ b/cli/elevenlabs/workflow/fixtures/diagram/tool_branches.mmd @@ -0,0 +1,26 @@ +flowchart TD + n_apologize("Say · Apologize") + n_collect["Subagent · Collect order number"] + n_done(["End (success)"]) + n_failed(["End (failure)"]) + n_lookup[["Tool · tool_lookup_order"]] + n_save[/"Update state · Remember order status"/] + n_start_node(["Start"]) + n_collect -->|"Has order number"| n_lookup + n_lookup -->|"failure"| n_apologize + n_lookup -->|"success"| n_save + n_save --> n_done + n_apologize --> n_failed + n_start_node --> n_collect + classDef t_start fill:#d1fae5,stroke:#059669,color:#064e3b + class n_start_node t_start + classDef t_end fill:#fee2e2,stroke:#dc2626,color:#7f1d1d + class n_done,n_failed t_end + classDef t_say fill:#e0f2fe,stroke:#0284c7,color:#0c4a6e + class n_apologize t_say + classDef t_tool fill:#fef3c7,stroke:#d97706,color:#78350f + class n_lookup t_tool + classDef t_state fill:#ede9fe,stroke:#7c3aed,color:#4c1d95 + class n_save t_state + classDef t_subagent fill:#f1f5f9,stroke:#475569,color:#0f172a + class n_collect t_subagent diff --git a/cli/elevenlabs/workflow/fixtures/diagram/transfers.json b/cli/elevenlabs/workflow/fixtures/diagram/transfers.json new file mode 100644 index 0000000..64fdbba --- /dev/null +++ b/cli/elevenlabs/workflow/fixtures/diagram/transfers.json @@ -0,0 +1,20 @@ +{ + "name": "Front desk", + "workflow": { + "nodes": { + "start_node": { "type": "start" }, + "triage": { "type": "override_agent", "label": "Triage" }, + "billing": { "type": "standalone_agent", "agent_id": "agent_billing", "transfer_message": "Connecting you to billing." }, + "billing_beta": { "type": "standalone_agent", "agent_id": "agent_billing", "branch_id": "agtbrch_beta" }, + "human": { "type": "phone_number", "transfer_destination": { "type": "phone", "phone_number": "+15550100" }, "transfer_type": "conference" }, + "sip": { "type": "phone_number", "transfer_destination": { "type": "sip_uri", "sip_uri": "sip:support@example.com" } } + }, + "edges": { + "e_start": { "source": "start_node", "target": "triage", "forward_condition": { "type": "unconditional" } }, + "e_billing": { "source": "triage", "target": "billing", "forward_condition": { "type": "llm", "condition": "The caller has a question about an invoice or a payment." } }, + "e_beta": { "source": "triage", "target": "billing_beta", "forward_condition": { "type": "expression", "label": "Beta customer", "expression": { "type": "eq_operator", "left": { "type": "dynamic_variable", "name": "tier" }, "right": { "type": "string_literal", "value": "beta" } } } }, + "e_human": { "source": "triage", "target": "human", "forward_condition": { "type": "llm", "condition": "The caller asks for a person." } }, + "e_sip": { "source": "triage", "target": "sip", "forward_condition": { "type": "expression", "expression": { "type": "boolean_literal", "value": false } } } + } + } +} diff --git a/cli/elevenlabs/workflow/fixtures/diagram/transfers.mmd b/cli/elevenlabs/workflow/fixtures/diagram/transfers.mmd new file mode 100644 index 0000000..b0b9b89 --- /dev/null +++ b/cli/elevenlabs/workflow/fixtures/diagram/transfers.mmd @@ -0,0 +1,18 @@ +flowchart TD + n_billing{{"Agent transfer · agent_billing"}} + n_billing_beta{{"Agent transfer · agent_billing (agtbrch_beta)"}} + n_human{{"Phone transfer · +15550100"}} + n_sip{{"Phone transfer · sip:support@example.com"}} + n_start_node(["Start"]) + n_triage["Subagent · Triage"] + n_triage -->|"Beta customer"| n_billing_beta + n_triage -->|"The caller has a question about an invo…"| n_billing + n_triage -->|"The caller asks for a person."| n_human + n_triage -->|"expression"| n_sip + n_start_node --> n_triage + classDef t_start fill:#d1fae5,stroke:#059669,color:#064e3b + class n_start_node t_start + classDef t_subagent fill:#f1f5f9,stroke:#475569,color:#0f172a + class n_triage t_subagent + classDef t_transfer fill:#fce7f3,stroke:#db2777,color:#831843 + class n_billing,n_billing_beta,n_human,n_sip t_transfer diff --git a/cli/elevenlabs/workflow/mod.rs b/cli/elevenlabs/workflow/mod.rs index 4064165..c45d11e 100644 --- a/cli/elevenlabs/workflow/mod.rs +++ b/cli/elevenlabs/workflow/mod.rs @@ -16,6 +16,7 @@ use fern_cli_sdk::app::CliApp; mod agents; mod api; mod components; +mod diagram; mod feedback; mod flows_wait; mod intent; @@ -46,6 +47,7 @@ pub fn register(app: CliApp) -> CliApp { let app = onboard::register(app); let app = agents::register(app); let app = templates::register(app); + let app = diagram::register(app); let app = tools::register(app); let app = tests::register(app); let app = residency::register(app); diff --git a/tests/workflow_diagram_test.rs b/tests/workflow_diagram_test.rs new file mode 100644 index 0000000..1f4f185 --- /dev/null +++ b/tests/workflow_diagram_test.rs @@ -0,0 +1,223 @@ +//! Integration tests for `elevenlabs agents workflow diagram` and its formats. +//! +//! The renderer itself is unit-tested in `cli/elevenlabs/workflow/diagram.rs`; +//! these cover what only the binary can: resolving an agent through +//! `agents.json`, `--all` / `--markdown` output, and that the command runs +//! with no API key at all (it is meant for CI on forks, where there is none). + +use std::path::Path; +use std::process::{Command, Output}; + +/// Run the CLI in `project` with no credentials, and `HOME` redirected so a +/// developer's own config cannot supply any. +fn cli(project: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_elevenlabs")) + .args(args) + .current_dir(project) + .env("HOME", project) + .env("NO_COLOR", "1") + .env("FERN_CLI_CREDENTIAL_STORE", "file") + .env_remove("ELEVENLABS_API_KEY") + .env_remove("ELEVENLABS_BASE_URL") + .env_remove("ELEVENLABS_VIA") + .output() + .expect("failed to spawn the elevenlabs binary") +} + +fn stdout(out: &Output) -> String { + String::from_utf8_lossy(&out.stdout).into_owned() +} + +/// A project with one agent that has a workflow, one branch of it, and one +/// agent that has none. +fn project() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + std::fs::create_dir(root.join("agent_configs")).unwrap(); + let workflow = serde_json::json!({ + "nodes": { + "start_node": { "type": "start" }, + "greet": { "type": "say", "label": "Greet", "message": { "type": "literal", "text": "Hi" } } + }, + "edges": { + "e": { "source": "start_node", "target": "greet", "forward_condition": { "type": "unconditional" } } + } + }); + let write = |path: &str, value: serde_json::Value| { + std::fs::write( + root.join(path), + serde_json::to_string_pretty(&value).unwrap(), + ) + .unwrap(); + }; + write( + "agent_configs/support.json", + serde_json::json!({ "name": "Support", "workflow": workflow }), + ); + write( + "agent_configs/support.beta.json", + serde_json::json!({ "name": "Support", "workflow": workflow }), + ); + write( + "agent_configs/plain.json", + serde_json::json!({ "name": "Plain", "conversation_config": {} }), + ); + write( + "agents.json", + serde_json::json!({ "agents": [ + { + "config": "agent_configs/support.json", + "id": "agent_support", + "branches": { "beta": { "config": "agent_configs/support.beta.json", "branch_id": "agtbrch_1" } } + }, + { "config": "agent_configs/plain.json", "id": "agent_plain" } + ]}), + ); + dir +} + +#[test] +fn a_file_an_id_and_a_name_render_the_same_diagram() { + let dir = project(); + let by_file = cli( + dir.path(), + &[ + "agents", + "workflow", + "diagram", + "agent_configs/support.json", + "--format", + "mermaid", + ], + ); + assert!(by_file.status.success(), "{by_file:?}"); + let diagram = stdout(&by_file); + assert!(diagram.starts_with("flowchart TD\n"), "{diagram}"); + assert!(diagram.contains(r#"n_greet("Say · Greet")"#), "{diagram}"); + assert!(diagram.contains("n_start_node --> n_greet"), "{diagram}"); + + for target in ["agent_support", "Support"] { + let out = cli( + dir.path(), + &[ + "agents", "workflow", "diagram", target, "--format", "mermaid", + ], + ); + assert!(out.status.success(), "{target}: {out:?}"); + assert_eq!(stdout(&out), diagram, "{target}"); + } +} + +#[test] +fn markdown_wraps_the_diagram_in_a_mermaid_fence() { + let dir = project(); + let out = cli( + dir.path(), + &[ + "agents", + "workflow", + "diagram", + "Support", + "--format", + "mermaid-md", + ], + ); + assert!(out.status.success(), "{out:?}"); + let text = stdout(&out); + assert!(text.starts_with("```mermaid\nflowchart TD\n"), "{text}"); + assert!(text.ends_with("```\n"), "{text}"); +} + +#[test] +fn all_renders_every_agent_and_branch_with_a_workflow() { + let dir = project(); + let out = cli( + dir.path(), + &[ + "agents", + "workflow", + "diagram", + "--all", + "--format", + "mermaid-md", + ], + ); + assert!(out.status.success(), "{out:?}"); + let text = stdout(&out); + assert!(text.contains("### Support\n"), "{text}"); + assert!(text.contains("### Support (branch 'beta')\n"), "{text}"); + assert_eq!(text.matches("```mermaid").count(), 2, "{text}"); + // The agent without a workflow is skipped, and says so on stderr. + assert!(!text.contains("Plain"), "{text}"); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("Skipping 'Plain'"), "{err}"); +} + +#[test] +fn an_agent_without_a_workflow_is_an_error() { + let dir = project(); + let out = cli(dir.path(), &["agents", "workflow", "diagram", "Plain"]); + assert!(!out.status.success(), "{out:?}"); + // Errors are reported as JSON on stdout, like every other command's. + let err = stdout(&out); + assert!(err.contains("has no workflow"), "{err}"); +} + +#[test] +fn an_unknown_target_is_an_error() { + let dir = project(); + let out = cli(dir.path(), &["agents", "workflow", "diagram", "nobody"]); + assert!(!out.status.success(), "{out:?}"); + // Errors are reported as JSON on stdout, like every other command's. + let err = stdout(&out); + assert!(err.contains("'nobody'"), "{err}"); +} + +#[test] +fn a_target_or_all_is_required() { + let dir = project(); + let out = cli(dir.path(), &["agents", "workflow", "diagram"]); + assert!(!out.status.success(), "{out:?}"); +} + +#[test] +fn diagram_draws_the_workflow_as_text() { + let dir = project(); + let out = cli(dir.path(), &["agents", "workflow", "diagram", "Support"]); + assert!(out.status.success(), "{out:?}"); + let text = stdout(&out); + // Box-drawing, not Mermaid source. + assert!(!text.contains("flowchart"), "{text}"); + assert!(text.contains("│ Start │"), "{text}"); + assert!(text.contains("Greet"), "{text}"); + // Not a terminal (and NO_COLOR is set), so no ANSI escapes. + assert!(!text.contains('\u{1b}'), "{text}"); +} + +#[test] +fn diagram_all_draws_each_agent_under_a_heading() { + let dir = project(); + let out = cli(dir.path(), &["agents", "workflow", "diagram", "--all"]); + assert!(out.status.success(), "{out:?}"); + let text = stdout(&out); + assert!( + text.contains("── Support (agent_configs/support.json)"), + "{text}" + ); + assert!( + text.contains("── Support (branch 'beta') (agent_configs/support.beta.json)"), + "{text}" + ); +} + +#[test] +fn an_unknown_format_is_an_error() { + let dir = project(); + let out = cli( + dir.path(), + &[ + "agents", "workflow", "diagram", "Support", "--format", "svg", + ], + ); + assert!(!out.status.success(), "{out:?}"); +}