mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-04 02:33:56 +00:00
Updated: Cargo.toml workspace members, CI workflow paths, Dockerfile COPY, AGENTS.md, skill mapping, and doc references. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2101 lines
71 KiB
Rust
2101 lines
71 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
|
|
use crate::artifact::{artifact_path, format_artifact_reference};
|
|
use crate::context::keys;
|
|
use crate::context::Context;
|
|
use crate::graph::{is_llm_handler_type, Graph, Node};
|
|
use crate::outcome::Outcome;
|
|
|
|
/// Build a fidelity-appropriate preamble string for non-full context modes.
|
|
///
|
|
/// The preamble provides prior conversation context to the next LLM session,
|
|
/// tailored by the fidelity mode:
|
|
/// - `Truncate`: Only graph goal and run ID
|
|
/// - `Compact`: Nested-bullet summary with handler-specific sub-items
|
|
/// - `SummaryLow`: Brief textual summary (~600 token target)
|
|
/// - `SummaryMedium`: Moderate detail (~1500 token target)
|
|
/// - `SummaryHigh`: Detailed per-stage Markdown report
|
|
/// - `Full`: Returns empty string (full-fidelity nodes share a thread)
|
|
#[must_use]
|
|
pub fn build_preamble(
|
|
fidelity: keys::Fidelity,
|
|
context: &Context,
|
|
graph: &Graph,
|
|
completed_nodes: &[String],
|
|
node_outcomes: &HashMap<String, Outcome>,
|
|
) -> String {
|
|
use keys::Fidelity;
|
|
|
|
let goal = graph.goal();
|
|
let run_id = context.run_id();
|
|
|
|
let preamble = match fidelity {
|
|
Fidelity::Full => String::new(),
|
|
Fidelity::Truncate => {
|
|
format!("Goal: {goal}\nRun ID: {run_id}\n")
|
|
}
|
|
Fidelity::Compact => {
|
|
build_compact_preamble(goal, completed_nodes, node_outcomes, context, graph)
|
|
}
|
|
Fidelity::SummaryLow => build_summary_preamble(
|
|
goal,
|
|
&run_id,
|
|
completed_nodes,
|
|
node_outcomes,
|
|
context,
|
|
graph,
|
|
SummaryDetail::Low,
|
|
),
|
|
Fidelity::SummaryMedium => build_summary_preamble(
|
|
goal,
|
|
&run_id,
|
|
completed_nodes,
|
|
node_outcomes,
|
|
context,
|
|
graph,
|
|
SummaryDetail::Medium,
|
|
),
|
|
Fidelity::SummaryHigh => build_summary_preamble(
|
|
goal,
|
|
&run_id,
|
|
completed_nodes,
|
|
node_outcomes,
|
|
context,
|
|
graph,
|
|
SummaryDetail::High,
|
|
),
|
|
};
|
|
|
|
let parent_preamble = context.get_string(keys::INTERNAL_PARENT_PREAMBLE, "");
|
|
if !parent_preamble.is_empty() && !preamble.is_empty() {
|
|
format!(
|
|
"## Parent workflow context\n{parent_preamble}\n\n## Current sub-workflow\n{preamble}"
|
|
)
|
|
} else {
|
|
preamble
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn is_meta_handler(graph: &Graph, node_id: &str) -> bool {
|
|
graph
|
|
.nodes
|
|
.get(node_id)
|
|
.and_then(|n| n.handler_type())
|
|
.is_some_and(|h| h == "start" || h == "exit")
|
|
}
|
|
|
|
fn is_blank_value(val: Option<&serde_json::Value>) -> bool {
|
|
val.and_then(|v| v.as_str()).is_some_and(|s| s.is_empty())
|
|
}
|
|
|
|
fn is_context_key_excluded(key: &str) -> bool {
|
|
key.starts_with(keys::INTERNAL_PREFIX)
|
|
|| key.starts_with(keys::CURRENT_PREFIX)
|
|
|| key.starts_with(keys::GRAPH_PREFIX)
|
|
|| key.starts_with(keys::THREAD_PREFIX)
|
|
|| key.starts_with(keys::RESPONSE_PREFIX)
|
|
|| key == keys::OUTCOME
|
|
|| key == keys::LAST_STAGE
|
|
|| key == keys::LAST_RESPONSE
|
|
|| key == keys::PREFERRED_LABEL
|
|
}
|
|
|
|
fn format_value(val: &serde_json::Value) -> String {
|
|
match val.as_str() {
|
|
Some(s) => s.to_string(),
|
|
None => val.to_string(),
|
|
}
|
|
}
|
|
|
|
fn format_token_count(tokens: i64) -> String {
|
|
if tokens >= 1_000_000 {
|
|
format!("{:.1}m", tokens as f64 / 1_000_000.0)
|
|
} else if tokens >= 1000 {
|
|
format!("{:.1}k", tokens as f64 / 1000.0)
|
|
} else {
|
|
tokens.to_string()
|
|
}
|
|
}
|
|
|
|
/// Returns the set of context keys that are rendered inline under a stage's
|
|
/// handler-specific details, so they can be skipped in the trailing context section.
|
|
fn stage_rendered_keys(node_id: &str, outcome: &Outcome) -> HashSet<String> {
|
|
let candidates = [
|
|
keys::COMMAND_OUTPUT.to_string(),
|
|
keys::COMMAND_STDERR.to_string(),
|
|
keys::LAST_STAGE.to_string(),
|
|
keys::LAST_RESPONSE.to_string(),
|
|
keys::response_key(node_id),
|
|
];
|
|
candidates
|
|
.into_iter()
|
|
.filter(|k| outcome.context_updates.contains_key(k))
|
|
.collect()
|
|
}
|
|
|
|
/// Render handler-specific nested bullets for compact mode.
|
|
fn render_compact_stage_details(
|
|
_node_id: &str,
|
|
node: Option<&Node>,
|
|
outcome: &Outcome,
|
|
) -> Vec<String> {
|
|
let handler = node.and_then(|n| n.handler_type());
|
|
match handler {
|
|
Some("command") => {
|
|
let mut lines = Vec::new();
|
|
if let Some(n) = node {
|
|
if let Some(cmd) = n
|
|
.attrs
|
|
.get("script")
|
|
.or_else(|| n.attrs.get("tool_command"))
|
|
.and_then(|v| v.as_str())
|
|
{
|
|
lines.push(format!(" - Script: `{cmd}`"));
|
|
}
|
|
}
|
|
if let Some(stdout_val) = outcome.context_updates.get(keys::COMMAND_OUTPUT) {
|
|
let stdout = format_value(stdout_val);
|
|
if stdout.trim().is_empty() {
|
|
lines.push(" - Stdout: (empty)".to_string());
|
|
} else {
|
|
lines.push(" - Stdout:".to_string());
|
|
lines.push(" ```".to_string());
|
|
lines.push(format!(" {}", stdout.trim()));
|
|
lines.push(" ```".to_string());
|
|
}
|
|
}
|
|
if let Some(stderr_val) = outcome.context_updates.get(keys::COMMAND_STDERR) {
|
|
let stderr = format_value(stderr_val);
|
|
if stderr.trim().is_empty() {
|
|
lines.push(" - Stderr: (empty)".to_string());
|
|
} else {
|
|
lines.push(" - Stderr:".to_string());
|
|
lines.push(" ```".to_string());
|
|
lines.push(format!(" {}", stderr.trim()));
|
|
lines.push(" ```".to_string());
|
|
}
|
|
}
|
|
lines
|
|
}
|
|
h if is_llm_handler_type(h) => {
|
|
let mut lines = Vec::new();
|
|
if let Some(usage) = &outcome.usage {
|
|
let input = format_token_count(usage.input_tokens);
|
|
let output = format_token_count(usage.output_tokens);
|
|
lines.push(format!(
|
|
" - Model: {}, {} tokens in / {} out",
|
|
usage.model, input, output
|
|
));
|
|
}
|
|
if !outcome.files_touched.is_empty() {
|
|
lines.push(format!(" - Files: {}", outcome.files_touched.join(", ")));
|
|
}
|
|
lines
|
|
}
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Render a full `## Stage: {node_id}` section for summary:high mode.
|
|
fn render_summary_high_stage_section(
|
|
node_id: &str,
|
|
node: Option<&Node>,
|
|
outcome: &Outcome,
|
|
) -> Vec<String> {
|
|
let handler = node.and_then(|n| n.handler_type());
|
|
let mut lines = Vec::new();
|
|
lines.push(format!("\n## Stage: {node_id}"));
|
|
lines.push(format!("- Status: {}", outcome.status));
|
|
|
|
if let Some(h) = handler {
|
|
lines.push(format!("- Handler: {h}"));
|
|
}
|
|
|
|
match handler {
|
|
Some("command") => {
|
|
if let Some(n) = node {
|
|
if let Some(cmd) = n
|
|
.attrs
|
|
.get("script")
|
|
.or_else(|| n.attrs.get("tool_command"))
|
|
.and_then(|v| v.as_str())
|
|
{
|
|
lines.push(format!("- Script: `{cmd}`"));
|
|
}
|
|
}
|
|
if let Some(stdout_val) = outcome.context_updates.get(keys::COMMAND_OUTPUT) {
|
|
if let Some(path) = artifact_path(stdout_val) {
|
|
lines.push(format!("- Stdout: {}", format_artifact_reference(path)));
|
|
} else {
|
|
let stdout = format_value(stdout_val);
|
|
if stdout.trim().is_empty() {
|
|
lines.push("- Stdout: (empty)".to_string());
|
|
} else {
|
|
lines.push("- Stdout:".to_string());
|
|
lines.push(" ```".to_string());
|
|
lines.push(format!(" {}", stdout.trim()));
|
|
lines.push(" ```".to_string());
|
|
}
|
|
}
|
|
}
|
|
if let Some(stderr_val) = outcome.context_updates.get(keys::COMMAND_STDERR) {
|
|
if let Some(path) = artifact_path(stderr_val) {
|
|
lines.push(format!("- Stderr: {}", format_artifact_reference(path)));
|
|
} else {
|
|
let stderr = format_value(stderr_val);
|
|
if stderr.trim().is_empty() {
|
|
lines.push("- Stderr: (empty)".to_string());
|
|
} else {
|
|
lines.push("- Stderr:".to_string());
|
|
lines.push(" ```".to_string());
|
|
lines.push(format!(" {}", stderr.trim()));
|
|
lines.push(" ```".to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
h if is_llm_handler_type(h) => {
|
|
if let Some(usage) = &outcome.usage {
|
|
lines.push(format!("- Model: {}", usage.model));
|
|
lines.push(format!(
|
|
"- Tokens: {} in / {} out",
|
|
format_token_count(usage.input_tokens),
|
|
format_token_count(usage.output_tokens)
|
|
));
|
|
}
|
|
if !outcome.files_touched.is_empty() {
|
|
lines.push(format!(
|
|
"- Files touched: {}",
|
|
outcome.files_touched.join(", ")
|
|
));
|
|
}
|
|
// Include full response from context_updates (or artifact pointer)
|
|
if let Some(resp_val) = outcome.context_updates.get(&keys::response_key(node_id)) {
|
|
if let Some(path) = artifact_path(resp_val) {
|
|
lines.push(format!("- Response: {}", format_artifact_reference(path)));
|
|
} else {
|
|
let resp = format_value(resp_val);
|
|
if !resp.is_empty() {
|
|
lines.push("- Response:".to_string());
|
|
// Blockquote each line
|
|
for line in resp.lines() {
|
|
lines.push(format!(" > {line}"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
if let Some(notes) = outcome.notes.as_deref() {
|
|
lines.push(format!("- Notes: {notes}"));
|
|
}
|
|
if let Some(reason) = outcome.failure_reason() {
|
|
lines.push(format!("- Failure reason: {reason}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
lines
|
|
}
|
|
|
|
/// Append filtered context as a `## Context` bullet list.
|
|
fn append_filtered_context(
|
|
parts: &mut Vec<String>,
|
|
context: &Context,
|
|
rendered_keys: &HashSet<String>,
|
|
) {
|
|
let snapshot = context.snapshot();
|
|
let mut context_keys: Vec<&String> = snapshot
|
|
.keys()
|
|
.filter(|k| {
|
|
!is_context_key_excluded(k)
|
|
&& !rendered_keys.contains(*k)
|
|
&& !is_blank_value(snapshot.get(*k))
|
|
})
|
|
.collect();
|
|
if !context_keys.is_empty() {
|
|
context_keys.sort();
|
|
parts.push(String::from("\n## Context"));
|
|
for key in context_keys {
|
|
if let Some(val) = snapshot.get(key) {
|
|
parts.push(format!("- {key}: {}", format_value(val)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Append filtered context as a `## Current context` Markdown table.
|
|
fn append_filtered_context_table(
|
|
parts: &mut Vec<String>,
|
|
context: &Context,
|
|
rendered_keys: &HashSet<String>,
|
|
) {
|
|
let snapshot = context.snapshot();
|
|
let mut context_keys: Vec<&String> = snapshot
|
|
.keys()
|
|
.filter(|k| {
|
|
!is_context_key_excluded(k)
|
|
&& !rendered_keys.contains(*k)
|
|
&& !is_blank_value(snapshot.get(*k))
|
|
})
|
|
.collect();
|
|
if !context_keys.is_empty() {
|
|
context_keys.sort();
|
|
parts.push(String::from("\n## Current context"));
|
|
parts.push("| Key | Value |".to_string());
|
|
parts.push("|-----|-------|".to_string());
|
|
for key in context_keys {
|
|
if let Some(val) = snapshot.get(key) {
|
|
parts.push(format!("| {key} | {} |", format_value(val)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Compact preamble
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn build_compact_preamble(
|
|
goal: &str,
|
|
completed_nodes: &[String],
|
|
node_outcomes: &HashMap<String, Outcome>,
|
|
context: &Context,
|
|
graph: &Graph,
|
|
) -> String {
|
|
let mut parts = Vec::new();
|
|
parts.push(format!("Goal: {goal}"));
|
|
|
|
let mut all_rendered_keys = HashSet::new();
|
|
|
|
{
|
|
let mut header_emitted = false;
|
|
for node_id in completed_nodes {
|
|
if is_meta_handler(graph, node_id) {
|
|
continue;
|
|
}
|
|
if !header_emitted {
|
|
parts.push(String::from("\n## Completed stages"));
|
|
header_emitted = true;
|
|
}
|
|
let node = graph.nodes.get(node_id);
|
|
if let Some(outcome) = node_outcomes.get(node_id) {
|
|
let status = &outcome.status;
|
|
parts.push(format!("- **{node_id}**: {status}"));
|
|
|
|
let details = render_compact_stage_details(node_id, node, outcome);
|
|
parts.extend(details);
|
|
|
|
all_rendered_keys.extend(stage_rendered_keys(node_id, outcome));
|
|
} else {
|
|
parts.push(format!("- **{node_id}**: completed"));
|
|
}
|
|
}
|
|
}
|
|
|
|
append_filtered_context(&mut parts, context, &all_rendered_keys);
|
|
|
|
parts.push(String::new());
|
|
parts.join("\n")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Summary preamble
|
|
// ---------------------------------------------------------------------------
|
|
|
|
enum SummaryDetail {
|
|
Low,
|
|
Medium,
|
|
High,
|
|
}
|
|
|
|
fn build_summary_preamble(
|
|
goal: &str,
|
|
run_id: &str,
|
|
completed_nodes: &[String],
|
|
node_outcomes: &HashMap<String, Outcome>,
|
|
context: &Context,
|
|
graph: &Graph,
|
|
detail: SummaryDetail,
|
|
) -> String {
|
|
let mut parts = Vec::new();
|
|
parts.push(format!("Goal: {goal}"));
|
|
parts.push(format!("Run ID: {run_id}"));
|
|
|
|
let mut all_rendered_keys = HashSet::new();
|
|
|
|
match detail {
|
|
SummaryDetail::High => {
|
|
let total_nodes = graph
|
|
.nodes
|
|
.keys()
|
|
.filter(|id| !is_meta_handler(graph, id))
|
|
.count();
|
|
let completed_count = completed_nodes
|
|
.iter()
|
|
.filter(|id| !is_meta_handler(graph, id))
|
|
.count();
|
|
parts.push(format!(
|
|
"Pipeline progress: {completed_count} of {total_nodes} stages completed"
|
|
));
|
|
|
|
for node_id in completed_nodes {
|
|
if is_meta_handler(graph, node_id) {
|
|
continue;
|
|
}
|
|
let node = graph.nodes.get(node_id);
|
|
if let Some(outcome) = node_outcomes.get(node_id) {
|
|
let section = render_summary_high_stage_section(node_id, node, outcome);
|
|
parts.extend(section);
|
|
all_rendered_keys.extend(stage_rendered_keys(node_id, outcome));
|
|
} else {
|
|
parts.push(format!("\n## Stage: {node_id}"));
|
|
parts.push("- Status: completed".to_string());
|
|
}
|
|
}
|
|
|
|
append_filtered_context_table(&mut parts, context, &all_rendered_keys);
|
|
}
|
|
SummaryDetail::Medium => {
|
|
let stage_count = completed_nodes.len();
|
|
parts.push(format!("Completed {stage_count} stage(s) so far."));
|
|
|
|
let recent_count = 5;
|
|
let stages_to_show: Vec<&String> = if stage_count > recent_count {
|
|
let skipped = stage_count - recent_count;
|
|
parts.push(format!("\n({skipped} earlier stage(s) omitted)"));
|
|
completed_nodes.iter().skip(skipped).collect()
|
|
} else {
|
|
completed_nodes.iter().collect()
|
|
};
|
|
|
|
{
|
|
let mut header_emitted = false;
|
|
for node_id in &stages_to_show {
|
|
if is_meta_handler(graph, node_id) {
|
|
continue;
|
|
}
|
|
if !header_emitted {
|
|
parts.push(String::from("\nRecent stages:"));
|
|
header_emitted = true;
|
|
}
|
|
if let Some(outcome) = node_outcomes.get(*node_id) {
|
|
let status = outcome.status.to_string();
|
|
let mut line = format!("- {node_id}: {status}");
|
|
if let Some(notes) = outcome.notes.as_deref() {
|
|
line.push_str(&format!(" ({notes})"));
|
|
}
|
|
if let Some(reason) = outcome.failure_reason() {
|
|
line.push_str(&format!(" [reason: {reason}]"));
|
|
}
|
|
parts.push(line);
|
|
|
|
let node = graph.nodes.get(*node_id);
|
|
let details = render_compact_stage_details(node_id, node, outcome);
|
|
parts.extend(details);
|
|
|
|
all_rendered_keys.extend(stage_rendered_keys(node_id, outcome));
|
|
} else {
|
|
parts.push(format!("- {node_id}: completed"));
|
|
}
|
|
}
|
|
}
|
|
|
|
append_filtered_context(&mut parts, context, &all_rendered_keys);
|
|
}
|
|
SummaryDetail::Low => {
|
|
let stage_count = completed_nodes.len();
|
|
parts.push(format!("Completed {stage_count} stage(s) so far."));
|
|
|
|
let recent_count = 2;
|
|
let stages_to_show: Vec<&String> = if stage_count > recent_count {
|
|
let skipped = stage_count - recent_count;
|
|
parts.push(format!("\n({skipped} earlier stage(s) omitted)"));
|
|
completed_nodes.iter().skip(skipped).collect()
|
|
} else {
|
|
completed_nodes.iter().collect()
|
|
};
|
|
|
|
{
|
|
let mut header_emitted = false;
|
|
for node_id in &stages_to_show {
|
|
if is_meta_handler(graph, node_id) {
|
|
continue;
|
|
}
|
|
if !header_emitted {
|
|
parts.push(String::from("\nRecent stages:"));
|
|
header_emitted = true;
|
|
}
|
|
if let Some(outcome) = node_outcomes.get(*node_id) {
|
|
let status = outcome.status.to_string();
|
|
let mut line = format!("- {node_id}: {status}");
|
|
if let Some(notes) = outcome.notes.as_deref() {
|
|
line.push_str(&format!(" ({notes})"));
|
|
}
|
|
if let Some(reason) = outcome.failure_reason() {
|
|
line.push_str(&format!(" [reason: {reason}]"));
|
|
}
|
|
parts.push(line);
|
|
|
|
let node = graph.nodes.get(*node_id);
|
|
let handler = node.and_then(|n| n.handler_type());
|
|
if let Some(h) = handler {
|
|
parts.push(format!(" - Handler: {h}"));
|
|
}
|
|
match handler {
|
|
Some("command") => {
|
|
if let Some(n) = node {
|
|
if let Some(cmd) = n
|
|
.attrs
|
|
.get("script")
|
|
.or_else(|| n.attrs.get("tool_command"))
|
|
.and_then(|v| v.as_str())
|
|
{
|
|
parts.push(format!(" - Script: `{cmd}`"));
|
|
}
|
|
}
|
|
}
|
|
h if is_llm_handler_type(h) => {
|
|
if let Some(usage) = &outcome.usage {
|
|
parts.push(format!(" - Model: {}", usage.model));
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
} else {
|
|
parts.push(format!("- {node_id}: completed"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
parts.push(String::new());
|
|
parts.join("\n")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::graph::AttrValue;
|
|
use crate::outcome::StageUsage;
|
|
|
|
// --- truncate mode ---
|
|
|
|
#[test]
|
|
fn build_preamble_truncate_includes_goal_and_run_id() {
|
|
let mut graph = Graph::new("test");
|
|
graph.attrs.insert(
|
|
"goal".to_string(),
|
|
AttrValue::String("Fix the login bug".to_string()),
|
|
);
|
|
let context = Context::new();
|
|
context.set(keys::INTERNAL_RUN_ID, serde_json::json!("abc-123"));
|
|
let completed_nodes: Vec<String> = Vec::new();
|
|
let node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::Truncate,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
preamble.contains("Fix the login bug"),
|
|
"should contain the goal"
|
|
);
|
|
assert!(preamble.contains("Run ID:"), "should contain run ID label");
|
|
assert!(
|
|
preamble.contains("abc-123"),
|
|
"should contain the run ID value"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_preamble_truncate_excludes_completed_stages() {
|
|
let mut graph = Graph::new("test");
|
|
graph.attrs.insert(
|
|
"goal".to_string(),
|
|
AttrValue::String("Deploy app".to_string()),
|
|
);
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["plan".to_string(), "code".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
node_outcomes.insert("plan".to_string(), Outcome::success());
|
|
node_outcomes.insert("code".to_string(), Outcome::success());
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::Truncate,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
!preamble.contains("plan"),
|
|
"truncate should not list completed stages"
|
|
);
|
|
assert!(
|
|
!preamble.contains("code"),
|
|
"truncate should not list completed stages"
|
|
);
|
|
}
|
|
|
|
// --- compact mode ---
|
|
|
|
#[test]
|
|
fn build_preamble_compact_lists_completed_stages() {
|
|
let mut graph = Graph::new("test");
|
|
graph.attrs.insert(
|
|
"goal".to_string(),
|
|
AttrValue::String("Deploy app".to_string()),
|
|
);
|
|
let context = Context::new();
|
|
context.set(keys::INTERNAL_RUN_ID, serde_json::json!("run-456"));
|
|
let completed_nodes = vec!["plan".to_string(), "code".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
node_outcomes.insert("plan".to_string(), Outcome::success());
|
|
node_outcomes.insert(
|
|
"code".to_string(),
|
|
Outcome::fail_classify("compilation error"),
|
|
);
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::Compact,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(preamble.contains("Deploy app"), "should contain the goal");
|
|
assert!(
|
|
preamble.contains("## Completed stages"),
|
|
"should have Completed stages heading"
|
|
);
|
|
assert!(
|
|
preamble.contains("**plan**"),
|
|
"should list completed stage 'plan' in bold"
|
|
);
|
|
assert!(
|
|
preamble.contains("success"),
|
|
"should show plan's success status"
|
|
);
|
|
assert!(
|
|
preamble.contains("**code**"),
|
|
"should list completed stage 'code' in bold"
|
|
);
|
|
assert!(preamble.contains("fail"), "should show code's fail status");
|
|
}
|
|
|
|
#[test]
|
|
fn build_preamble_compact_includes_context_values() {
|
|
let graph = Graph::new("test");
|
|
let context = Context::new();
|
|
context.set(keys::GRAPH_GOAL, serde_json::json!("Build it"));
|
|
context.set("user.name", serde_json::json!("alice"));
|
|
let completed_nodes: Vec<String> = Vec::new();
|
|
let node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::Compact,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
!preamble.contains("graph.goal"),
|
|
"should exclude graph.* context keys"
|
|
);
|
|
assert!(
|
|
preamble.contains("user.name"),
|
|
"should include user.name context key"
|
|
);
|
|
assert!(preamble.contains("alice"), "should include context value");
|
|
}
|
|
|
|
#[test]
|
|
fn build_preamble_compact_excludes_internal_keys() {
|
|
let graph = Graph::new("test");
|
|
let context = Context::new();
|
|
context.set(keys::INTERNAL_FIDELITY, serde_json::json!("compact"));
|
|
context.set(keys::retry_count_key("plan"), serde_json::json!(1));
|
|
context.set(keys::CURRENT_NODE, serde_json::json!("work"));
|
|
context.set(
|
|
keys::graph_attr_key("default_fidelity"),
|
|
serde_json::json!("compact"),
|
|
);
|
|
context.set("thread.main.current_node", serde_json::json!("work"));
|
|
context.set(
|
|
keys::response_key("plan"),
|
|
serde_json::json!("some response"),
|
|
);
|
|
context.set(keys::LAST_STAGE, serde_json::json!("plan"));
|
|
context.set(keys::LAST_RESPONSE, serde_json::json!("resp"));
|
|
context.set(keys::PREFERRED_LABEL, serde_json::json!("success"));
|
|
context.set("user.name", serde_json::json!("bob"));
|
|
let completed_nodes: Vec<String> = Vec::new();
|
|
let node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::Compact,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
!preamble.contains("internal.fidelity"),
|
|
"should exclude internal keys"
|
|
);
|
|
assert!(
|
|
!preamble.contains("internal.retry_count"),
|
|
"should exclude internal keys"
|
|
);
|
|
assert!(
|
|
!preamble.contains("current_node"),
|
|
"should exclude current keys"
|
|
);
|
|
assert!(
|
|
!preamble.contains("graph.default_fidelity"),
|
|
"should exclude graph.* keys"
|
|
);
|
|
assert!(
|
|
!preamble.contains("thread.main"),
|
|
"should exclude thread.* keys"
|
|
);
|
|
assert!(
|
|
!preamble.contains("response.plan"),
|
|
"should exclude response.* keys"
|
|
);
|
|
assert!(
|
|
!preamble.contains("- last_stage:"),
|
|
"should exclude last_stage"
|
|
);
|
|
assert!(
|
|
!preamble.contains("- last_response:"),
|
|
"should exclude last_response"
|
|
);
|
|
assert!(
|
|
!preamble.contains("- preferred_label:"),
|
|
"should exclude preferred_label"
|
|
);
|
|
assert!(
|
|
preamble.contains("user.name"),
|
|
"should include non-internal keys"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_preamble_compact_shows_notes_on_stages() {
|
|
// Compact no longer shows notes inline (handler-specific details replace them),
|
|
// but notes are still available in the outcome for non-handler stages.
|
|
let graph = Graph::new("test");
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["work".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
let mut outcome = Outcome::success();
|
|
outcome.notes = Some("auto-status: completed".to_string());
|
|
node_outcomes.insert("work".to_string(), outcome);
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::Compact,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
// Compact uses bold node IDs and handler-specific details now
|
|
assert!(
|
|
preamble.contains("**work**"),
|
|
"should include node ID in bold"
|
|
);
|
|
assert!(preamble.contains("success"), "should show success status");
|
|
}
|
|
|
|
// --- compact handler-specific details ---
|
|
|
|
#[test]
|
|
fn compact_command_stage_shows_command_stdout_stderr() {
|
|
let mut graph = Graph::new("test");
|
|
let mut run_tests = Node::new("run_tests");
|
|
run_tests.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("parallelogram".to_string()),
|
|
);
|
|
run_tests.attrs.insert(
|
|
"script".to_string(),
|
|
AttrValue::String("echo '10 passed'".to_string()),
|
|
);
|
|
graph.nodes.insert("run_tests".to_string(), run_tests);
|
|
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["run_tests".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
let mut outcome = Outcome::success();
|
|
outcome.context_updates.insert(
|
|
keys::COMMAND_OUTPUT.to_string(),
|
|
serde_json::json!("10 passed\n"),
|
|
);
|
|
outcome
|
|
.context_updates
|
|
.insert(keys::COMMAND_STDERR.to_string(), serde_json::json!(""));
|
|
node_outcomes.insert("run_tests".to_string(), outcome);
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::Compact,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
preamble.contains("Script: `echo '10 passed'`"),
|
|
"should show script command"
|
|
);
|
|
assert!(preamble.contains("Stdout:"), "should show stdout label");
|
|
assert!(preamble.contains("10 passed"), "should show stdout content");
|
|
assert!(
|
|
preamble.contains("Stderr: (empty)"),
|
|
"should show empty stderr"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compact_agent_loop_stage_shows_model_and_files() {
|
|
let mut graph = Graph::new("test");
|
|
let mut report = Node::new("report");
|
|
report
|
|
.attrs
|
|
.insert("shape".to_string(), AttrValue::String("box".to_string()));
|
|
graph.nodes.insert("report".to_string(), report);
|
|
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["report".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
let mut outcome = Outcome::success();
|
|
outcome.usage = Some(StageUsage {
|
|
model: "claude-sonnet-4-20250514".to_string(),
|
|
input_tokens: 1234,
|
|
output_tokens: 567,
|
|
cache_read_tokens: None,
|
|
cache_write_tokens: None,
|
|
reasoning_tokens: None,
|
|
cost: None,
|
|
});
|
|
outcome.files_touched = vec!["src/lib.rs".to_string(), "src/main.rs".to_string()];
|
|
node_outcomes.insert("report".to_string(), outcome);
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::Compact,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
preamble.contains("claude-sonnet-4-20250514"),
|
|
"should show model name"
|
|
);
|
|
assert!(
|
|
preamble.contains("1.2k tokens in"),
|
|
"should show token count"
|
|
);
|
|
assert!(
|
|
preamble.contains("src/lib.rs, src/main.rs"),
|
|
"should show files touched"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compact_context_excludes_engine_keys() {
|
|
let graph = Graph::new("test");
|
|
let context = Context::new();
|
|
context.set(
|
|
keys::graph_attr_key("default_fidelity"),
|
|
serde_json::json!("compact"),
|
|
);
|
|
context.set("thread.main.current_node", serde_json::json!("work"));
|
|
context.set(
|
|
keys::response_key("plan"),
|
|
serde_json::json!("some LLM response"),
|
|
);
|
|
context.set(keys::LAST_STAGE, serde_json::json!("plan"));
|
|
context.set("user.preference", serde_json::json!("dark"));
|
|
let completed_nodes: Vec<String> = Vec::new();
|
|
let node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::Compact,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
!preamble.contains("graph.default_fidelity"),
|
|
"should exclude graph.* keys"
|
|
);
|
|
assert!(
|
|
!preamble.contains("thread.main"),
|
|
"should exclude thread.* keys"
|
|
);
|
|
assert!(
|
|
!preamble.contains("response.plan"),
|
|
"should exclude response.* keys"
|
|
);
|
|
assert!(
|
|
!preamble.contains("- last_stage:"),
|
|
"should exclude last_stage"
|
|
);
|
|
assert!(
|
|
preamble.contains("user.preference"),
|
|
"should include user keys"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compact_context_deduplicates_stage_rendered_keys() {
|
|
let mut graph = Graph::new("test");
|
|
let mut step = Node::new("step");
|
|
step.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("parallelogram".to_string()),
|
|
);
|
|
step.attrs.insert(
|
|
"script".to_string(),
|
|
AttrValue::String("echo hi".to_string()),
|
|
);
|
|
graph.nodes.insert("step".to_string(), step);
|
|
|
|
let context = Context::new();
|
|
// command.output is set in context (the engine copies context_updates to context)
|
|
context.set(keys::COMMAND_OUTPUT, serde_json::json!("hi\n"));
|
|
context.set(keys::COMMAND_STDERR, serde_json::json!(""));
|
|
let completed_nodes = vec!["step".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
let mut outcome = Outcome::success();
|
|
outcome
|
|
.context_updates
|
|
.insert(keys::COMMAND_OUTPUT.to_string(), serde_json::json!("hi\n"));
|
|
outcome
|
|
.context_updates
|
|
.insert(keys::COMMAND_STDERR.to_string(), serde_json::json!(""));
|
|
node_outcomes.insert("step".to_string(), outcome);
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::Compact,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
// command.output should NOT appear in the Context section
|
|
// because it's already rendered inline under the stage
|
|
let context_section = preamble.split("## Context").nth(1).unwrap_or("");
|
|
assert!(
|
|
!context_section.contains("command.output"),
|
|
"command.output should be deduplicated from context section"
|
|
);
|
|
}
|
|
|
|
// --- summary:low mode ---
|
|
|
|
#[test]
|
|
fn build_preamble_summary_low_includes_stage_count() {
|
|
let mut graph = Graph::new("test");
|
|
graph.attrs.insert(
|
|
"goal".to_string(),
|
|
AttrValue::String("Run tests".to_string()),
|
|
);
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["plan".to_string(), "code".to_string(), "test".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
node_outcomes.insert("plan".to_string(), Outcome::success());
|
|
node_outcomes.insert("code".to_string(), Outcome::success());
|
|
node_outcomes.insert("test".to_string(), Outcome::fail_classify("test failure"));
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryLow,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(preamble.contains("Run tests"), "should contain the goal");
|
|
assert!(
|
|
preamble.contains("3 stage(s)"),
|
|
"should mention total stage count"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_preamble_summary_low_shows_only_recent_stages() {
|
|
let mut graph = Graph::new("test");
|
|
let mut step3 = Node::new("step3");
|
|
step3.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("parallelogram".to_string()),
|
|
);
|
|
graph.nodes.insert("step3".to_string(), step3);
|
|
|
|
let context = Context::new();
|
|
let completed_nodes = vec![
|
|
"step1".to_string(),
|
|
"step2".to_string(),
|
|
"step3".to_string(),
|
|
"step4".to_string(),
|
|
];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
node_outcomes.insert("step1".to_string(), Outcome::success());
|
|
node_outcomes.insert("step2".to_string(), Outcome::success());
|
|
node_outcomes.insert("step3".to_string(), Outcome::success());
|
|
node_outcomes.insert("step4".to_string(), Outcome::fail_classify("error"));
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryLow,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
// summary:low shows only 2 recent stages
|
|
assert!(!preamble.contains("step1"), "should omit older stages");
|
|
assert!(!preamble.contains("step2"), "should omit older stages");
|
|
assert!(preamble.contains("step3"), "should show recent stage");
|
|
assert!(preamble.contains("step4"), "should show most recent stage");
|
|
assert!(
|
|
preamble.contains("omitted"),
|
|
"should indicate omitted stages"
|
|
);
|
|
// Handler type should appear for nodes with known handlers
|
|
assert!(
|
|
preamble.contains("Handler: command"),
|
|
"should show handler type for step3"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn summary_low_command_stage_shows_handler_and_command() {
|
|
let mut graph = Graph::new("test");
|
|
let mut run_tests = Node::new("run_tests");
|
|
run_tests.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("parallelogram".to_string()),
|
|
);
|
|
run_tests.attrs.insert(
|
|
"script".to_string(),
|
|
AttrValue::String("cargo test".to_string()),
|
|
);
|
|
graph.nodes.insert("run_tests".to_string(), run_tests);
|
|
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["run_tests".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
let mut outcome = Outcome::fail_classify("exit code 1");
|
|
outcome.context_updates.insert(
|
|
keys::COMMAND_OUTPUT.to_string(),
|
|
serde_json::json!("test failed"),
|
|
);
|
|
node_outcomes.insert("run_tests".to_string(), outcome);
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryLow,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
preamble.contains("Handler: command"),
|
|
"should show handler type"
|
|
);
|
|
assert!(
|
|
preamble.contains("Script: `cargo test`"),
|
|
"should show script command"
|
|
);
|
|
// Low mode should NOT include stdout/stderr
|
|
assert!(
|
|
!preamble.contains("Stdout:"),
|
|
"should not show stdout in low mode"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn summary_low_agent_loop_stage_shows_handler_and_model() {
|
|
let mut graph = Graph::new("test");
|
|
let mut report = Node::new("report");
|
|
report
|
|
.attrs
|
|
.insert("shape".to_string(), AttrValue::String("box".to_string()));
|
|
graph.nodes.insert("report".to_string(), report);
|
|
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["report".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
let mut outcome = Outcome::success();
|
|
outcome.usage = Some(StageUsage {
|
|
model: "claude-sonnet-4-20250514".to_string(),
|
|
input_tokens: 1000,
|
|
output_tokens: 200,
|
|
cache_read_tokens: None,
|
|
cache_write_tokens: None,
|
|
reasoning_tokens: None,
|
|
cost: None,
|
|
});
|
|
node_outcomes.insert("report".to_string(), outcome);
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryLow,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
preamble.contains("Handler: agent"),
|
|
"should show handler type"
|
|
);
|
|
assert!(
|
|
preamble.contains("Model: claude-sonnet-4-20250514"),
|
|
"should show model name"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_preamble_summary_low_excludes_context_values() {
|
|
let graph = Graph::new("test");
|
|
let context = Context::new();
|
|
context.set("user.name", serde_json::json!("alice"));
|
|
let completed_nodes: Vec<String> = Vec::new();
|
|
let node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryLow,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
!preamble.contains("user.name"),
|
|
"summary:low should not include context values"
|
|
);
|
|
}
|
|
|
|
// --- summary:medium mode ---
|
|
|
|
#[test]
|
|
fn build_preamble_summary_medium_shows_more_stages_than_low() {
|
|
let graph = Graph::new("test");
|
|
let context = Context::new();
|
|
let completed_nodes = vec![
|
|
"s1".to_string(),
|
|
"s2".to_string(),
|
|
"s3".to_string(),
|
|
"s4".to_string(),
|
|
"s5".to_string(),
|
|
"s6".to_string(),
|
|
"s7".to_string(),
|
|
];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
node_outcomes.insert("s1".to_string(), Outcome::success());
|
|
node_outcomes.insert("s2".to_string(), Outcome::success());
|
|
node_outcomes.insert("s3".to_string(), Outcome::success());
|
|
node_outcomes.insert("s4".to_string(), Outcome::success());
|
|
node_outcomes.insert("s5".to_string(), Outcome::success());
|
|
node_outcomes.insert("s6".to_string(), Outcome::success());
|
|
node_outcomes.insert("s7".to_string(), Outcome::success());
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryMedium,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
// summary:medium shows 5 recent stages
|
|
assert!(!preamble.contains("- s1:"), "should omit oldest stages");
|
|
assert!(!preamble.contains("- s2:"), "should omit oldest stages");
|
|
assert!(preamble.contains("s3"), "should show recent stage s3");
|
|
assert!(preamble.contains("s7"), "should show most recent stage s7");
|
|
assert!(
|
|
preamble.contains("omitted"),
|
|
"should indicate omitted stages"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_preamble_summary_medium_includes_context_values() {
|
|
let graph = Graph::new("test");
|
|
let context = Context::new();
|
|
context.set("user.name", serde_json::json!("alice"));
|
|
let completed_nodes: Vec<String> = Vec::new();
|
|
let node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryMedium,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
preamble.contains("user.name"),
|
|
"summary:medium should include context values"
|
|
);
|
|
assert!(preamble.contains("alice"), "should include context value");
|
|
}
|
|
|
|
#[test]
|
|
fn build_preamble_summary_medium_uses_compact_handler_details() {
|
|
let mut graph = Graph::new("test");
|
|
let mut run_tests = Node::new("run_tests");
|
|
run_tests.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("parallelogram".to_string()),
|
|
);
|
|
run_tests.attrs.insert(
|
|
"script".to_string(),
|
|
AttrValue::String("make test".to_string()),
|
|
);
|
|
graph.nodes.insert("run_tests".to_string(), run_tests);
|
|
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["run_tests".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
let mut outcome = Outcome::success();
|
|
outcome.context_updates.insert(
|
|
keys::COMMAND_OUTPUT.to_string(),
|
|
serde_json::json!("All tests passed\n"),
|
|
);
|
|
outcome
|
|
.context_updates
|
|
.insert(keys::COMMAND_STDERR.to_string(), serde_json::json!(""));
|
|
node_outcomes.insert("run_tests".to_string(), outcome);
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryMedium,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
preamble.contains("Script: `make test`"),
|
|
"should show script command via compact renderer"
|
|
);
|
|
assert!(
|
|
preamble.contains("All tests passed"),
|
|
"should show stdout via compact renderer"
|
|
);
|
|
assert!(
|
|
!preamble.contains("set command.output"),
|
|
"should not dump raw context updates"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn summary_medium_agent_loop_stage_shows_compact_details() {
|
|
let mut graph = Graph::new("test");
|
|
let mut report = Node::new("report");
|
|
report
|
|
.attrs
|
|
.insert("shape".to_string(), AttrValue::String("box".to_string()));
|
|
graph.nodes.insert("report".to_string(), report);
|
|
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["report".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
let mut outcome = Outcome::success();
|
|
outcome.usage = Some(StageUsage {
|
|
model: "claude-sonnet-4-20250514".to_string(),
|
|
input_tokens: 1500,
|
|
output_tokens: 300,
|
|
cache_read_tokens: None,
|
|
cache_write_tokens: None,
|
|
reasoning_tokens: None,
|
|
cost: None,
|
|
});
|
|
outcome.files_touched = vec!["src/lib.rs".to_string()];
|
|
node_outcomes.insert("report".to_string(), outcome);
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryMedium,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
preamble.contains("claude-sonnet-4-20250514"),
|
|
"should show model name"
|
|
);
|
|
assert!(preamble.contains("src/lib.rs"), "should show files touched");
|
|
}
|
|
|
|
// --- summary:high mode ---
|
|
|
|
#[test]
|
|
fn build_preamble_summary_high_shows_all_stages() {
|
|
let graph = Graph::new("test");
|
|
let context = Context::new();
|
|
let completed_nodes = vec![
|
|
"s1".to_string(),
|
|
"s2".to_string(),
|
|
"s3".to_string(),
|
|
"s4".to_string(),
|
|
"s5".to_string(),
|
|
"s6".to_string(),
|
|
];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
node_outcomes.insert("s1".to_string(), Outcome::success());
|
|
node_outcomes.insert("s2".to_string(), Outcome::success());
|
|
node_outcomes.insert("s3".to_string(), Outcome::success());
|
|
node_outcomes.insert("s4".to_string(), Outcome::success());
|
|
node_outcomes.insert("s5".to_string(), Outcome::success());
|
|
node_outcomes.insert("s6".to_string(), Outcome::success());
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryHigh,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
// summary:high shows ALL stages as ## Stage: headings
|
|
assert!(
|
|
preamble.contains("## Stage: s1"),
|
|
"should show all stages including s1"
|
|
);
|
|
assert!(
|
|
preamble.contains("## Stage: s6"),
|
|
"should show all stages including s6"
|
|
);
|
|
assert!(!preamble.contains("omitted"), "should not omit any stages");
|
|
}
|
|
|
|
#[test]
|
|
fn build_preamble_summary_high_includes_failure_reasons() {
|
|
let graph = Graph::new("test");
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["work".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
node_outcomes.insert(
|
|
"work".to_string(),
|
|
Outcome::fail_classify("connection timeout"),
|
|
);
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryHigh,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
preamble.contains("connection timeout"),
|
|
"should include failure reason"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_preamble_summary_high_includes_context_values() {
|
|
let graph = Graph::new("test");
|
|
let context = Context::new();
|
|
context.set(keys::GRAPH_GOAL, serde_json::json!("Build"));
|
|
context.set("user.name", serde_json::json!("alice"));
|
|
let completed_nodes: Vec<String> = Vec::new();
|
|
let node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryHigh,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
!preamble.contains("graph.goal"),
|
|
"should exclude graph.* from context"
|
|
);
|
|
// Table format for summary:high
|
|
assert!(
|
|
preamble.contains("| user.name |"),
|
|
"should include context values as table"
|
|
);
|
|
}
|
|
|
|
// --- summary:high handler-specific ---
|
|
|
|
#[test]
|
|
fn summary_high_produces_stage_sections() {
|
|
let graph = Graph::new("test");
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["start".to_string(), "work".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
node_outcomes.insert("start".to_string(), Outcome::success());
|
|
node_outcomes.insert("work".to_string(), Outcome::success());
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryHigh,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
preamble.contains("## Stage: start"),
|
|
"should have stage heading for start"
|
|
);
|
|
assert!(
|
|
preamble.contains("## Stage: work"),
|
|
"should have stage heading for work"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn summary_high_command_stage_full_detail() {
|
|
let mut graph = Graph::new("test");
|
|
let mut run_tests = Node::new("run_tests");
|
|
run_tests.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("parallelogram".to_string()),
|
|
);
|
|
run_tests.attrs.insert(
|
|
"script".to_string(),
|
|
AttrValue::String("make test".to_string()),
|
|
);
|
|
graph.nodes.insert("run_tests".to_string(), run_tests);
|
|
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["run_tests".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
let mut outcome = Outcome::success();
|
|
outcome.context_updates.insert(
|
|
keys::COMMAND_OUTPUT.to_string(),
|
|
serde_json::json!("All tests passed\n"),
|
|
);
|
|
outcome.context_updates.insert(
|
|
keys::COMMAND_STDERR.to_string(),
|
|
serde_json::json!("warning: unused var\n"),
|
|
);
|
|
node_outcomes.insert("run_tests".to_string(), outcome);
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryHigh,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
preamble.contains("## Stage: run_tests"),
|
|
"should have stage heading"
|
|
);
|
|
assert!(preamble.contains("Handler: command"), "should show handler");
|
|
assert!(
|
|
preamble.contains("Script: `make test`"),
|
|
"should show script command"
|
|
);
|
|
assert!(
|
|
preamble.contains("All tests passed"),
|
|
"should include stdout"
|
|
);
|
|
assert!(
|
|
preamble.contains("warning: unused var"),
|
|
"should include stderr"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn summary_high_agent_loop_stage_with_response_preview() {
|
|
let mut graph = Graph::new("test");
|
|
let mut report = Node::new("report");
|
|
report
|
|
.attrs
|
|
.insert("shape".to_string(), AttrValue::String("box".to_string()));
|
|
graph.nodes.insert("report".to_string(), report);
|
|
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["report".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
let mut outcome = Outcome::success();
|
|
outcome.usage = Some(StageUsage {
|
|
model: "claude-sonnet-4-20250514".to_string(),
|
|
input_tokens: 1500,
|
|
output_tokens: 300,
|
|
cache_read_tokens: None,
|
|
cache_write_tokens: None,
|
|
reasoning_tokens: None,
|
|
cost: None,
|
|
});
|
|
outcome.files_touched = vec!["src/lib.rs".to_string()];
|
|
outcome.context_updates.insert(
|
|
keys::response_key("report"),
|
|
serde_json::json!("The tests all pass successfully."),
|
|
);
|
|
node_outcomes.insert("report".to_string(), outcome);
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryHigh,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
preamble.contains("## Stage: report"),
|
|
"should have stage heading"
|
|
);
|
|
assert!(preamble.contains("Handler: agent"), "should show handler");
|
|
assert!(
|
|
preamble.contains("Model: claude-sonnet-4-20250514"),
|
|
"should show model"
|
|
);
|
|
assert!(preamble.contains("1.5k in"), "should show formatted tokens");
|
|
assert!(
|
|
preamble.contains("Files touched: src/lib.rs"),
|
|
"should show files"
|
|
);
|
|
assert!(
|
|
preamble.contains("The tests all pass"),
|
|
"should include response"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn summary_high_context_as_table() {
|
|
let graph = Graph::new("test");
|
|
let context = Context::new();
|
|
context.set("user.name", serde_json::json!("alice"));
|
|
context.set("custom.key", serde_json::json!("value"));
|
|
let completed_nodes: Vec<String> = Vec::new();
|
|
let node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryHigh,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
preamble.contains("## Current context"),
|
|
"should have context table heading"
|
|
);
|
|
assert!(
|
|
preamble.contains("| Key | Value |"),
|
|
"should have table header"
|
|
);
|
|
assert!(
|
|
preamble.contains("| user.name | alice |"),
|
|
"should have context row"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn summary_high_pipeline_progress_count() {
|
|
let mut graph = Graph::new("test");
|
|
// Create 4 nodes total (including start/exit)
|
|
let start = Node::new("start");
|
|
graph.nodes.insert("start".to_string(), start);
|
|
let work = Node::new("work");
|
|
graph.nodes.insert("work".to_string(), work);
|
|
let test = Node::new("test");
|
|
graph.nodes.insert("test".to_string(), test);
|
|
let exit = Node::new("exit");
|
|
graph.nodes.insert("exit".to_string(), exit);
|
|
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["start".to_string(), "work".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
node_outcomes.insert("start".to_string(), Outcome::success());
|
|
node_outcomes.insert("work".to_string(), Outcome::success());
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryHigh,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
preamble.contains("2 of 4 stages completed"),
|
|
"should show pipeline progress with total node count, got:\n{preamble}"
|
|
);
|
|
}
|
|
|
|
// --- format_token_count ---
|
|
|
|
#[test]
|
|
fn format_token_count_formatting() {
|
|
assert_eq!(format_token_count(500), "500");
|
|
assert_eq!(format_token_count(999), "999");
|
|
assert_eq!(format_token_count(1000), "1.0k");
|
|
assert_eq!(format_token_count(1234), "1.2k");
|
|
assert_eq!(format_token_count(1500), "1.5k");
|
|
assert_eq!(format_token_count(10000), "10.0k");
|
|
assert_eq!(format_token_count(1_000_000), "1.0m");
|
|
assert_eq!(format_token_count(3_456_789), "3.5m");
|
|
}
|
|
|
|
// --- is_context_key_excluded ---
|
|
|
|
#[test]
|
|
fn is_context_key_excluded_checks() {
|
|
assert!(is_context_key_excluded(keys::INTERNAL_FIDELITY));
|
|
assert!(is_context_key_excluded(&keys::retry_count_key("plan")));
|
|
assert!(is_context_key_excluded(keys::CURRENT_NODE));
|
|
assert!(is_context_key_excluded(keys::CURRENT_PREAMBLE));
|
|
assert!(is_context_key_excluded(&keys::graph_attr_key(
|
|
"default_fidelity"
|
|
)));
|
|
assert!(is_context_key_excluded(keys::GRAPH_GOAL));
|
|
assert!(is_context_key_excluded(&keys::thread_current_node_key(
|
|
"main"
|
|
)));
|
|
assert!(is_context_key_excluded(&keys::response_key("plan")));
|
|
assert!(is_context_key_excluded(keys::OUTCOME));
|
|
assert!(is_context_key_excluded(keys::LAST_STAGE));
|
|
assert!(is_context_key_excluded(keys::LAST_RESPONSE));
|
|
assert!(is_context_key_excluded(keys::PREFERRED_LABEL));
|
|
assert!(!is_context_key_excluded("user.name"));
|
|
assert!(!is_context_key_excluded("custom.key"));
|
|
assert!(!is_context_key_excluded(keys::COMMAND_OUTPUT));
|
|
}
|
|
|
|
// --- meta node filtering ---
|
|
|
|
#[test]
|
|
fn compact_preamble_excludes_start_node() {
|
|
let mut graph = Graph::new("test");
|
|
let mut start = Node::new("start");
|
|
start.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("Mdiamond".to_string()),
|
|
);
|
|
graph.nodes.insert("start".to_string(), start);
|
|
let plan = Node::new("plan");
|
|
graph.nodes.insert("plan".to_string(), plan);
|
|
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["start".to_string(), "plan".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
node_outcomes.insert("start".to_string(), Outcome::success());
|
|
node_outcomes.insert("plan".to_string(), Outcome::success());
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::Compact,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
!preamble.contains("**start**"),
|
|
"should not show start node, got:\n{preamble}"
|
|
);
|
|
assert!(preamble.contains("**plan**"), "should show non-meta nodes");
|
|
}
|
|
|
|
#[test]
|
|
fn summary_high_excludes_start_node() {
|
|
let mut graph = Graph::new("test");
|
|
let mut start = Node::new("start");
|
|
start.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("Mdiamond".to_string()),
|
|
);
|
|
graph.nodes.insert("start".to_string(), start);
|
|
let work = Node::new("work");
|
|
graph.nodes.insert("work".to_string(), work);
|
|
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["start".to_string(), "work".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
node_outcomes.insert("start".to_string(), Outcome::success());
|
|
node_outcomes.insert("work".to_string(), Outcome::success());
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryHigh,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
!preamble.contains("## Stage: start"),
|
|
"should not show start stage, got:\n{preamble}"
|
|
);
|
|
assert!(
|
|
preamble.contains("## Stage: work"),
|
|
"should show non-meta stages"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn summary_high_progress_excludes_meta_nodes() {
|
|
let mut graph = Graph::new("test");
|
|
let mut start = Node::new("start");
|
|
start.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("Mdiamond".to_string()),
|
|
);
|
|
graph.nodes.insert("start".to_string(), start);
|
|
let work = Node::new("work");
|
|
graph.nodes.insert("work".to_string(), work);
|
|
let test_node = Node::new("test");
|
|
graph.nodes.insert("test".to_string(), test_node);
|
|
let mut exit = Node::new("exit");
|
|
exit.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("Msquare".to_string()),
|
|
);
|
|
graph.nodes.insert("exit".to_string(), exit);
|
|
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["start".to_string(), "work".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
node_outcomes.insert("start".to_string(), Outcome::success());
|
|
node_outcomes.insert("work".to_string(), Outcome::success());
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryHigh,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
preamble.contains("1 of 2 stages completed"),
|
|
"should exclude meta nodes from progress count, got:\n{preamble}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn summary_medium_excludes_start_node() {
|
|
let mut graph = Graph::new("test");
|
|
let mut start = Node::new("start");
|
|
start.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("Mdiamond".to_string()),
|
|
);
|
|
graph.nodes.insert("start".to_string(), start);
|
|
let work = Node::new("work");
|
|
graph.nodes.insert("work".to_string(), work);
|
|
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["start".to_string(), "work".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
node_outcomes.insert("start".to_string(), Outcome::success());
|
|
node_outcomes.insert("work".to_string(), Outcome::success());
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryMedium,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
!preamble.contains("- start:"),
|
|
"should not show start stage, got:\n{preamble}"
|
|
);
|
|
assert!(preamble.contains("- work:"), "should show non-meta stages");
|
|
}
|
|
|
|
#[test]
|
|
fn summary_low_excludes_start_node() {
|
|
let mut graph = Graph::new("test");
|
|
let mut start = Node::new("start");
|
|
start.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("Mdiamond".to_string()),
|
|
);
|
|
graph.nodes.insert("start".to_string(), start);
|
|
let work = Node::new("work");
|
|
graph.nodes.insert("work".to_string(), work);
|
|
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["start".to_string(), "work".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
node_outcomes.insert("start".to_string(), Outcome::success());
|
|
node_outcomes.insert("work".to_string(), Outcome::success());
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryLow,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
!preamble.contains("- start:"),
|
|
"should not show start stage, got:\n{preamble}"
|
|
);
|
|
assert!(preamble.contains("- work:"), "should show non-meta stages");
|
|
}
|
|
|
|
#[test]
|
|
fn summary_medium_no_recent_stages_when_only_start() {
|
|
let mut graph = Graph::new("test");
|
|
let mut start = Node::new("start");
|
|
start.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("Mdiamond".to_string()),
|
|
);
|
|
graph.nodes.insert("start".to_string(), start);
|
|
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["start".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
node_outcomes.insert("start".to_string(), Outcome::success());
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryMedium,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
!preamble.contains("Recent stages:"),
|
|
"should not show Recent stages header when only meta nodes, got:\n{preamble}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn summary_low_no_recent_stages_when_only_start() {
|
|
let mut graph = Graph::new("test");
|
|
let mut start = Node::new("start");
|
|
start.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("Mdiamond".to_string()),
|
|
);
|
|
graph.nodes.insert("start".to_string(), start);
|
|
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["start".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
node_outcomes.insert("start".to_string(), Outcome::success());
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryLow,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
!preamble.contains("Recent stages:"),
|
|
"should not show Recent stages header when only meta nodes, got:\n{preamble}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compact_preamble_no_completed_stages_when_only_start() {
|
|
let mut graph = Graph::new("test");
|
|
let mut start = Node::new("start");
|
|
start.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("Mdiamond".to_string()),
|
|
);
|
|
graph.nodes.insert("start".to_string(), start);
|
|
|
|
let context = Context::new();
|
|
let completed_nodes = vec!["start".to_string()];
|
|
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
node_outcomes.insert("start".to_string(), Outcome::success());
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::Compact,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
!preamble.contains("## Completed stages"),
|
|
"should not show Completed stages header when only meta nodes, got:\n{preamble}"
|
|
);
|
|
}
|
|
|
|
// --- blank context values ---
|
|
|
|
#[test]
|
|
fn blank_context_values_excluded() {
|
|
let graph = Graph::new("test");
|
|
let context = Context::new();
|
|
context.set("failure_class", serde_json::json!(""));
|
|
context.set("failure_signature", serde_json::json!(""));
|
|
context.set("user.name", serde_json::json!("alice"));
|
|
let completed_nodes: Vec<String> = Vec::new();
|
|
let node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::Compact,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
!preamble.contains("failure_class"),
|
|
"should exclude blank failure_class"
|
|
);
|
|
assert!(
|
|
!preamble.contains("failure_signature"),
|
|
"should exclude blank failure_signature"
|
|
);
|
|
assert!(
|
|
preamble.contains("user.name"),
|
|
"should include non-blank context"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn blank_context_values_excluded_from_summary_high_table() {
|
|
let graph = Graph::new("test");
|
|
let context = Context::new();
|
|
context.set("failure_class", serde_json::json!(""));
|
|
context.set("user.name", serde_json::json!("alice"));
|
|
let completed_nodes: Vec<String> = Vec::new();
|
|
let node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::SummaryHigh,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
!preamble.contains("failure_class"),
|
|
"should exclude blank failure_class from table"
|
|
);
|
|
assert!(
|
|
preamble.contains("| user.name | alice |"),
|
|
"should include non-blank context in table"
|
|
);
|
|
}
|
|
|
|
// --- empty state ---
|
|
|
|
#[test]
|
|
fn build_preamble_compact_with_no_stages() {
|
|
let graph = Graph::new("test");
|
|
let context = Context::new();
|
|
let completed_nodes: Vec<String> = Vec::new();
|
|
let node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::Compact,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
!preamble.contains("Completed stages"),
|
|
"should not show stages header when empty"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_preamble_prepends_parent_preamble_when_present() {
|
|
let graph = Graph::new("test");
|
|
let context = Context::new();
|
|
context.set(
|
|
keys::INTERNAL_PARENT_PREAMBLE,
|
|
serde_json::json!("Parent completed plan and review"),
|
|
);
|
|
let completed_nodes: Vec<String> = Vec::new();
|
|
let node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::Compact,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
preamble.contains("## Parent workflow context"),
|
|
"should contain parent section header"
|
|
);
|
|
assert!(
|
|
preamble.contains("Parent completed plan and review"),
|
|
"should contain parent preamble text"
|
|
);
|
|
assert!(
|
|
preamble.contains("## Current sub-workflow"),
|
|
"should contain current sub-workflow section header"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_preamble_no_parent_preamble_when_absent() {
|
|
let graph = Graph::new("test");
|
|
let context = Context::new();
|
|
let completed_nodes: Vec<String> = Vec::new();
|
|
let node_outcomes: HashMap<String, Outcome> = HashMap::new();
|
|
|
|
let preamble = build_preamble(
|
|
keys::Fidelity::Compact,
|
|
&context,
|
|
&graph,
|
|
&completed_nodes,
|
|
&node_outcomes,
|
|
);
|
|
|
|
assert!(
|
|
!preamble.contains("Parent workflow context"),
|
|
"should not contain parent section when no parent preamble"
|
|
);
|
|
}
|
|
}
|