The hive reads the agent-archetype key when it provisions and rebuilds an agent and keeps it in swarm-agent-archetypes.json. For an archetype agent the meta flake has no agent-<name> input and mkAgent imports hyperhive.hyperhiveArchetypes.<name>.module; provisioning, rebuilds, the meta-input cascade, a deploy's rev and the startup migration skip the config repos. The harness gets HIVE_ARCHETYPE and the system prompt says the agent has no config repo. The create form sends the picked archetype. Adds the android-dev built-in. Refs #5386
578 lines
23 KiB
Rust
578 lines
23 KiB
Rust
//! System-prompt renderer. Single `prompts/system.md` with
|
|
//! HTML-comment markers gating role-specific and config-repo-specific
|
|
//! blocks; this module assembles the final prompt (always "agent" role — there is only one
|
|
//! role). Marker grammar + placeholder substitution rules in
|
|
//! `docs/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`).
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::Duration;
|
|
|
|
use anyhow::{Context, Result};
|
|
use swarm_queue_client::operator_profiles::{self, OperatorProfile};
|
|
|
|
/// How long [`write_system_prompt`] waits for the operator list before
|
|
/// rendering without it.
|
|
const OPERATORS_TIMEOUT: Duration = Duration::from_secs(5);
|
|
|
|
/// Assemble the system prompt for a given label + optional hive /
|
|
/// swarm display names + the swarm's operators. Pure function — no I/O. Splits out from
|
|
/// [`write_system_prompt`] so the marker logic + substitution is unit-testable
|
|
/// in isolation. The caller supplies the template body so tests can pass an
|
|
/// inline fixture and production reads it once at harness startup via
|
|
/// [`hive_sh4re::assets::prompt_template`]
|
|
/// (`$HIVE_ASSETS_DIR/prompts/system.md`). Substitution placeholders +
|
|
/// marker grammar documented in
|
|
/// `docs/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`).
|
|
#[must_use]
|
|
pub fn render(
|
|
template: &str,
|
|
label: &str,
|
|
hive_name: Option<&str>,
|
|
swarm_name: Option<&str>,
|
|
docs_dir: Option<&str>,
|
|
operators: &[OperatorProfile],
|
|
) -> String {
|
|
let body = filter_blocks(template, "role", "agent");
|
|
let qualified = crate::identity::qualify(label);
|
|
let hive_identity = hive_name
|
|
.filter(|n| !n.is_empty())
|
|
.map_or(String::new(), |n| format!(" on hive `{n}`"));
|
|
let swarm_identity = swarm_name
|
|
.filter(|n| !n.is_empty())
|
|
.map_or(String::new(), |n| format!(" in swarm `{n}`"));
|
|
let rendered = body
|
|
.replace("{label}", label)
|
|
.replace("{qualified_label}", &qualified)
|
|
.replace("{hive_identity}", &hive_identity)
|
|
.replace("{swarm_identity}", &swarm_identity)
|
|
.replace("{operators}", &operators_line(operators));
|
|
// When the reference docs are mounted in-container (`services.hyperhive.agent.docs.enable`
|
|
// wires `HIVE_DOCS_DIR` + `claude --add-dir`), append a single pointer
|
|
// sentence so the agent knows they exist. Additive: it doesn't replace the
|
|
// agent's own memory/project instructions. Absent env → no change.
|
|
match docs_dir.filter(|d| !d.is_empty()) {
|
|
Some(dir) => format!(
|
|
"{rendered}\n\nThe hyperhive reference docs (the repo `docs/` tree \
|
|
describing this live system) are mounted read-only at `{dir}` — read \
|
|
them (start at the index, then the topic file for the area you're \
|
|
touching) rather than guessing.\n"
|
|
),
|
|
None => rendered,
|
|
}
|
|
}
|
|
|
|
/// The `{operators}` substitution: a paragraph naming each operator, with their
|
|
/// pronouns where set, or nothing when there are none.
|
|
fn operators_line(operators: &[OperatorProfile]) -> String {
|
|
if operators.is_empty() {
|
|
return String::new();
|
|
}
|
|
let names: Vec<String> = operators
|
|
.iter()
|
|
.map(|o| match &o.pronouns {
|
|
Some(pronouns) => format!("{} ({pronouns})", o.name),
|
|
None => o.name.clone(),
|
|
})
|
|
.collect();
|
|
format!("\n\nOperators: {}.", names.join(", "))
|
|
}
|
|
|
|
/// The swarm's operators, or none when the operator-profile bucket cannot be
|
|
/// read within [`OPERATORS_TIMEOUT`]: the prompt then reads as it does with no
|
|
/// operator list.
|
|
async fn operators() -> Vec<OperatorProfile> {
|
|
within_timeout(OPERATORS_TIMEOUT, read_operators()).await
|
|
}
|
|
|
|
/// One read of the operator list over this agent's queue connection.
|
|
async fn read_operators()
|
|
-> std::result::Result<Vec<OperatorProfile>, Box<dyn std::error::Error + Send + Sync>> {
|
|
let client = crate::swarm_queue::client().await;
|
|
swarm_queue_client::ensure_connected(&client)?;
|
|
match operator_profiles::read(&client).await? {
|
|
operator_profiles::Read::Current(operators) => Ok(operators),
|
|
operator_profiles::Read::NoBucket => {
|
|
Err("the operator-profiles bucket does not answer".into())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `read`'s profiles, or none when it fails or outlasts `timeout`.
|
|
async fn within_timeout<E: std::fmt::Display>(
|
|
timeout: Duration,
|
|
read: impl Future<Output = std::result::Result<Vec<OperatorProfile>, E>>,
|
|
) -> Vec<OperatorProfile> {
|
|
match tokio::time::timeout(timeout, read).await {
|
|
Ok(Ok(operators)) => operators,
|
|
Ok(Err(e)) => {
|
|
tracing::warn!(error = %e, "prompt: operator profiles unreadable; rendering without them");
|
|
Vec::new()
|
|
}
|
|
Err(_) => {
|
|
tracing::warn!(
|
|
timeout_s = timeout.as_secs(),
|
|
"prompt: no operator profiles within the timeout; rendering without them"
|
|
);
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Walk `template` line-by-line, stripping `<!-- KIND:X -->` /
|
|
/// `<!-- /KIND:X -->` marker lines and suppressing the lines inside a block
|
|
/// unless `X == target`. A close marker ends the current block. Markers of
|
|
/// any other kind are left in place, for the pass that filters that kind.
|
|
/// Two kinds exist: `role` (always `agent`; the grammar stays wired for a
|
|
/// future manager / multi-role prompt) and `config` ([`config_blocks`]).
|
|
fn filter_blocks(template: &str, kind: &str, target: &str) -> String {
|
|
let mut out = String::with_capacity(template.len());
|
|
// None = outside any block; Some(value) = inside a block of this kind.
|
|
let mut active: Option<&str> = None;
|
|
for line in template.lines() {
|
|
let trimmed = line.trim();
|
|
if let Some(value) = parse_open_marker(trimmed, kind) {
|
|
active = Some(value);
|
|
continue;
|
|
}
|
|
if parse_close_marker(trimmed, kind).is_some() {
|
|
active = None;
|
|
continue;
|
|
}
|
|
if active.is_none_or(|value| value == target) {
|
|
out.push_str(line);
|
|
out.push('\n');
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// `template` with its `config:repo` blocks for a custom agent, or its
|
|
/// `config:archetype` blocks and `{archetype}` filled in for an agent built
|
|
/// from `archetype`. An empty name is a custom agent.
|
|
fn config_blocks(template: &str, archetype: Option<&str>) -> String {
|
|
match archetype.filter(|a| !a.is_empty()) {
|
|
Some(name) => filter_blocks(template, "config", "archetype").replace("{archetype}", name),
|
|
None => filter_blocks(template, "config", "repo"),
|
|
}
|
|
}
|
|
|
|
/// `<!-- role:agent -->` with `kind = "role"` → `Some("agent")`. Anything
|
|
/// else returns None. Whitespace inside the marker is tolerated so a future
|
|
/// author's `<!--role:foo-->` (no spaces) still parses; the dashboard
|
|
/// markdown renderer is equally lenient. Close tags (`/role:...`) can't
|
|
/// accidentally match — the `strip_prefix(kind)` rejects the leading slash
|
|
/// before we'd ever see it.
|
|
fn parse_open_marker<'a>(line: &'a str, kind: &str) -> Option<&'a str> {
|
|
let inside = line.strip_prefix("<!--")?.strip_suffix("-->")?.trim();
|
|
let value = inside.strip_prefix(kind)?.strip_prefix(':')?.trim();
|
|
Some(value)
|
|
}
|
|
|
|
/// `<!-- /role:agent -->` with `kind = "role"` → `Some("agent")`. Mirror of
|
|
/// [`parse_open_marker`] for the closing tag.
|
|
fn parse_close_marker<'a>(line: &'a str, kind: &str) -> Option<&'a str> {
|
|
let inside = line.strip_prefix("<!--")?.strip_suffix("-->")?.trim();
|
|
inside
|
|
.strip_prefix('/')?
|
|
.strip_prefix(kind)?
|
|
.strip_prefix(':')
|
|
.map(str::trim)
|
|
}
|
|
|
|
/// Write the assembled prompt to a stable path next to the harness
|
|
/// socket and return the path. The Rust harness passes this path to
|
|
/// `claude --system-prompt-file` so the per-turn prompts only carry
|
|
/// the role + tools instructions in the system slot; per-turn prompts
|
|
/// become much smaller (just the wake-message body).
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if the system prompt file cannot be written.
|
|
pub async fn write_system_prompt(_socket: &Path, label: &str) -> Result<PathBuf> {
|
|
let parent = crate::paths::config_dir();
|
|
tokio::fs::create_dir_all(&parent).await.ok();
|
|
let template_path = hive_sh4re::assets::prompt_template();
|
|
let template = tokio::fs::read_to_string(&template_path)
|
|
.await
|
|
.with_context(|| {
|
|
format!(
|
|
"read claude system prompt template from {}",
|
|
template_path.display()
|
|
)
|
|
})?;
|
|
// Surface hive + swarm display names in the prompt opener when
|
|
// configured. Both `None` falls back to the non-identity wording
|
|
// verbatim (single-hive deployments see no diff).
|
|
let hive_name = crate::identity::hive_name();
|
|
let swarm_name = crate::identity::swarm_name();
|
|
// `services.hyperhive.agent.docs.enable` sets HIVE_DOCS_DIR (and the harness passes it to
|
|
// claude via `--add-dir`); when present, render() appends a pointer line.
|
|
let docs_dir = std::env::var("HIVE_DOCS_DIR")
|
|
.ok()
|
|
.filter(|d| !d.is_empty());
|
|
// The meta flake sets HIVE_ARCHETYPE on an agent built from an archetype,
|
|
// which has no config repo of its own.
|
|
let template = config_blocks(&template, std::env::var("HIVE_ARCHETYPE").ok().as_deref());
|
|
let body = render(
|
|
&template,
|
|
label,
|
|
hive_name.as_deref(),
|
|
swarm_name.as_deref(),
|
|
docs_dir.as_deref(),
|
|
&operators().await,
|
|
);
|
|
let path = parent.join("claude-system-prompt.md");
|
|
tokio::fs::write(&path, body).await?;
|
|
tracing::info!(path = %path.display(), "wrote claude system prompt");
|
|
Ok(path)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::sync::LazyLock;
|
|
|
|
// The production template lives at `$HIVE_ASSETS_DIR/prompts/system.md`
|
|
// and is loaded at runtime. The unit tests below want to assert
|
|
// against the actual production wording (so the renderer + tool
|
|
// surface stay honest), so they resolve the same path at test
|
|
// runtime via two fallbacks:
|
|
// 1. `$HIVE_ASSETS_DIR/prompts/system.md` — the runtime contract
|
|
// production uses. The flake's `checks.cargo-test` derivation
|
|
// sets this to the `hyperhive-assets` output so `cargo test`
|
|
// inside the nix sandbox finds the file without needing
|
|
// `prompts/` in the cargo source tree. `packages.default`
|
|
// explicitly does NOT carry the assets dep, so a prompt edit
|
|
// doesn't bust the binary derivation — only this test check.
|
|
// 2. `env!("CARGO_MANIFEST_DIR")/prompts/system.md` — for plain
|
|
// `cargo test --workspace` from a checked-out repo where the
|
|
// env var isn't set; `env!` is a compile-time string lookup,
|
|
// no file open at compile, so this still doesn't pull
|
|
// `prompts/` into the build hash.
|
|
// The combined effect is that the flake's `cleanSrc` no longer
|
|
// unions `./hive-agent/prompts` — tweaks to system.md don't bust
|
|
// the cargo cache anymore.
|
|
static PRODUCTION_TEMPLATE: LazyLock<String> = LazyLock::new(|| {
|
|
let path = match std::env::var("HIVE_ASSETS_DIR") {
|
|
Ok(v) if !v.is_empty() => format!("{v}/prompts/system.md"),
|
|
_ => concat!(env!("CARGO_MANIFEST_DIR"), "/prompts/system.md").to_owned(),
|
|
};
|
|
std::fs::read_to_string(&path)
|
|
.unwrap_or_else(|e| panic!("read production prompt template at {path}: {e}"))
|
|
});
|
|
|
|
const SAMPLE: &str = "\
|
|
shared opener
|
|
<!-- role:agent -->
|
|
agent-only line
|
|
<!-- /role:agent -->
|
|
<!-- role:manager -->
|
|
manager-only line
|
|
<!-- /role:manager -->
|
|
shared closer
|
|
";
|
|
|
|
#[test]
|
|
fn filter_keeps_shared_and_target_role() {
|
|
let agent = filter_blocks(SAMPLE, "role", "agent");
|
|
assert!(agent.contains("shared opener"));
|
|
assert!(agent.contains("agent-only line"));
|
|
assert!(!agent.contains("manager-only line"));
|
|
assert!(agent.contains("shared closer"));
|
|
// Marker lines themselves are stripped — no `<!--` left behind.
|
|
assert!(!agent.contains("<!--"));
|
|
}
|
|
|
|
#[test]
|
|
fn filter_for_manager_picks_manager_block() {
|
|
let manager = filter_blocks(SAMPLE, "role", "manager");
|
|
assert!(manager.contains("shared opener"));
|
|
assert!(!manager.contains("agent-only line"));
|
|
assert!(manager.contains("manager-only line"));
|
|
assert!(manager.contains("shared closer"));
|
|
assert!(!manager.contains("<!--"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_open_marker_handles_whitespace_variants() {
|
|
assert_eq!(
|
|
parse_open_marker("<!-- role:agent -->", "role"),
|
|
Some("agent")
|
|
);
|
|
assert_eq!(
|
|
parse_open_marker("<!--role:agent-->", "role"),
|
|
Some("agent")
|
|
);
|
|
assert_eq!(
|
|
parse_open_marker("<!-- role:manager -->", "role"),
|
|
Some("manager")
|
|
);
|
|
// Close tags must NOT match open-tag parser.
|
|
assert_eq!(parse_open_marker("<!-- /role:agent -->", "role"), None);
|
|
// Non-markers pass through (return None).
|
|
assert_eq!(parse_open_marker("just text", "role"), None);
|
|
assert_eq!(parse_open_marker("<!-- not a role -->", "role"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_close_marker_handles_whitespace_variants() {
|
|
assert_eq!(
|
|
parse_close_marker("<!-- /role:agent -->", "role"),
|
|
Some("agent")
|
|
);
|
|
assert_eq!(
|
|
parse_close_marker("<!--/role:manager-->", "role"),
|
|
Some("manager")
|
|
);
|
|
// Open tags must NOT match close-tag parser.
|
|
assert_eq!(parse_close_marker("<!-- role:agent -->", "role"), None);
|
|
assert_eq!(parse_close_marker("just text", "role"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn render_substitutes_label_and_names_no_operator_pronouns() {
|
|
// Real template's first agent line.
|
|
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None, &[]);
|
|
assert!(rendered.contains("hyperhive agent `alice`"));
|
|
assert!(rendered.contains("say \"the operator\""));
|
|
assert!(!rendered.contains("pronoun"));
|
|
assert!(!rendered.contains("{label}"));
|
|
assert!(!rendered.contains("{operator_pronouns}"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_no_role_markers_in_output() {
|
|
// No raw role markers should survive into the rendered prompt.
|
|
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None, &[]);
|
|
assert!(!rendered.contains("<!-- role:"));
|
|
assert!(!rendered.contains("<!-- /role:"));
|
|
// Shared tools appear.
|
|
assert!(rendered.contains("mcp__hyperhive__recv"));
|
|
assert!(rendered.contains("mcp__hyperhive__send"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_uses_agent_opener() {
|
|
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None, &[]);
|
|
assert!(rendered.starts_with("You are hyperhive agent"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_points_agents_at_the_work_dir() {
|
|
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None, &[]);
|
|
assert!(rendered.contains("`/agents/alice/work/`"), "{rendered}");
|
|
}
|
|
|
|
const CONFIG_FIXTURE: &str = "\
|
|
shared
|
|
<!-- config:repo -->
|
|
open a PR on agent-configs
|
|
<!-- /config:repo -->
|
|
<!-- config:archetype -->
|
|
built from `{archetype}`, no config repo
|
|
<!-- /config:archetype -->
|
|
";
|
|
|
|
/// A custom agent gets the config-repo lines, an archetype agent the
|
|
/// archetype lines with its name, and an empty name is a custom agent.
|
|
#[test]
|
|
fn config_blocks_pick_the_block_by_archetype() {
|
|
for custom in [None, Some("")] {
|
|
let picked = config_blocks(CONFIG_FIXTURE, custom);
|
|
assert!(picked.contains("open a PR on agent-configs"), "{picked}");
|
|
assert!(!picked.contains("no config repo"), "{picked}");
|
|
}
|
|
let picked = config_blocks(CONFIG_FIXTURE, Some("android-dev"));
|
|
assert!(picked.contains("built from `android-dev`, no config repo"));
|
|
assert!(!picked.contains("agent-configs"), "{picked}");
|
|
assert!(picked.contains("shared"));
|
|
assert!(!picked.contains("<!--"), "{picked}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_production_prompt_keeps_no_config_markers() {
|
|
for archetype in [None, Some("reviewer")] {
|
|
let rendered = render(
|
|
&config_blocks(&PRODUCTION_TEMPLATE, archetype),
|
|
"alice",
|
|
None,
|
|
None,
|
|
None,
|
|
&[],
|
|
);
|
|
assert!(!rendered.contains("<!-- config:"), "{rendered}");
|
|
assert!(!rendered.contains("<!-- /config:"), "{rendered}");
|
|
assert!(!rendered.contains("{archetype}"), "{rendered}");
|
|
}
|
|
}
|
|
|
|
// Inline fixture for the {hive_identity} / {swarm_identity}
|
|
// placeholders. Cargo's `cargo test` resolves `PRODUCTION_TEMPLATE`
|
|
// against `$HIVE_ASSETS_DIR/prompts/system.md`, which the flake
|
|
// builds at derivation time — a fresh placeholder added on the
|
|
// source side isn't in the shipped asset until the flake rebuilds,
|
|
// so PRODUCTION_TEMPLATE can't be the fixture here. The string
|
|
// below carries just enough of the opener shape to exercise the
|
|
// substitution logic; nothing here depends on the production
|
|
// template's flavor markers.
|
|
const IDENTITY_FIXTURE: &str = "\
|
|
You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system.
|
|
";
|
|
|
|
#[test]
|
|
fn render_substitutes_hive_identity_when_set() {
|
|
let rendered = render(IDENTITY_FIXTURE, "alice", Some("pr1ma"), None, None, &[]);
|
|
assert!(rendered.contains("on hive `pr1ma`"), "{rendered}");
|
|
// swarm clause stays absent when only hive is set.
|
|
assert!(!rendered.contains("in swarm"));
|
|
// No raw placeholder leaks.
|
|
assert!(!rendered.contains("{hive_identity}"));
|
|
assert!(!rendered.contains("{swarm_identity}"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_substitutes_swarm_identity_when_set() {
|
|
let rendered = render(
|
|
IDENTITY_FIXTURE,
|
|
"ruth",
|
|
None,
|
|
Some("constellat1on"),
|
|
None,
|
|
&[],
|
|
);
|
|
assert!(rendered.contains("in swarm `constellat1on`"));
|
|
assert!(!rendered.contains("on hive"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_substitutes_both_when_both_set() {
|
|
let rendered = render(
|
|
IDENTITY_FIXTURE,
|
|
"iris",
|
|
Some("pr1ma"),
|
|
Some("constellat1on"),
|
|
None,
|
|
&[],
|
|
);
|
|
// Order: hive then swarm, both inline before "in a multi-agent
|
|
// system" — keeps the opener grammar intact.
|
|
assert!(rendered.contains("on hive `pr1ma` in swarm `constellat1on`"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_omits_identity_when_unset() {
|
|
// None / None must round-trip the non-identity opener verbatim
|
|
// — single-hive deployments see zero diff.
|
|
let rendered = render(IDENTITY_FIXTURE, "alice", None, None, None, &[]);
|
|
assert!(!rendered.contains("on hive"));
|
|
assert!(!rendered.contains("in swarm"));
|
|
assert!(!rendered.contains("{hive_identity}"));
|
|
assert!(!rendered.contains("{swarm_identity}"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_treats_empty_identity_as_none() {
|
|
// Defensive: an env var set to empty string round-trips
|
|
// through `identity::hive_name()` as None (the accessor
|
|
// filters empty), but `render` should still no-op on a
|
|
// direct `Some("")` from a test fixture or a future caller.
|
|
let rendered = render(IDENTITY_FIXTURE, "alice", Some(""), Some(""), None, &[]);
|
|
assert!(!rendered.contains("on hive"));
|
|
assert!(!rendered.contains("in swarm"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_appends_docs_pointer_when_docs_dir_set() {
|
|
let rendered = render(
|
|
&PRODUCTION_TEMPLATE,
|
|
"alice",
|
|
None,
|
|
None,
|
|
Some("/run/hive-docs"),
|
|
&[],
|
|
);
|
|
// Key on a phrase unique to the pointer sentence — "reference docs"
|
|
// alone also appears in the /knowledge blurb of the base template.
|
|
assert!(
|
|
rendered.contains("mounted read-only at `/run/hive-docs`"),
|
|
"expected docs pointer sentence with the dir:\n{rendered}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_no_docs_pointer_when_docs_dir_absent_or_empty() {
|
|
for docs in [None, Some("")] {
|
|
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, docs, &[]);
|
|
assert!(
|
|
!rendered.contains("mounted read-only at"),
|
|
"docs pointer must not appear for {docs:?}:\n{rendered}"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn operator(name: &str, pronouns: Option<&str>) -> OperatorProfile {
|
|
OperatorProfile {
|
|
name: name.to_owned(),
|
|
pronouns: pronouns.map(str::to_owned),
|
|
}
|
|
}
|
|
|
|
const OPERATORS_FIXTURE: &str = "say \"the operator\".{operators}\n\nnext paragraph\n";
|
|
|
|
#[test]
|
|
fn render_lists_operators_with_their_pronouns() {
|
|
let operators = [operator("mara", Some("she/her")), operator("sol", None)];
|
|
let rendered = render(OPERATORS_FIXTURE, "alice", None, None, None, &operators);
|
|
assert_eq!(
|
|
rendered,
|
|
"say \"the operator\".\n\nOperators: mara (she/her), sol.\n\nnext paragraph\n"
|
|
);
|
|
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None, &operators);
|
|
assert!(
|
|
rendered.contains("Operators: mara (she/her), sol."),
|
|
"{rendered}"
|
|
);
|
|
}
|
|
|
|
/// No operators renders the template as it reads without the placeholder,
|
|
/// byte for byte.
|
|
#[test]
|
|
fn render_with_no_operators_is_the_template_without_the_placeholder() {
|
|
let rendered = render(OPERATORS_FIXTURE, "alice", None, None, None, &[]);
|
|
assert_eq!(rendered, "say \"the operator\".\n\nnext paragraph\n");
|
|
assert!(PRODUCTION_TEMPLATE.contains("{operators}"));
|
|
assert_eq!(
|
|
render(&PRODUCTION_TEMPLATE, "alice", None, None, None, &[]),
|
|
render(
|
|
&PRODUCTION_TEMPLATE.replace("{operators}", ""),
|
|
"alice",
|
|
None,
|
|
None,
|
|
None,
|
|
&[]
|
|
)
|
|
);
|
|
}
|
|
|
|
/// An unreachable or failing bucket reads as no operators, so the prompt
|
|
/// renders as it does with none.
|
|
#[tokio::test(start_paused = true)]
|
|
async fn an_unread_operator_list_is_empty() {
|
|
let hangs = std::future::pending::<std::result::Result<Vec<OperatorProfile>, String>>();
|
|
let start = tokio::time::Instant::now();
|
|
assert_eq!(within_timeout(OPERATORS_TIMEOUT, hangs).await, []);
|
|
assert!(
|
|
start.elapsed() < OPERATORS_TIMEOUT * 2,
|
|
"{:?}",
|
|
start.elapsed()
|
|
);
|
|
let fails = async { Err::<Vec<OperatorProfile>, _>("no bucket".to_owned()) };
|
|
assert_eq!(within_timeout(OPERATORS_TIMEOUT, fails).await, []);
|
|
// Control: a read that answers in time is what comes back.
|
|
let answers = async { Ok::<_, String>(vec![operator("mara", Some("she/her"))]) };
|
|
assert_eq!(
|
|
within_timeout(OPERATORS_TIMEOUT, answers).await,
|
|
[operator("mara", Some("she/her"))]
|
|
);
|
|
}
|
|
}
|