use std::collections::HashMap;
use std::sync::Arc;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::knowledge::Catalog;
use crate::server::JsonRpcError;
use crate::telemetry::Telemetry;
pub fn list(catalog: &Arc<Catalog>) -> Vec<Value> {
catalog
.prompts()
.map(|p| {
json!({
"name": p.name,
"description": p.summary,
"arguments": p.arguments,
})
})
.collect()
}
pub fn dispatch_get(
params: Value,
catalog: &Arc<Catalog>,
telemetry: &Arc<Telemetry>,
) -> Result<Value, JsonRpcError> {
let req: GetParams = serde_json::from_value(params)
.map_err(|e| JsonRpcError::invalid_params(format!("prompts/get params: {e}")))?;
let prompt = catalog.prompt(&req.name).ok_or_else(|| JsonRpcError {
code: -32602,
message: format!(
"unknown prompt `{}` — call prompts/list to see the available names",
req.name
),
data: None,
})?;
let args = req.arguments.unwrap_or_default();
for declared in &prompt.arguments {
if declared.required {
let v = args.get(&declared.name);
let is_empty = v.map(|s| s.trim().is_empty()).unwrap_or(true);
if is_empty {
telemetry.record_prompt_get(&req.name, true);
return Err(JsonRpcError::invalid_params(format!(
"prompts/get `{}`: required argument `{}` is missing or empty",
req.name, declared.name
)));
}
}
}
telemetry.record_prompt_get(&req.name, false);
let rendered = render(&prompt.body, &args);
Ok(json!({
"description": prompt.title,
"messages": [
{
"role": "user",
"content": { "type": "text", "text": rendered }
}
]
}))
}
fn render(body: &str, args: &HashMap<String, String>) -> String {
let mut out = String::with_capacity(body.len() + 64);
let mut rest = body;
while let Some(start) = rest.find("{{") {
out.push_str(&rest[..start]);
let after_open = &rest[start + 2..];
let Some(end) = after_open.find("}}") else {
out.push_str(&rest[start..]);
return out;
};
let name = after_open[..end].trim();
let value = args
.get(name)
.map(|s| s.as_str())
.filter(|s| !s.trim().is_empty())
.unwrap_or("(unspecified)");
out.push_str(value);
rest = &after_open[end + 2..];
}
out.push_str(rest);
out
}
#[derive(Debug, Deserialize)]
struct GetParams {
name: String,
#[serde(default)]
arguments: Option<HashMap<String, String>>,
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn embedded() -> Arc<Catalog> {
Arc::new(Catalog::load_embedded().expect("embedded corpus must load"))
}
fn tel() -> Arc<Telemetry> {
Arc::new(Telemetry::new(crate::telemetry::TelemetryConfig {
jsonl_sink: None,
deployment_id: "".into(),
max_samples: 1000,
}))
}
#[test]
fn render_substitutes_known_arg_placeholders() {
let mut args = HashMap::new();
args.insert("intent".to_string(), "summarise a patient record".to_string());
let out = render("You will: {{intent}}.", &args);
assert_eq!(out, "You will: summarise a patient record.");
}
#[test]
fn render_substitutes_unspecified_when_arg_missing() {
let args = HashMap::new();
let out = render("Domain: {{domain}}.", &args);
assert_eq!(out, "Domain: (unspecified).");
}
#[test]
fn render_substitutes_unspecified_when_arg_is_empty_string() {
let mut args = HashMap::new();
args.insert("streaming".to_string(), "".to_string());
let out = render("Streaming: {{streaming}}.", &args);
assert_eq!(out, "Streaming: (unspecified).");
}
#[test]
fn render_tolerates_unbalanced_braces() {
let args = HashMap::new();
let out = render("trailing {{ open", &args);
assert_eq!(out, "trailing {{ open");
}
#[test]
fn render_handles_multiple_occurrences_of_same_arg() {
let mut args = HashMap::new();
args.insert("x".to_string(), "yes".to_string());
let out = render("a={{x}} b={{x}} c={{x}}", &args);
assert_eq!(out, "a=yes b=yes c=yes");
}
#[test]
fn render_trims_whitespace_inside_placeholder_braces() {
let mut args = HashMap::new();
args.insert("intent".to_string(), "X".to_string());
let out = render("You will: {{ intent }}.", &args);
assert_eq!(out, "You will: X.");
}
#[test]
fn list_emits_one_entry_per_embedded_prompt() {
let cat = embedded();
let entries = list(&cat);
assert!(entries.len() >= 3, "expected ≥ 3 prompts, saw {}", entries.len());
let names: Vec<&str> = entries
.iter()
.map(|e| e["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"flow_design"));
assert!(names.contains(&"shield_design"));
assert!(names.contains(&"session_design"));
for e in &entries {
assert!(e["name"].is_string());
assert!(e["description"].is_string());
assert!(e["arguments"].is_array());
}
}
#[test]
fn dispatch_get_renders_flow_design_with_arguments() {
let cat = embedded();
let v = dispatch_get(
json!({
"name": "flow_design",
"arguments": {
"intent": "summarise a patient record",
"domain": "healthcare",
"streaming": "no",
"compliance": "HIPAA, GDPR"
}
}),
&cat, &tel(),
)
.unwrap();
assert_eq!(v["description"], "Design an AXON flow");
let msgs = v["messages"].as_array().unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0]["role"], "user");
let text = msgs[0]["content"]["text"].as_str().unwrap();
assert!(text.contains("summarise a patient record"));
assert!(text.contains("**healthcare**"));
assert!(text.contains("**no**"));
assert!(text.contains("**HIPAA, GDPR**"));
assert!(!text.contains("{{intent}}"));
}
#[test]
fn dispatch_get_renders_unspecified_for_missing_optional_arguments() {
let cat = embedded();
let v = dispatch_get(
json!({
"name": "flow_design",
"arguments": { "intent": "ship a thing" }
}),
&cat, &tel(),
)
.unwrap();
let text = v["messages"][0]["content"]["text"].as_str().unwrap();
assert!(text.contains("ship a thing"));
assert!(text.contains("**(unspecified)**"));
}
#[test]
fn dispatch_get_rejects_missing_required_argument() {
let cat = embedded();
let err = dispatch_get(
json!({ "name": "flow_design", "arguments": {} }),
&cat, &tel(),
)
.expect_err("missing required `intent` must reject");
assert_eq!(err.code, -32602);
assert!(err.message.contains("required argument `intent`"));
}
#[test]
fn dispatch_get_rejects_empty_required_argument() {
let cat = embedded();
let err = dispatch_get(
json!({ "name": "shield_design", "arguments": { "purpose": " " } }),
&cat, &tel(),
)
.expect_err("whitespace-only required argument must be treated as missing");
assert_eq!(err.code, -32602);
assert!(err.message.contains("required argument `purpose`"));
}
#[test]
fn dispatch_get_rejects_unknown_prompt_name() {
let cat = embedded();
let err = dispatch_get(
json!({ "name": "does_not_exist", "arguments": {} }),
&cat, &tel(),
)
.expect_err("unknown prompt must surface a structured error");
assert_eq!(err.code, -32602);
assert!(err.message.contains("unknown prompt"));
assert!(err.message.contains("prompts/list"));
}
#[test]
fn dispatch_get_accepts_session_design_with_full_argument_set() {
let cat = embedded();
let v = dispatch_get(
json!({
"name": "session_design",
"arguments": {
"intent": "turn-taking chat with cancellation",
"parties": "2",
"backpressure": "8",
"reconnect": "yes"
}
}),
&cat, &tel(),
)
.unwrap();
let text = v["messages"][0]["content"]["text"].as_str().unwrap();
assert!(text.contains("turn-taking chat with cancellation"));
assert!(text.contains("Parties: **2**"));
assert!(text.contains("Credit window: **8**"));
assert!(text.contains("Reconnect-on-disconnect: **yes**"));
}
}