Skip to content

Commit cd991a5

Browse files
committed
test(real-llm): end-to-end cluster-feature tests against a live provider
Add #[ignore] integration tests (core/tests/test_real_llm_cluster_features.rs) that exercise the 3.3.0 LLM-loop features against a real model from .a3s/config.acl — validating paths mock clients cannot: - real_budget_guard_allow_records_actual_usage: record_after_llm receives the provider's ACTUAL non-zero token usage (mocks return fixed/zero). - real_budget_guard_deny_blocks_llm_call: Deny aborts before the provider is contacted; no usage, no history. - real_resume_run_carries_checkpoint_metrics_forward: resume_run against the live model continues cumulative metrics from the checkpoint. - real_run_with_store_leaves_no_dangling_checkpoint: completed real run clears its loop checkpoint (leak-fix lifecycle, end-to-end). - real_identity_labels_survive_live_run: tenant/principal/template/ correlation intact through a live run. Run with: A3S_CONFIG_FILE=/abs/.a3s/config.acl \ cargo test -p a3s-code-core --test test_real_llm_cluster_features \ -- --ignored --nocapture Verified locally: 5 passed against openai/MiniMax-M2.7-highspeed (155s).
1 parent cbd5b20 commit cd991a5

1 file changed

Lines changed: 302 additions & 0 deletions

File tree

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
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

Comments
 (0)