Files
clawmates/crates/cm-runtime/tests/live_anthropic.rs
T
Omar SobhandClaude Fable 5 5407111a89 Live Anthropic validation — and the platform-breaking bug it caught
Running the opt-in live suite (CM_LIVE_LLM=1 + ANTHROPIC_API_KEY) against
the real API immediately surfaced a launch blocker: Anthropic (and
OpenAI) restrict tool names to ^[a-zA-Z0-9_-]{1,128}$ — our ENTIRE
registry uses dotted names (clock.now, email.send, shell.exec, ...).
The scripted provider never enforced the pattern, so every real-model
deployment would have 400'd on the first tool call.

- Fix at the provider boundary, where it belongs: wire_tool_name /
  internal_tool_name codec (dots <-> __) applied in BOTH HTTP providers
  at all three sites (tools list, assistant tool_use echo, inbound
  tool_use decode). Internal naming (DB step rows, scenarios, UI traces)
  unchanged. Offline unit test round-trips every registry name through
  the wire pattern
- New live tests, all passing against api.anthropic.com (Haiku 4.5):
  - provider tool ROUND TRIP: real ToolUse arrives, ToolResult ships
    back exactly as a checkpoint would reassemble it, model completes,
    real usage events on the wire
  - full runtime loop: real model calls clock.now, run completes, REAL
    token usage metered, credits decremented
  - the #1-risk validation: a real model's email.send intercepted ->
    suspended -> approved -> checkpoint RESUMED against the live API ->
    completed -> outbox exactly 1 (checkpoint/resume fidelity end to end)
- Stray TC_OPENAI_COMPAT_* envs renamed to CM_OPENAI_COMPAT_*

No credentials stored anywhere; the key was passed via env only.

163 Rust tests (+5 live, key-gated).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 12:55:51 -05:00

203 lines
6.6 KiB
Rust

//! The COMPLETE production path against the real Anthropic API
//! (CM_LIVE_LLM=1 + ANTHROPIC_API_KEY): a real model drives the runtime
//! loop — ungated tool execution with real usage metering, and the §15
//! chain: gated email intercepted, checkpointed, approved, and RESUMED
//! against the live API (checkpoint/resume fidelity, the plan's #1 risk).
use std::sync::Arc;
use std::time::Duration;
use cm_domain::{
AccessPolicy, Agent, AgentId, AgentStatus, Role, RunState, User, UserId, Workspace, WorkspaceId,
};
use cm_llm::AnthropicProvider;
use cm_runtime::{RunEventBody, Runtime, RuntimeConfig};
use cm_safety::{approvals, Decision};
const MODEL: &str = "claude-haiku-4-5-20251001";
fn live() -> Option<String> {
if std::env::var("CM_LIVE_LLM").as_deref() != Ok("1") {
eprintln!("skipped: set CM_LIVE_LLM=1 to run");
return None;
}
match std::env::var("ANTHROPIC_API_KEY") {
Ok(key) => Some(key),
Err(_) => {
eprintln!("skipped: ANTHROPIC_API_KEY not set");
None
}
}
}
async fn seeded(pool: &sqlx::PgPool) -> (Workspace, User, Agent) {
let ws = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
cm_db::repo::workspaces::insert(pool, &ws).await.unwrap();
let owner = User {
id: UserId::new(),
workspace_id: ws.id,
email: format!("{}@acme.test", UserId::new()),
role: Role::Owner,
display_name: "Owner".into(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
};
cm_db::repo::users::insert(pool, &owner).await.unwrap();
let agent = Agent {
id: AgentId::new(),
workspace_id: ws.id,
name: "Scout".into(),
job_title: "Analyst".into(),
system_prompt: "Be terse. Use your tools when they apply.".into(),
avatar: String::new(),
accent: String::new(),
wallpaper: String::new(),
managed_by: owner.id,
status: AgentStatus::Online,
};
cm_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
.await
.unwrap();
cm_db::repo::credits::add_lot(pool, ws.id, 1000, "live")
.await
.unwrap();
(ws, owner, agent)
}
#[tokio::test]
async fn a_real_model_runs_tools_and_gets_metered() {
let Some(key) = live() else { return };
let pool = cm_testkit::test_pool().await;
let (ws, _, agent) = seeded(&pool).await;
let rt = Runtime::new(
pool.clone(),
Arc::new(AnthropicProvider::new(key)),
RuntimeConfig::basic(MODEL, 600),
);
let session = cm_db::repo::sessions::create(&pool, agent.id, ws.id, "Live")
.await
.unwrap();
let started = rt
.send_message(
session.id,
"What is the current UTC time? Use your clock tool, then state it.",
)
.await
.unwrap();
let mut rx = started.events;
while let Ok(envelope) = rx.recv().await {
if matches!(
envelope.event,
RunEventBody::RunCompleted { .. } | RunEventBody::Error { .. }
) {
break;
}
}
let run = cm_db::repo::runs::get(&pool, started.run_id).await.unwrap();
assert_eq!(run.state, RunState::Completed);
// A real clock.now step happened.
let steps: i64 = sqlx::query_scalar(
"SELECT count(*) FROM steps s JOIN messages m ON m.id = s.message_id
WHERE m.session_id = $1 AND s.tool_name = 'clock.now' AND s.status = 'ok'",
)
.bind(session.id.as_uuid())
.fetch_one(&pool)
.await
.unwrap();
assert!(steps >= 1, "the model must have called the clock");
// Real usage metered, real credits burned.
let (tin, tout): (i64, i64) = sqlx::query_as(
"SELECT COALESCE(SUM(tokens_in),0)::BIGINT, COALESCE(SUM(tokens_out),0)::BIGINT
FROM usage_events WHERE workspace_id = $1",
)
.bind(ws.id.as_uuid())
.fetch_one(&pool)
.await
.unwrap();
assert!(tin > 0 && tout > 0, "real token usage: in={tin} out={tout}");
assert!(
cm_db::repo::credits::balance(&pool, ws.id).await.unwrap() < 1000,
"credits decremented"
);
}
#[tokio::test]
async fn the_gated_chain_suspends_and_resumes_against_the_live_api() {
let Some(key) = live() else { return };
let pool = cm_testkit::test_pool().await;
let (ws, owner, agent) = seeded(&pool).await;
let rt = Runtime::new(
pool.clone(),
Arc::new(AnthropicProvider::new(key)),
RuntimeConfig::basic(MODEL, 600),
);
let session = cm_db::repo::sessions::create(&pool, agent.id, ws.id, "Live gated")
.await
.unwrap();
let started = rt
.send_message(
session.id,
"Use your email tool to send [email protected] an email with subject \
'Q2' and body 'Numbers attached.' Do it now without asking.",
)
.await
.unwrap();
let mut rx = started.events;
let mut suspended = false;
while let Ok(envelope) = rx.recv().await {
match envelope.event {
RunEventBody::RunSuspended { .. } => {
suspended = true;
break;
}
RunEventBody::Error { .. } | RunEventBody::RunCompleted { .. } => break,
_ => {}
}
}
assert!(suspended, "a REAL model's email.send must be intercepted");
let outbox: i64 = sqlx::query_scalar("SELECT count(*) FROM outbox")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(outbox, 0, "nothing sends while pending");
// Approve → the checkpoint resumes against the LIVE API: the stored
// assistant tool_use turn + our tool_result must reassemble into a
// request Anthropic accepts (checkpoint fidelity, end to end).
let pending = approvals::list_pending(&pool, ws.id).await.unwrap();
assert_eq!(pending.len(), 1);
approvals::decide(&pool, pending[0].id, owner.id, Decision::Approve)
.await
.unwrap();
rt.resume_run(cm_safety::ResumeReady {
run_id: started.run_id,
approval_id: pending[0].id,
approved: true,
})
.await
.unwrap();
let mut completed = false;
for _ in 0..240 {
let run = cm_db::repo::runs::get(&pool, started.run_id).await.unwrap();
if run.state == RunState::Completed {
completed = true;
break;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
assert!(completed, "resume against the live API must complete");
let outbox: i64 = sqlx::query_scalar("SELECT count(*) FROM outbox")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(outbox, 1, "approved email sent exactly once");
}