use super::display::term_cols;
use super::{render_markdown, RenderOpts};
#[derive(Clone, Copy, PartialEq, Eq)]
enum Status {
Ok,
Degraded,
Error,
Pending,
}
impl Status {
fn from_report_key(key: &str) -> Option<Self> {
match key {
"ok" => Some(Self::Ok),
"degraded" => Some(Self::Degraded),
"error" => Some(Self::Error),
_ => None,
}
}
fn from_section_key(key: &str) -> Option<Self> {
match key {
"pending" => Some(Self::Pending),
other => Self::from_report_key(other),
}
}
fn as_str(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Degraded => "degraded",
Self::Error => "error",
Self::Pending => "pending",
}
}
fn marker(self) -> &'static str {
match self {
Self::Ok => "",
Self::Degraded => "⚠",
Self::Error => "✖",
Self::Pending => "…",
}
}
}
const RENDER_REPORT_DESCRIPTION: &str =
"Present collected findings to the user as a rendered Markdown document \
(dashboards, triage results, status roll-ups, summaries). Use this when \
your deliverable is information TO READ, not a status line about your \
work — the moment you have gathered what was asked, render it. Full GFM \
renders: headings, tables, task lists, code fences, blockquotes. A failed \
data source is NOT a reason to abort the report: render what you have and \
mark the affected section `degraded` or `error` (or `pending` if it is \
still resolving) so the human sees the partial result plus exactly what is \
missing. Prefer ONE report with `sections` over many small calls. The tool \
result you receive back is a short ack, not the rendered text — the \
document has already been shown to the user, so do not repeat it in your \
reply. If your reading changes after rendering, explicitly state \
\"Correction: [one-line reason] — supersedes the report above\" before the \
corrected version. Reporting findings does not complete an unfinished execution request: \
continue the requested work with tools. When the task is complete, keep the \
final reply brief and include only new information.";
const REPORT_DELIVERY_GUIDANCE: &str =
"The report is already visible to the user. Do not repeat its contents in \
your reply. If the report was wrong or you have changed your reading of \
the request, say so explicitly — start your reply with a statement such \
as \"Correction: [one-line reason] — supersedes the report above\" — and \
then give the corrected version. A silent second copy of the table is not \
a correction. Continue any unfinished requested work with tools; rendering \
a report is not completion of that work. When the task is complete, keep \
the final reply brief and include only new information.";
pub fn render_report_tool_definition() -> serde_json::Value {
serde_json::json!({
"type": "function",
"function": {
"name": "render_report",
"description": RENDER_REPORT_DESCRIPTION,
"parameters": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Report title — rendered as the top-level heading."
},
"status": {
"type": "string",
"enum": ["ok", "degraded", "error"],
"description": "Overall status. 'degraded' = some sections failed \
but the report is still useful; 'error' = the report \
is materially incomplete. Defaults to 'ok'."
},
"body": {
"type": "string",
"description": "Optional Markdown summary/intro rendered under the \
title, before any sections. Use alone for a simple \
one-block report."
},
"sections": {
"type": "array",
"description": "Optional detail sections, each a sub-heading. Prefer \
this over many separate render_report calls.",
"items": {
"type": "object",
"properties": {
"heading": {
"type": "string",
"description": "Section sub-heading."
},
"status": {
"type": "string",
"enum": ["ok", "degraded", "error", "pending"],
"description": "Per-section status marker. Use \
'degraded'/'error' for a failed data \
source, 'pending' for a slice still \
resolving. Defaults to 'ok'."
},
"body": {
"type": "string",
"description": "Section content as Markdown."
}
},
"required": ["heading", "body"]
}
}
},
"required": ["title"]
}
}
})
}
fn compose_document(args: &serde_json::Value) -> Result<(String, String), String> {
use std::fmt::Write as _;
let title = args["title"].as_str().unwrap_or("").trim();
if title.is_empty() {
return Err("render_report requires a non-empty `title`".to_string());
}
let status = match args["status"].as_str().map(str::trim) {
None | Some("") => Status::Ok,
Some(key) => Status::from_report_key(key).ok_or_else(|| {
format!("unknown report status \"{key}\" — use \"ok\", \"degraded\", or \"error\"")
})?,
};
let mut md = String::new();
let marker = status.marker();
if marker.is_empty() {
let _ = writeln!(md, "# {title}");
} else {
let _ = writeln!(md, "# {marker} {title}");
}
match status {
Status::Degraded => {
let _ = writeln!(
md,
"\n> ⚠ Some sections are degraded — see the markers below."
);
}
Status::Error => {
let _ = writeln!(md, "\n> ✖ This report is materially incomplete.");
}
_ => {}
}
if let Some(body) = args["body"].as_str() {
let body = body.trim();
if !body.is_empty() {
let _ = writeln!(md, "\n{body}");
}
}
let mut section_count = 0usize;
if let Some(sections) = args["sections"].as_array() {
for section in sections {
let heading = section["heading"].as_str().unwrap_or("").trim();
if heading.is_empty() {
return Err("each render_report section requires a non-empty `heading`".to_string());
}
let sstatus = match section["status"].as_str().map(str::trim) {
None | Some("") => Status::Ok,
Some(key) => Status::from_section_key(key).ok_or_else(|| {
format!(
"unknown section status \"{key}\" — use \"ok\", \"degraded\", \
\"error\", or \"pending\""
)
})?,
};
let sbody = section["body"].as_str().unwrap_or("").trim();
let smarker = sstatus.marker();
if smarker.is_empty() {
let _ = writeln!(md, "\n## {heading}");
} else {
let _ = writeln!(md, "\n## {smarker} {heading}");
}
if !sbody.is_empty() {
let _ = writeln!(md, "\n{sbody}");
}
section_count += 1;
}
}
let ack = if section_count > 0 {
format!(
"report rendered: \"{title}\" — {}, {section_count} section(s)",
status.as_str()
)
} else {
format!("report rendered: \"{title}\" — {}", status.as_str())
};
Ok((md, ack))
}
pub(crate) fn execute_render_report(
args: &serde_json::Value,
color: bool,
evidence: Option<&super::capability_check::Evidence>,
plan_draft: Option<&dyn super::PlanDraftSink>,
) -> (String, Option<String>) {
match compose_document(args) {
Ok((markdown, ack)) => {
let markdown = match evidence {
Some(evidence) => super::capability_check::annotate_unsupported(markdown, evidence),
None => markdown,
};
if let Some(sink) = plan_draft {
return match sink.save_draft(markdown) {
Ok(revision) => (
format!(
"plan draft saved as revision {revision}; not yet shown to the \
operator. Finish the plan, then call exit_plan_mode."
),
None,
),
Err(error) => (format!("error: saving plan draft: {error}"), None),
};
}
let rendered = render_markdown(
&markdown,
RenderOpts {
color,
cols: term_cols(),
},
);
(format!("{ack}\n{REPORT_DELIVERY_GUIDANCE}"), Some(rendered))
}
Err(e) => (format!("error: {e}"), None),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn compose(v: serde_json::Value) -> Result<(String, String), String> {
compose_document(&v)
}
mod capability_wiring {
use super::*;
use crate::agentic::capability_check::Evidence;
const CLAIM: &str = "| converse | ✅ |\n\nVerified working end-to-end.";
fn render(evidence: Option<&Evidence>) -> String {
let (_ack, doc) = execute_render_report(
&json!({"title": "Voice stack", "body": CLAIM}),
false,
evidence,
None,
);
doc.expect("a composed report always renders a document")
}
fn events(names: &[(&str, bool)]) -> Vec<crate::ToolEvent> {
names
.iter()
.map(|(t, ok)| crate::ToolEvent::from_call(*t, &json!({}), *ok, None))
.collect()
}
#[test]
fn ordinary_verification_table_renders_without_invented_tool_names() {
let args = json!({
"title": "Refactor verification",
"sections": [{
"heading": "Verification gates",
"body": "| Gate | Result |\n|---|---|\n| cargo build --workspace | ✅ green |\n| Guard tests (16) | ✅ passed |"
}]
});
let mut evidence = Evidence::default();
evidence.record("run_command", true, Some(crate::ExecOutcome::Passed));
let (ack, doc) = execute_render_report(&args, false, Some(&evidence), None);
let (_, plain) = execute_render_report(&args, false, None, None);
assert_eq!(doc, plain, "human gate labels are ordinary report content");
assert!(ack.contains("report rendered"), "{ack}");
assert!(
!ack.contains("verified"),
"rendering is not verification: {ack}"
);
}
#[test]
fn a_claim_without_tool_evidence_is_annotated_in_the_rendered_document() {
let evidence = Evidence::default();
let doc = render(Some(&evidence));
assert!(doc.contains("capability check (#1947)"), "{doc}");
assert!(
doc.contains("no tool ran in this turn"),
"only the actual ledger fact is asserted: {doc}"
);
}
#[test]
fn successful_tool_history_renders_no_refutation() {
let evidence = Evidence::from_events(&events(&[("voice__converse", true)]));
let doc = render(Some(&evidence));
assert!(
!doc.contains("capability check"),
"the ledger establishes no contradictory fact: {doc}"
);
}
#[test]
fn no_recorder_means_no_check_rather_than_a_refutation() {
let doc = render(None);
assert!(
!doc.contains("capability check"),
"absence of a recorder must not read as absence of evidence: {doc}"
);
}
#[test]
fn the_ack_is_unchanged_whatever_the_evidence_says() {
let silent = Evidence::default();
let (annotated, _) = execute_render_report(
&json!({"title": "Voice stack", "body": CLAIM}),
false,
Some(&silent),
None,
);
let (plain, _) = execute_render_report(
&json!({"title": "Voice stack", "body": CLAIM}),
false,
None,
None,
);
assert_eq!(annotated, plain);
}
}
#[test]
fn definition_names_the_tool_and_requires_title() {
let def = render_report_tool_definition();
assert_eq!(def["function"]["name"], "render_report");
assert_eq!(def["function"]["parameters"]["required"], json!(["title"]));
}
#[test]
fn body_only_report_titles_and_acks() {
let (md, ack) = compose(json!({
"title": "Good Morning",
"body": "You have 3 meetings today."
}))
.unwrap();
assert!(md.starts_with("# Good Morning\n"), "{md}");
assert!(md.contains("You have 3 meetings today."), "{md}");
assert_eq!(ack, "report rendered: \"Good Morning\" — ok");
}
#[test]
fn sections_render_as_subheadings_and_are_counted() {
let (md, ack) = compose(json!({
"title": "Triage",
"sections": [
{"heading": "Calendar", "body": "2 meetings"},
{"heading": "NVBugs", "body": "no P0s"}
]
}))
.unwrap();
assert!(md.contains("\n## Calendar\n"), "{md}");
assert!(md.contains("\n## NVBugs\n"), "{md}");
assert_eq!(ack, "report rendered: \"Triage\" — ok, 2 section(s)");
}
#[test]
fn degraded_section_is_marked_inline_not_aborted() {
let (md, ack) = compose(json!({
"title": "Morning",
"status": "degraded",
"sections": [
{"heading": "Calendar", "body": "2 meetings"},
{"heading": "ShadowSync deadlines", "status": "error",
"body": "JIRA token expired — refresh ~/.jira/token"}
]
}))
.unwrap();
assert!(
md.contains("# ⚠ Morning"),
"title carries the status glyph: {md}"
);
assert!(md.contains("degraded"), "caption states the degrade: {md}");
assert!(md.contains("## ✖ ShadowSync deadlines"), "{md}");
assert!(
md.contains("## Calendar"),
"healthy section stays clean: {md}"
);
assert_eq!(ack, "report rendered: \"Morning\" — degraded, 2 section(s)");
}
#[test]
fn pending_is_a_section_only_status() {
let (md, _) = compose(json!({
"title": "R",
"sections": [{"heading": "Slow", "status": "pending", "body": "loading"}]
}))
.unwrap();
assert!(md.contains("## … Slow"), "{md}");
let err = compose(json!({"title": "R", "status": "pending"})).unwrap_err();
assert!(err.contains("unknown report status"), "{err}");
}
#[test]
fn empty_title_is_actionable_error() {
let err = compose(json!({"title": " "})).unwrap_err();
assert!(err.contains("non-empty `title`"), "{err}");
}
#[test]
fn unknown_status_is_actionable_error() {
let err = compose(json!({"title": "R", "status": "critical"})).unwrap_err();
assert!(err.contains("unknown report status"), "{err}");
assert!(err.contains("critical"), "names the offending value: {err}");
}
#[test]
fn section_without_heading_is_actionable_error() {
let err = compose(json!({
"title": "R",
"sections": [{"heading": "", "body": "x"}]
}))
.unwrap_err();
assert!(err.contains("non-empty `heading`"), "{err}");
}
#[test]
fn plan_disposition_stores_a_revision_instead_of_rendering() {
use crate::agentic::PlanDraftSink as _;
#[derive(Default)]
struct FakeSink(std::sync::Mutex<Option<crate::agentic::PlanDraft>>);
impl crate::agentic::PlanDraftSink for FakeSink {
fn save_draft(&self, markdown: String) -> Result<u32, String> {
let mut slot = self.0.lock().unwrap();
let revision = slot.as_ref().map_or(1, |d| d.revision + 1);
*slot = Some(crate::agentic::PlanDraft { revision, markdown });
Ok(revision)
}
fn latest_draft(&self) -> Option<crate::agentic::PlanDraft> {
self.0.lock().unwrap().clone()
}
}
let sink = FakeSink::default();
for n in 1..=4 {
let (ack, doc) = execute_render_report(
&json!({"title": format!("Refactor plan v{n}"), "body": "steps..."}),
false,
None,
Some(&sink),
);
assert!(
doc.is_none(),
"a Plan-disposition report must never render for display: {ack}"
);
assert!(
ack.contains(&format!("revision {n}")),
"ack must name the revision it just saved: {ack}"
);
assert!(
ack.contains("not yet shown"),
"ack must say the draft has not been presented: {ack}"
);
}
let latest = sink.latest_draft().expect("four saves left a draft");
assert_eq!(
latest.revision, 4,
"the fourth save replaces the slot, never appends"
);
assert!(latest.markdown.contains("Refactor plan v4"));
assert!(
!latest.markdown.contains("Refactor plan v1"),
"the draft slot holds ONE revision, not a history: {}",
latest.markdown
);
}
#[test]
fn report_delivery_nudge_permits_explicit_correction() {
assert!(
REPORT_DELIVERY_GUIDANCE.contains("supersedes the report above"),
"nudge must provide the correction form the model can use: {REPORT_DELIVERY_GUIDANCE}"
);
assert!(
REPORT_DELIVERY_GUIDANCE.contains("Correction:"),
"nudge must name the prefix the model should start with: {REPORT_DELIVERY_GUIDANCE}"
);
assert!(
REPORT_DELIVERY_GUIDANCE.contains("Do not repeat"),
"nudge must still discourage silent repetition: {REPORT_DELIVERY_GUIDANCE}"
);
assert!(
RENDER_REPORT_DESCRIPTION.contains("supersedes the report above"),
"tool description must prescribe the same correction statement: {RENDER_REPORT_DESCRIPTION}"
);
assert!(
!RENDER_REPORT_DESCRIPTION.contains("render_report again"),
"tool description must not offer a second, unlabelled correction path: {RENDER_REPORT_DESCRIPTION}"
);
}
}