use serde::{Deserialize, Serialize};
use crate::context::memory::Memory;
use crate::context::state::JobContext;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FallbackDeliverable {
pub partial: bool,
pub failure_reason: String,
pub last_action: Option<LastAction>,
pub action_stats: ActionStats,
pub tokens_used: u64,
pub cost: String,
pub elapsed_secs: f64,
pub repair_attempts: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LastAction {
pub tool_name: String,
pub output_preview: String,
pub success: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionStats {
pub total: u32,
pub successful: u32,
pub failed: u32,
}
impl FallbackDeliverable {
pub fn build(ctx: &JobContext, memory: &Memory, reason: &str) -> Self {
let successful = memory.successful_actions() as u32;
let failed = memory.failed_actions() as u32;
let total = memory.actions.len() as u32;
let last_action = memory.last_action().map(|a| {
let owned_fallback;
let preview_str: &str = if let Some(v) = a.output_sanitized.as_ref() {
match v {
serde_json::Value::String(s) => s.as_str(),
other => {
owned_fallback = serde_json::to_string(other).unwrap_or_default();
&owned_fallback
}
}
} else if let Some(ref err) = a.error {
err.as_str()
} else {
""
};
let preview = truncate_str(preview_str, 200);
LastAction {
tool_name: a.tool_name.clone(),
output_preview: preview.to_string(),
success: a.success,
}
});
let elapsed_secs = ctx.elapsed().map_or(0.0, |d| d.as_secs_f64());
Self {
partial: successful > 0,
failure_reason: truncate_str(reason, 1000).to_string(),
last_action,
action_stats: ActionStats {
total,
successful,
failed,
},
tokens_used: ctx.total_tokens_used,
cost: ctx.actual_cost.to_string(),
elapsed_secs,
repair_attempts: ctx.repair_attempts,
}
}
}
fn truncate_str(s: &str, max_len: usize) -> &str {
&s[..crate::util::floor_char_boundary(s, max_len)]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::memory::Memory;
use crate::context::state::JobContext;
use chrono::{Duration, Utc};
use rust_decimal::Decimal;
use std::time::Duration as StdDuration;
#[test]
fn test_fallback_zero_actions() {
let ctx = JobContext::new("Test", "Empty job");
let memory = Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "timed out");
assert!(!fb.partial); assert_eq!(fb.failure_reason, "timed out"); assert!(fb.last_action.is_none()); assert_eq!(fb.action_stats.total, 0); assert_eq!(fb.action_stats.successful, 0); assert_eq!(fb.action_stats.failed, 0); assert_eq!(fb.tokens_used, 0); assert_eq!(fb.cost, "0"); assert_eq!(fb.repair_attempts, 0); }
#[test]
fn test_fallback_mixed_actions() {
let mut ctx = JobContext::new("Test", "Mixed job");
ctx.total_tokens_used = 5000;
ctx.actual_cost = Decimal::new(42, 2); ctx.repair_attempts = 1;
let mut memory = Memory::new(ctx.job_id);
for _ in 0..3 {
let action = memory
.create_action("tool_a", serde_json::json!({}))
.succeed(
Some("output".to_string()),
serde_json::json!({}),
StdDuration::from_secs(1),
);
memory.record_action(action);
}
for _ in 0..2 {
let action = memory
.create_action("tool_b", serde_json::json!({}))
.fail("broke", StdDuration::from_secs(1));
memory.record_action(action);
}
let fb = FallbackDeliverable::build(&ctx, &memory, "max iterations");
assert!(fb.partial); assert_eq!(fb.action_stats.total, 5); assert_eq!(fb.action_stats.successful, 3); assert_eq!(fb.action_stats.failed, 2); assert_eq!(fb.tokens_used, 5000); assert_eq!(fb.cost, "0.42"); assert_eq!(fb.repair_attempts, 1); assert!(fb.last_action.is_some()); let la = fb.last_action.unwrap(); assert_eq!(la.tool_name, "tool_b"); assert!(!la.success); assert_eq!(la.output_preview, "broke"); }
#[test]
fn test_fallback_failed_action_shows_error() {
let ctx = JobContext::new("Test", "Error preview");
let mut memory = Memory::new(ctx.job_id);
let action = memory
.create_action("broken_tool", serde_json::json!({}))
.fail("connection timed out after 30s", StdDuration::from_secs(30));
memory.record_action(action);
let fb = FallbackDeliverable::build(&ctx, &memory, "tool failure");
let la = fb.last_action.unwrap(); assert!(!la.success); assert_eq!(la.output_preview, "connection timed out after 30s"); }
#[test]
fn test_fallback_last_action_truncation() {
let ctx = JobContext::new("Test", "Truncation");
let mut memory = Memory::new(ctx.job_id);
let long_output = "x".repeat(500);
let action = memory
.create_action("tool_c", serde_json::json!({}))
.succeed(
Some(long_output.clone()),
serde_json::Value::String(long_output),
StdDuration::from_secs(1),
);
memory.record_action(action);
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
let la = fb.last_action.unwrap(); assert!(la.output_preview.len() <= 200); assert!(!la.output_preview.is_empty()); }
#[test]
fn test_fallback_uses_sanitized_output() {
let ctx = JobContext::new("Test", "Sanitized");
let mut memory = Memory::new(ctx.job_id);
let action = memory
.create_action("tool_d", serde_json::json!({}))
.succeed(
Some("[REDACTED]".to_string()),
serde_json::json!({"api_key": "sk-secret-key-12345"}),
StdDuration::from_secs(1),
);
memory.record_action(action);
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
let la = fb.last_action.unwrap(); assert!(!la.output_preview.contains("sk-secret")); assert!(la.output_preview.contains("REDACTED")); }
#[test]
fn test_fallback_elapsed_time() {
let mut ctx = JobContext::new("Test", "Timing");
let now = Utc::now();
ctx.started_at = Some(now - Duration::seconds(10));
ctx.completed_at = Some(now);
let memory = Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
assert!((fb.elapsed_secs - 10.0).abs() < 0.1); }
#[test]
fn test_fallback_no_started_at() {
let ctx = JobContext::new("Test", "Never started");
let memory = Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
assert!((fb.elapsed_secs - 0.0).abs() < 0.001); }
#[test]
fn test_fallback_elapsed_time_no_completed_at() {
let mut ctx = JobContext::new("Test", "Still running");
ctx.started_at = Some(Utc::now() - Duration::seconds(5));
let memory = Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "stuck");
assert!(fb.elapsed_secs >= 4.0 && fb.elapsed_secs <= 7.0); }
#[test]
fn test_fallback_failure_reason_truncation() {
let ctx = JobContext::new("Test", "Long reason");
let memory = Memory::new(ctx.job_id);
let long_reason = "x".repeat(5000);
let fb = FallbackDeliverable::build(&ctx, &memory, &long_reason);
assert!(fb.failure_reason.len() <= 1000); assert!(!fb.failure_reason.is_empty()); }
#[test]
fn test_truncate_str_ascii() {
assert_eq!(truncate_str("hello", 10), "hello"); assert_eq!(truncate_str("hello world", 5), "hello"); }
#[test]
fn test_truncate_str_unicode() {
let s = "café";
assert_eq!(truncate_str(s, 10), "café"); assert_eq!(truncate_str(s, 4), "caf"); }
#[test]
fn test_fallback_serialization() {
let ctx = JobContext::new("Test", "Serialize");
let memory = Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "test error");
let json = serde_json::to_value(&fb).unwrap(); let deserialized: FallbackDeliverable = serde_json::from_value(json).unwrap(); assert_eq!(deserialized.failure_reason, "test error"); }
}