|
| 1 | +//! Real-LLM end-to-end tests for the cluster-grade features added in 3.3.0 |
| 2 | +//! (BudgetGuard enforcement, loop-checkpoint lifecycle, resume_run, identity |
| 3 | +//! labels). These exercise code paths that mock LLM clients cannot validate — |
| 4 | +//! most importantly that `BudgetGuard::record_after_llm` receives the |
| 5 | +//! provider's *actual* token usage and that a real run's lifecycle clears its |
| 6 | +//! checkpoint. |
| 7 | +//! |
| 8 | +//! All `#[ignore]` — they require a live provider in `.a3s/config.acl`. Run: |
| 9 | +//! |
| 10 | +//! ```bash |
| 11 | +//! A3S_CONFIG_FILE=/abs/path/.a3s/config.acl \ |
| 12 | +//! cargo test -p a3s-code-core --test test_real_llm_cluster_features -- --ignored --nocapture |
| 13 | +//! ``` |
| 14 | +
|
| 15 | +use std::path::PathBuf; |
| 16 | +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; |
| 17 | +use std::sync::Arc; |
| 18 | + |
| 19 | +use a3s_code_core::budget::{BudgetDecision, BudgetGuard}; |
| 20 | +use a3s_code_core::config::CodeConfig; |
| 21 | +use a3s_code_core::llm::TokenUsage; |
| 22 | +use a3s_code_core::store::{MemorySessionStore, SessionStore}; |
| 23 | +use a3s_code_core::{Agent, SessionOptions}; |
| 24 | + |
| 25 | +fn repo_config_path() -> PathBuf { |
| 26 | + std::env::var_os("A3S_CONFIG_FILE") |
| 27 | + .map(PathBuf::from) |
| 28 | + .unwrap_or_else(|| { |
| 29 | + PathBuf::from(env!("CARGO_MANIFEST_DIR")) |
| 30 | + .join("../../..") |
| 31 | + .join(".a3s/config.acl") |
| 32 | + }) |
| 33 | +} |
| 34 | + |
| 35 | +async fn real_agent() -> Agent { |
| 36 | + let path = repo_config_path(); |
| 37 | + let config = CodeConfig::from_file(&path) |
| 38 | + .unwrap_or_else(|e| panic!("failed to load {}: {e}", path.display())); |
| 39 | + Agent::from_config(config) |
| 40 | + .await |
| 41 | + .expect("agent from real config") |
| 42 | +} |
| 43 | + |
| 44 | +// A guard that always denies, counting how many times it was consulted. |
| 45 | +#[derive(Default)] |
| 46 | +struct DenyGuard { |
| 47 | + checks: AtomicUsize, |
| 48 | + records: AtomicUsize, |
| 49 | +} |
| 50 | + |
| 51 | +#[async_trait::async_trait] |
| 52 | +impl BudgetGuard for DenyGuard { |
| 53 | + async fn check_before_llm(&self, _session_id: &str, _est: usize) -> BudgetDecision { |
| 54 | + self.checks.fetch_add(1, Ordering::SeqCst); |
| 55 | + BudgetDecision::Deny { |
| 56 | + resource: "llm_tokens".to_string(), |
| 57 | + reason: "test cap exceeded".to_string(), |
| 58 | + } |
| 59 | + } |
| 60 | + async fn record_after_llm(&self, _session_id: &str, _usage: &TokenUsage) { |
| 61 | + self.records.fetch_add(1, Ordering::SeqCst); |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +// A guard that allows but captures the *actual* usage the provider reports. |
| 66 | +#[derive(Default)] |
| 67 | +struct RecordingGuard { |
| 68 | + checks: AtomicUsize, |
| 69 | + records: AtomicUsize, |
| 70 | + last_total_tokens: AtomicU64, |
| 71 | +} |
| 72 | + |
| 73 | +#[async_trait::async_trait] |
| 74 | +impl BudgetGuard for RecordingGuard { |
| 75 | + async fn check_before_llm(&self, _session_id: &str, _est: usize) -> BudgetDecision { |
| 76 | + self.checks.fetch_add(1, Ordering::SeqCst); |
| 77 | + BudgetDecision::Allow |
| 78 | + } |
| 79 | + async fn record_after_llm(&self, _session_id: &str, usage: &TokenUsage) { |
| 80 | + self.records.fetch_add(1, Ordering::SeqCst); |
| 81 | + self.last_total_tokens |
| 82 | + .store(usage.total_tokens as u64, Ordering::SeqCst); |
| 83 | + } |
| 84 | +} |
| 85 | + |
| 86 | +/// A real `Deny` from `check_before_llm` must abort the call BEFORE the |
| 87 | +/// provider is contacted: send errors with "Budget exhausted", the guard |
| 88 | +/// was consulted exactly once, `record_after_llm` never fired, and no |
| 89 | +/// conversation history was recorded. |
| 90 | +#[tokio::test(flavor = "multi_thread")] |
| 91 | +#[ignore = "requires real provider credentials and network access"] |
| 92 | +async fn real_budget_guard_deny_blocks_llm_call() { |
| 93 | + let guard = Arc::new(DenyGuard::default()); |
| 94 | + let agent = real_agent().await; |
| 95 | + let opts = SessionOptions::new() |
| 96 | + .with_session_id("real-budget-deny") |
| 97 | + .with_budget_guard(guard.clone() as Arc<dyn BudgetGuard>); |
| 98 | + let session = agent |
| 99 | + .session("/tmp/real-budget-deny", Some(opts)) |
| 100 | + .expect("session"); |
| 101 | + |
| 102 | + let err = session |
| 103 | + .send("Reply with the single word: ok", None) |
| 104 | + .await |
| 105 | + .unwrap_err(); |
| 106 | + assert!( |
| 107 | + err.to_string().contains("Budget exhausted"), |
| 108 | + "expected budget-exhausted error, got: {err}" |
| 109 | + ); |
| 110 | + assert_eq!( |
| 111 | + guard.checks.load(Ordering::SeqCst), |
| 112 | + 1, |
| 113 | + "guard consulted once" |
| 114 | + ); |
| 115 | + assert_eq!( |
| 116 | + guard.records.load(Ordering::SeqCst), |
| 117 | + 0, |
| 118 | + "record_after_llm must not fire when denied (LLM never called)" |
| 119 | + ); |
| 120 | + assert!( |
| 121 | + session.history().is_empty(), |
| 122 | + "denied call must not record history" |
| 123 | + ); |
| 124 | +} |
| 125 | + |
| 126 | +/// On `Allow`, the real run completes and `record_after_llm` receives the |
| 127 | +/// provider's ACTUAL non-zero token usage — the post-call accounting path a |
| 128 | +/// mock client (which returns fixed/zero usage) cannot validate. |
| 129 | +#[tokio::test(flavor = "multi_thread")] |
| 130 | +#[ignore = "requires real provider credentials and network access"] |
| 131 | +async fn real_budget_guard_allow_records_actual_usage() { |
| 132 | + let guard = Arc::new(RecordingGuard::default()); |
| 133 | + let agent = real_agent().await; |
| 134 | + let opts = SessionOptions::new() |
| 135 | + .with_session_id("real-budget-allow") |
| 136 | + .with_budget_guard(guard.clone() as Arc<dyn BudgetGuard>); |
| 137 | + let session = agent |
| 138 | + .session("/tmp/real-budget-allow", Some(opts)) |
| 139 | + .expect("session"); |
| 140 | + |
| 141 | + let result = session |
| 142 | + .send("Reply with the single word: ok", None) |
| 143 | + .await |
| 144 | + .expect("real send should succeed under an allowing guard"); |
| 145 | + |
| 146 | + assert!(!result.text.is_empty(), "real model returned text"); |
| 147 | + assert!(guard.checks.load(Ordering::SeqCst) >= 1, "guard consulted"); |
| 148 | + assert!( |
| 149 | + guard.records.load(Ordering::SeqCst) >= 1, |
| 150 | + "record_after_llm must fire on a successful real call" |
| 151 | + ); |
| 152 | + assert!( |
| 153 | + guard.last_total_tokens.load(Ordering::SeqCst) > 0, |
| 154 | + "record_after_llm must receive the provider's real (non-zero) token usage" |
| 155 | + ); |
| 156 | + assert!( |
| 157 | + result.usage.total_tokens > 0, |
| 158 | + "AgentResult must carry real token usage" |
| 159 | + ); |
| 160 | +} |
| 161 | + |
| 162 | +/// A real run with a `SessionStore` configured must, on completion, leave NO |
| 163 | +/// dangling loop checkpoint for its run id — the leak-fix lifecycle path |
| 164 | +/// exercised end-to-end against a live model. |
| 165 | +#[tokio::test(flavor = "multi_thread")] |
| 166 | +#[ignore = "requires real provider credentials and network access"] |
| 167 | +async fn real_run_with_store_leaves_no_dangling_checkpoint() { |
| 168 | + let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new()); |
| 169 | + let agent = real_agent().await; |
| 170 | + let opts = SessionOptions::new() |
| 171 | + .with_session_id("real-ckpt-clear") |
| 172 | + .with_session_store(Arc::clone(&store)); |
| 173 | + let session = agent |
| 174 | + .session("/tmp/real-ckpt-clear", Some(opts)) |
| 175 | + .expect("session"); |
| 176 | + |
| 177 | + let result = session |
| 178 | + .send( |
| 179 | + "Reply with the single word: done. Do not call any tools.", |
| 180 | + None, |
| 181 | + ) |
| 182 | + .await |
| 183 | + .expect("real send should succeed"); |
| 184 | + assert!(!result.text.is_empty()); |
| 185 | + |
| 186 | + let runs = session.runs().await; |
| 187 | + assert_eq!(runs.len(), 1, "one run recorded"); |
| 188 | + let run_id = &runs[0].id; |
| 189 | + assert_eq!(runs[0].status, a3s_code_core::run::RunStatus::Completed); |
| 190 | + |
| 191 | + // Whether or not the model used a tool (which would have written a |
| 192 | + // checkpoint mid-run), the completed run must leave none behind. |
| 193 | + let lingering = store.load_loop_checkpoint(run_id).await.expect("load"); |
| 194 | + assert!( |
| 195 | + lingering.is_none(), |
| 196 | + "completed real run must not leave a dangling loop checkpoint" |
| 197 | + ); |
| 198 | +} |
| 199 | + |
| 200 | +/// Identity labels (tenant/principal/template/correlation) attached to a |
| 201 | +/// session survive through a live run and the run is recorded as Completed. |
| 202 | +#[tokio::test(flavor = "multi_thread")] |
| 203 | +#[ignore = "requires real provider credentials and network access"] |
| 204 | +async fn real_identity_labels_survive_live_run() { |
| 205 | + let agent = real_agent().await; |
| 206 | + let opts = SessionOptions::new() |
| 207 | + .with_session_id("real-labels") |
| 208 | + .with_tenant_id("acme-prod") |
| 209 | + .with_principal("svc-bot") |
| 210 | + .with_agent_template_id("planner-v3") |
| 211 | + .with_correlation_id("trace-real-1"); |
| 212 | + let session = agent |
| 213 | + .session("/tmp/real-labels", Some(opts)) |
| 214 | + .expect("session"); |
| 215 | + |
| 216 | + let result = session |
| 217 | + .send("Reply with the single word: ok", None) |
| 218 | + .await |
| 219 | + .expect("real send should succeed"); |
| 220 | + assert!(!result.text.is_empty()); |
| 221 | + |
| 222 | + assert_eq!(session.tenant_id(), Some("acme-prod")); |
| 223 | + assert_eq!(session.principal(), Some("svc-bot")); |
| 224 | + assert_eq!(session.agent_template_id(), Some("planner-v3")); |
| 225 | + assert_eq!(session.correlation_id(), Some("trace-real-1")); |
| 226 | + |
| 227 | + let runs = session.runs().await; |
| 228 | + assert_eq!(runs.len(), 1); |
| 229 | + assert_eq!(runs[0].status, a3s_code_core::run::RunStatus::Completed); |
| 230 | +} |
| 231 | + |
| 232 | +/// `resume_run` against a live model: seed a checkpoint carrying non-zero |
| 233 | +/// cumulative metrics, resume, and confirm the run completes AND the |
| 234 | +/// resumed AgentResult's usage is at least the seeded amount (i.e. metrics |
| 235 | +/// carried forward, not reset to zero) plus the real turn's tokens. |
| 236 | +#[tokio::test(flavor = "multi_thread")] |
| 237 | +#[ignore = "requires real provider credentials and network access"] |
| 238 | +async fn real_resume_run_carries_checkpoint_metrics_forward() { |
| 239 | + use a3s_code_core::llm::{ContentBlock, Message}; |
| 240 | + use a3s_code_core::loop_checkpoint::{LoopCheckpoint, LOOP_CHECKPOINT_SCHEMA_VERSION}; |
| 241 | + |
| 242 | + let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new()); |
| 243 | + let seeded_run = "real-resume-old"; |
| 244 | + let seeded_total = 500u32; |
| 245 | + store |
| 246 | + .save_loop_checkpoint( |
| 247 | + seeded_run, |
| 248 | + &LoopCheckpoint { |
| 249 | + schema_version: LOOP_CHECKPOINT_SCHEMA_VERSION, |
| 250 | + run_id: seeded_run.to_string(), |
| 251 | + session_id: "real-resume".to_string(), |
| 252 | + turn: 1, |
| 253 | + messages: vec![ |
| 254 | + Message::user("Reply with the single word: ok"), |
| 255 | + Message { |
| 256 | + role: "assistant".to_string(), |
| 257 | + content: vec![ContentBlock::Text { |
| 258 | + text: "working".to_string(), |
| 259 | + }], |
| 260 | + reasoning_content: None, |
| 261 | + }, |
| 262 | + ], |
| 263 | + total_usage: TokenUsage { |
| 264 | + prompt_tokens: 400, |
| 265 | + completion_tokens: 100, |
| 266 | + total_tokens: seeded_total as usize, |
| 267 | + cache_read_tokens: None, |
| 268 | + cache_write_tokens: None, |
| 269 | + }, |
| 270 | + tool_calls_count: 2, |
| 271 | + verification_reports: Vec::new(), |
| 272 | + checkpoint_ms: 1_700_000_000_000, |
| 273 | + }, |
| 274 | + ) |
| 275 | + .await |
| 276 | + .expect("seed checkpoint"); |
| 277 | + |
| 278 | + let agent = real_agent().await; |
| 279 | + let opts = SessionOptions::new() |
| 280 | + .with_session_id("real-resume") |
| 281 | + .with_session_store(Arc::clone(&store)); |
| 282 | + let session = agent |
| 283 | + .session("/tmp/real-resume", Some(opts)) |
| 284 | + .expect("session"); |
| 285 | + |
| 286 | + let result = session |
| 287 | + .resume_run(seeded_run) |
| 288 | + .await |
| 289 | + .expect("resume_run against real model should succeed"); |
| 290 | + |
| 291 | + assert!(!result.text.is_empty(), "resumed run produced text"); |
| 292 | + assert!( |
| 293 | + result.usage.total_tokens > seeded_total as usize, |
| 294 | + "resumed usage ({}) must exceed the seeded {} (carried forward + real turn)", |
| 295 | + result.usage.total_tokens, |
| 296 | + seeded_total |
| 297 | + ); |
| 298 | + assert!( |
| 299 | + result.tool_calls_count >= 2, |
| 300 | + "seeded tool-call count must carry forward" |
| 301 | + ); |
| 302 | +} |
0 commit comments