use crate::{bytes_to_hex_str, default_persona, ensure_diamond_allowance, fmt_duration, fmt_lh, non_blank, parse_address, registry, resolve_caller_key, resolve_caller_label, take_as_flag, wallet, CALL_COST_WEI, CALL_METER_TOPUP_WEI};
pub(crate) struct ParsedCall {
caller: Option<String>,
fresh: bool,
model: Option<String>,
pay: Option<String>,
verify: Option<Vec<String>>,
target: String,
message: String,
}
pub(crate) const CALL_USAGE: &str =
"usage: localharness call [--as <yourname>] [--fresh] [--model <id>] [--pay <amount>] [--verify <keys>] <target> <message>";
pub(crate) fn parse_verify_keys(spec: &str) -> Vec<String> {
spec.split(',')
.map(str::trim)
.filter(|k| !k.is_empty())
.map(str::to_string)
.collect()
}
pub(crate) fn parse_call_args(rest: &[String]) -> Result<ParsedCall, String> {
let (caller, rest) = take_as_flag(rest)?;
let mut fresh = false;
let mut model = None;
let mut pay = None;
let mut verify = None;
let mut i = 0;
while i < rest.len() {
match rest[i].as_str() {
"--model" => match rest.get(i + 1) {
Some(m) => {
model = Some(m.clone());
i += 2;
}
None => return Err(CALL_USAGE.to_string()),
},
"--pay" => match rest.get(i + 1) {
Some(p) => {
pay = Some(p.clone());
i += 2;
}
None => return Err(CALL_USAGE.to_string()),
},
"--verify" => match rest.get(i + 1) {
Some(v) => {
verify = Some(parse_verify_keys(v));
i += 2;
}
None => return Err(CALL_USAGE.to_string()),
},
"--fresh" => {
fresh = true;
i += 1;
}
_ => break,
}
}
match rest[i..].split_first() {
Some((t, msg)) if !msg.is_empty() => {
if let Some(f) = msg
.iter()
.find(|w| matches!(w.as_str(), "--pay" | "--model" | "--verify" | "--fresh"))
{
return Err(format!(
"`{f}` must come BEFORE the target (e.g. `call {f} … {t} \"message\"`) — \
after the target it is swallowed into the message and ignored.\n{CALL_USAGE}"
));
}
Ok(ParsedCall {
caller,
fresh,
model,
pay,
verify,
target: t.clone(),
message: msg.join(" "),
})
}
_ => Err(CALL_USAGE.to_string()),
}
}
pub(crate) fn verify_reply(reply: &str, required: &[String]) -> Result<(), String> {
let trimmed = reply.trim();
let json = match (trimmed.find('{'), trimmed.rfind('}')) {
(Some(a), Some(b)) if b >= a => &trimmed[a..=b],
_ => trimmed,
};
let value: serde_json::Value =
serde_json::from_str(json).map_err(|_| "reply not JSON".to_string())?;
let obj = value
.as_object()
.ok_or_else(|| "reply not a JSON object".to_string())?;
for key in required {
if !obj.contains_key(key) {
return Err(format!("missing key '{key}'"));
}
}
Ok(())
}
pub(crate) fn history_dir() -> std::path::PathBuf {
std::path::Path::new(".localharness").join("history")
}
pub(crate) fn model_backend_tag(model: Option<&str>) -> &'static str {
if model.map(|m| m.starts_with("claude")).unwrap_or(false) {
"anthropic"
} else {
"gemini"
}
}
pub(crate) fn history_path(caller_label: &str, target: &str, backend: &str) -> std::path::PathBuf {
history_dir().join(format!("{caller_label}__{target}.{backend}.bin"))
}
pub(crate) fn thread_file_target(caller_label: &str, file_name: &str) -> Option<String> {
let stem = file_name
.strip_prefix(&format!("{caller_label}__"))?
.strip_suffix(".bin")
.filter(|t| !t.is_empty())?;
let target = stem
.strip_suffix(".gemini")
.or_else(|| stem.strip_suffix(".anthropic"))
.unwrap_or(stem);
if target.is_empty() {
return None;
}
Some(target.to_string())
}
pub(crate) fn hint_for_call_error(err: &str) -> Option<&'static str> {
let e = err.to_ascii_lowercase();
if e.contains("below") && e.contains("price") {
return Some(
"your --pay is under the agent's advertised price — use `--pay auto` \
(the default) to pay exactly its price, or `whoami <name>` to see it.",
);
}
if e.contains("402")
|| e.contains("payment")
|| e.contains("no session")
|| e.contains("insufficient")
|| e.contains("credit")
{
return Some(
"the credit proxy has no $LH for your identity. `call` meters \
~1 $LH per request, so a fresh identity must be funded first — \
run `localharness redeem <code>`, or have another agent `send` you $LH.",
);
}
if e.contains("401")
|| e.contains("403")
|| e.contains("unauthorized")
|| e.contains("forbidden")
|| e.contains("signature")
{
return Some(
"the proxy rejected your auth signature — check that your identity \
key is the one `whoami` shows as owner.",
);
}
if e.contains("429") || e.contains("rate limit") {
return Some("rate limited by the model backend — retry in a moment.");
}
None
}
pub(crate) fn report_call_error(prefix: &str, err: &str) {
eprintln!("{prefix}: {err}");
if let Some(hint) = hint_for_call_error(err) {
eprintln!(" hint: {hint}");
}
}
pub(crate) async fn call(rest: &[String]) -> i32 {
let ParsedCall {
caller,
fresh,
model,
pay,
verify,
target,
message,
} = match parse_call_args(rest) {
Ok(p) => p,
Err(usage) => {
eprintln!("{usage}");
return 2;
}
};
if let Err(e) = non_blank(&message, "call: message") {
eprintln!("{e}");
return 1;
}
let pay_wei = match pay.as_deref() {
None => None,
Some("auto") => match registry::id_of_name(&target).await {
Ok(id) if id != 0 => match registry::x402_ask_price_of(id).await {
Ok(wei) => {
println!("--pay auto: '{target}' asks {}/call (paid to the agent; the model run also meters ~1 LH)", fmt_lh(wei));
Some(wei)
}
Err(e) => {
eprintln!("--pay auto: price lookup failed: {e}");
return 1;
}
},
Ok(_) => {
eprintln!("--pay auto: '{target}' is not a registered agent");
return 2;
}
Err(e) => {
eprintln!("--pay auto: RPC error: {e}");
return 1;
}
},
Some(p) => match localharness::encoding::parse_token_amount(p) {
Some(v) if v > 0 => Some(v),
_ => {
eprintln!("--pay must be a positive $LH amount (e.g. 0.001) or 'auto', got '{p}'");
return 2;
}
},
};
let (_key_file, key_hex) = match resolve_caller_key(caller.as_deref()) {
Ok(c) => c,
Err(e) => {
eprintln!("{e}");
return 2;
}
};
let caller_label = match resolve_caller_label(caller.as_deref()) {
Ok(l) => l,
Err(e) => {
eprintln!("{e}");
return 2;
}
};
let backend = model_backend_tag(model.as_deref());
let hist_file = history_path(&caller_label, &target, backend);
let prior_history = if fresh {
let _ = std::fs::remove_file(&hist_file);
None
} else {
std::fs::read(&hist_file).ok()
};
match run_agent_turn(&key_hex, &target, &message, prior_history, model.as_deref()).await {
Ok((text, new_history)) => {
if text.trim().is_empty() {
eprintln!("call: the agent returned no text — no payment settled");
return 1;
}
println!("{}", text.trim());
if let Some(bytes) = new_history {
if let Some(dir) = hist_file.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(&hist_file, bytes);
}
if let (Some(required), Some(value_wei)) = (verify.as_deref(), pay_wei) {
if let Err(reason) = verify_reply(&text, required) {
eprintln!(
"--verify: {reason} — payment NOT sent ({} withheld)",
fmt_lh(value_wei)
);
return 1;
}
}
match pay_wei {
Some(value_wei) => settle_call_payment(&key_hex, &target, value_wei).await,
None => 0,
}
}
Err(e) => {
report_call_error("call failed", &e);
1
}
}
}
async fn settle_call_payment(key_hex: &str, target: &str, value_wei: u128) -> i32 {
let signer = match wallet::from_private_key_hex(key_hex) {
Ok(s) => s,
Err(e) => {
eprintln!("--pay: bad identity key: {e}");
return 1;
}
};
let from = wallet::address(&signer);
let from_hex = bytes_to_hex_str(&from);
let to_hex = match registry::tba_of_name(target).await {
Ok(Some(t)) => t,
Ok(None) => {
eprintln!("--pay: '{target}' has no token-bound account to receive payment");
return 1;
}
Err(e) => {
eprintln!("--pay: RPC error resolving {target}: {e}");
return 1;
}
};
let to = match parse_address(&to_hex) {
Ok(b) => b,
Err(_) => {
eprintln!("internal: bad TBA address for {target}: {to_hex}");
return 1;
}
};
if let Err(code) = ensure_diamond_allowance(&signer, &from_hex, value_wei).await {
return code;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let valid_before = now + 3600;
let nonce = registry::random_x402_nonce();
let signature = match registry::sign_x402(
&signer,
&from,
&to,
value_wei,
0,
valid_before,
&nonce,
) {
Ok(s) => s,
Err(e) => {
eprintln!("--pay: could not sign x402 authorization: {e}");
return 1;
}
};
match registry::settle_x402_sponsored(&signer, &from, &to, value_wei, 0, valid_before, &nonce, &signature)
.await
{
Ok(tx) => {
println!("paid {} to {target}'s account {to_hex} (tx {tx})", fmt_lh(value_wei));
0
}
Err(e) => {
report_call_error("--pay settlement failed", &e);
1
}
}
}
pub(crate) async fn ensure_meter_funded(caller: &k256::ecdsa::SigningKey) {
let addr = bytes_to_hex_str(&wallet::address(caller));
if registry::credit_balance_of(&addr).await.unwrap_or(0) >= CALL_COST_WEI {
return;
}
let deposit = || {
registry::deposit_credits_sponsored(caller, CALL_METER_TOPUP_WEI)
};
match deposit().await {
Ok(_) => {}
Err(e) if crate::colony::is_transient_rpc_error(&e) => {
if let Err(e2) = deposit().await {
eprintln!("warning: meter top-up failed twice ({e2}) — the call may 402");
}
}
Err(e) => {
eprintln!("warning: meter top-up failed ({e}) — the call may 402");
}
}
}
const X402_PAYMENT_HEADER: &str = "X-PAYMENT";
fn provider_and_model(model: Option<&str>) -> (&'static str, &str) {
match model {
Some(m) if m.starts_with("claude") => ("anthropic", m),
_ => ("gemini", ""),
}
}
fn price_wei_for_model(prices: &serde_json::Value, provider: &str, model: &str) -> Option<u128> {
let rows = prices.as_array()?;
let lookup = |want_model: &str| -> Option<u128> {
rows.iter().find_map(|r| {
if r.get("provider")?.as_str()? == provider && r.get("model")?.as_str()? == want_model {
r.get("price_wei")?.as_str()?.parse::<u128>().ok()
} else {
None
}
})
};
lookup(model).or_else(|| lookup("*"))
}
async fn try_build_x402_payment(
caller: &k256::ecdsa::SigningKey,
model: Option<&str>,
) -> Option<(String, String)> {
let base = registry::CREDIT_PROXY_URL.trim_end_matches('/');
let prices: serde_json::Value = reqwest::Client::new()
.get(format!("{base}/prices"))
.send()
.await
.ok()?
.json()
.await
.ok()?;
let payee_hex = prices.get("x402")?.get("payTo")?.as_str()?.to_string();
let payee = parse_address(&payee_hex).ok()?;
let (provider, model_id) = provider_and_model(model);
let cost = price_wei_for_model(prices.get("prices")?, provider, model_id)?;
let from = wallet::address(caller);
let from_hex = bytes_to_hex_str(&from);
if registry::token_balance_of(&from_hex).await.unwrap_or(0) < cost {
return None;
}
if ensure_diamond_allowance(caller, &from_hex, cost).await.is_err() {
return None;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let valid_before = now + 300;
let nonce = registry::random_x402_nonce();
let sig = registry::sign_x402(caller, &from, &payee, cost, 0, valid_before, &nonce).ok()?;
let auth = registry::x402_authorization_json(
&from_hex,
&payee_hex,
cost,
0,
valid_before,
&nonce,
&sig,
);
eprintln!(
"x402: paying {} per call from your WALLET (x402 settle to the platform payee; the meter is untouched)",
fmt_lh(cost)
);
Some((X402_PAYMENT_HEADER.to_string(), auth.to_string()))
}
pub(crate) async fn run_agent_turn(
key_hex: &str,
target: &str,
message: &str,
prior_history: Option<Vec<u8>>,
model: Option<&str>,
) -> Result<(String, Option<Vec<u8>>), String> {
let caller =
wallet::from_private_key_hex(key_hex).map_err(|e| format!("bad identity key: {e}"))?;
let target_id = match registry::id_of_name(target).await {
Ok(id) if id != 0 => id,
Ok(_) => return Err(format!("{target} is not a registered agent")),
Err(e) => return Err(format!("RPC error: {e}")),
};
let mut system = match registry::persona_of(target_id).await {
Ok(Some(p)) => p,
Ok(None) => default_persona(target),
Err(e) => return Err(format!("RPC error reading persona: {e}")),
};
let chain = registry::chain::active();
system = format!(
"You run on the localharness platform — a self-sovereign agent platform — on \
{} (EVM chain id {}). Its credit token is $LH and all payments and state \
settle on this chain; never claim to run on any other blockchain.\n\n{system}",
chain.name, chain.chain_id
);
if let Ok(Some(lessons)) = registry::lessons_of(target_id).await {
if let Some(section) = localharness::lessons::compose_section(&lessons) {
system = format!("{system}\n\n{section}");
}
}
if let Ok(Some(skills)) = registry::skills_of(target_id).await {
if let Some(section) = localharness::skills::compose_section(&skills) {
system = format!("{system}\n\n{section}");
}
}
let price_wei = registry::x402_ask_price_of(target_id)
.await
.unwrap_or(registry::DEFAULT_ASK_PRICE_WEI);
system = format!(
"{system}\n\nYour advertised price is {} per paid call; callers may have paid it. Never claim to be free or sponsored.",
fmt_lh(price_wei)
);
let x402_header = try_build_x402_payment(&caller, model).await;
if x402_header.is_none() {
ensure_meter_funded(&caller).await;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let token = registry::proxy_auth_token(&caller, now, "gemini");
let base = url::Url::parse(registry::CREDIT_PROXY_URL)
.map_err(|e| format!("internal: bad proxy url: {e}"))?;
let caps = localharness::types::CapabilitiesConfig {
enabled_tools: Some(Vec::new()),
enable_subagents: false,
..Default::default()
};
if model.map(|m| m.starts_with("claude")).unwrap_or(false) {
#[cfg(feature = "anthropic")]
{
let model = model.unwrap().to_string();
let build = |history: Option<Vec<u8>>| {
let mut cfg = localharness::AnthropicAgentConfig::new(token.clone())
.with_base_url(base.clone())
.with_model(model.clone())
.with_system_instructions(system.clone())
.with_capabilities(caps.clone());
if let Some((name, value)) = x402_header.clone() {
cfg = cfg.with_extra_header(name, value);
}
if let Some(bytes) = history {
cfg = cfg.with_history_bytes(bytes);
}
cfg
};
let agent =
start_with_history_fallback(target, prior_history, "anthropic session", |h| {
localharness::Agent::start_anthropic(build(h))
})
.await?;
return run_turn_and_persist(agent, message).await;
}
#[cfg(not(feature = "anthropic"))]
{
return Err("Claude models require a build with `--features anthropic`".to_string());
}
}
let build = |history: Option<Vec<u8>>| {
let mut cfg = localharness::GeminiAgentConfig::new(token.clone())
.with_base_url(base.clone())
.with_system_instructions(system.clone())
.with_capabilities(caps.clone());
if let Some((name, value)) = x402_header.clone() {
cfg = cfg.with_extra_header(name, value);
}
if let Some(bytes) = history {
cfg = cfg.with_history_bytes(bytes);
}
cfg
};
let agent = start_with_history_fallback(target, prior_history, "agent session", |h| {
localharness::Agent::start_gemini(build(h))
})
.await?;
run_turn_and_persist(agent, message).await
}
async fn start_with_history_fallback<F, Fut>(
target: &str,
prior_history: Option<Vec<u8>>,
label: &str,
start: F,
) -> Result<localharness::Agent, String>
where
F: Fn(Option<Vec<u8>>) -> Fut,
Fut: std::future::Future<Output = localharness::Result<localharness::Agent>>,
{
match start(prior_history.clone()).await {
Ok(a) => Ok(a),
Err(_) if prior_history.is_some() => {
eprintln!(
"warning: could not load saved conversation with {target} \
(incompatible or corrupt) — starting a fresh thread"
);
start(None)
.await
.map_err(|e| format!("could not start {label}: {e}"))
}
Err(e) => Err(format!("could not start {label}: {e}")),
}
}
async fn run_turn_and_persist(
agent: localharness::Agent,
message: &str,
) -> Result<(String, Option<Vec<u8>>), String> {
let reply = match agent.chat(message).await {
Ok(resp) => resp.text().await.map_err(|e| format!("response error: {e}")),
Err(e) => Err(e.to_string()),
};
let new_history = agent.history_bytes().ok().flatten();
let _ = agent.shutdown().await;
reply.map(|text| (text, new_history))
}
pub(crate) fn threads(caller_name: Option<&str>) -> i32 {
let label = match resolve_caller_label(caller_name) {
Ok(l) => l,
Err(e) => {
eprintln!("{e}");
return 2;
}
};
let now = std::time::SystemTime::now();
let mut found: Vec<(String, Option<u64>)> = match std::fs::read_dir(history_dir()) {
Ok(rd) => rd
.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name().into_string().ok()?;
let target = thread_file_target(&label, &name)?;
let age = e
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|t| now.duration_since(t).ok())
.map(|d| d.as_secs());
Some((target, age))
})
.collect(),
Err(_) => Vec::new(),
};
found.sort();
if found.is_empty() {
println!("no saved conversations for {label}");
return 0;
}
println!("conversations for {label}:");
for (target, age) in found {
match age {
Some(secs) => println!(" {target} (last active {} ago)", fmt_duration(secs)),
None => println!(" {target}"),
}
}
0
}
pub(crate) fn forget(caller_name: Option<&str>, target: &str) -> i32 {
let label = match resolve_caller_label(caller_name) {
Ok(l) => l,
Err(e) => {
eprintln!("{e}");
return 2;
}
};
if target == "--all" {
let mut n = 0;
if let Ok(rd) = std::fs::read_dir(history_dir()) {
for e in rd.flatten() {
let Ok(name) = e.file_name().into_string() else {
continue;
};
if thread_file_target(&label, &name).is_some()
&& std::fs::remove_file(e.path()).is_ok()
{
n += 1;
}
}
}
println!("forgot {n} conversation(s) for {label}");
return 0;
}
let mut removed = false;
for candidate in [
history_path(&label, target, "gemini"),
history_path(&label, target, "anthropic"),
history_dir().join(format!("{label}__{target}.bin")), ] {
if std::fs::remove_file(candidate).is_ok() {
removed = true;
}
}
if removed {
println!("forgot conversation with {target}");
} else {
println!("no saved conversation with {target}");
}
0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::args;
#[test]
fn parse_call_plain_target_and_message() {
let p = parse_call_args(&args(&["alice", "how", "are", "you"])).unwrap();
assert_eq!(p.caller, None);
assert_eq!(p.target, "alice");
assert_eq!(p.message, "how are you");
}
#[test]
fn parse_call_single_word_message() {
let p = parse_call_args(&args(&["alice", "hello"])).unwrap();
assert_eq!(p.caller, None);
assert_eq!(p.target, "alice");
assert_eq!(p.message, "hello");
}
#[test]
fn parse_call_with_as_flag() {
let p = parse_call_args(&args(&["--as", "bob", "alice", "what's", "up"])).unwrap();
assert_eq!(p.caller.as_deref(), Some("bob"));
assert!(!p.fresh);
assert_eq!(p.target, "alice");
assert_eq!(p.message, "what's up");
}
#[test]
fn parse_call_fresh_flag() {
let p = parse_call_args(&args(&["--fresh", "alice", "hi"])).unwrap();
assert!(p.fresh);
assert_eq!(p.caller, None);
assert_eq!(p.target, "alice");
assert_eq!(p.message, "hi");
}
#[test]
fn parse_call_flags_order_independent() {
let a = parse_call_args(&args(&["--as", "bob", "--fresh", "alice", "hi"])).unwrap();
let b = parse_call_args(&args(&["--fresh", "--as", "bob", "alice", "hi"])).unwrap();
for p in [a, b] {
assert_eq!(p.caller.as_deref(), Some("bob"));
assert!(p.fresh);
assert_eq!(p.target, "alice");
assert_eq!(p.message, "hi");
}
}
#[test]
fn parse_call_rejects_a_leading_flag_placed_after_the_target() {
for parts in [
vec!["alice", "hi", "--pay", "auto"],
vec!["--as", "bob", "alice", "hi", "--pay", "0.01"],
vec!["alice", "hi", "--model", "claude-opus"],
] {
match parse_call_args(&args(&parts)) {
Err(e) => assert!(e.contains("must come BEFORE the target"), "got: {e}"),
Ok(_) => panic!("expected an error for a leading flag placed after the target: {parts:?}"),
}
}
let p = parse_call_args(&args(&["--as", "bob", "--pay", "auto", "alice", "hi"])).unwrap();
assert_eq!(p.pay.as_deref(), Some("auto"));
assert_eq!(p.message, "hi");
}
#[test]
fn parse_call_accepts_model_flag_in_any_order() {
let perms = [
vec!["--model", "claude-opus", "--as", "bob", "--fresh", "alice", "hi"],
vec!["--fresh", "--model", "claude-opus", "--as", "bob", "alice", "hi"],
vec!["--as", "bob", "--model", "claude-opus", "--fresh", "alice", "hi"],
];
for parts in perms {
let p = parse_call_args(&args(&parts)).unwrap();
assert_eq!(p.caller.as_deref(), Some("bob"));
assert_eq!(p.model.as_deref(), Some("claude-opus"));
assert!(p.fresh);
assert_eq!(p.target, "alice");
assert_eq!(p.message, "hi");
}
assert!(parse_call_args(&args(&["--model"])).is_err());
}
#[test]
fn parse_call_defaults_to_not_fresh() {
let p = parse_call_args(&args(&["alice", "hi"])).unwrap();
assert!(!p.fresh);
}
#[test]
fn parse_call_pay_flag() {
let p = parse_call_args(&args(&["alice", "hi"])).unwrap();
assert_eq!(p.pay, None);
for parts in [
vec!["--pay", "0.05", "alice", "hi"],
vec!["--fresh", "--pay", "0.05", "alice", "hi"],
vec!["--pay", "0.05", "--fresh", "alice", "hi"],
] {
let p = parse_call_args(&args(&parts)).unwrap();
assert_eq!(p.pay.as_deref(), Some("0.05"));
assert_eq!(p.target, "alice");
assert_eq!(p.message, "hi");
}
assert!(parse_call_args(&args(&["--pay"])).is_err());
}
#[test]
fn parse_call_verify_flag() {
let p = parse_call_args(&args(&["alice", "hi"])).unwrap();
assert_eq!(p.verify, None);
for parts in [
vec!["--verify", "answer,score", "alice", "hi"],
vec!["--pay", "0.05", "--verify", "answer, score", "alice", "hi"],
vec!["--verify", "answer ,score", "--pay", "auto", "alice", "hi"],
] {
let p = parse_call_args(&args(&parts)).unwrap();
assert_eq!(
p.verify.as_deref(),
Some(["answer".to_string(), "score".to_string()].as_slice())
);
assert_eq!(p.target, "alice");
assert_eq!(p.message, "hi");
}
assert!(parse_call_args(&args(&["--verify"])).is_err());
}
#[test]
fn parse_verify_keys_splits_trims_and_drops_blanks() {
assert_eq!(parse_verify_keys("a,b,c"), vec!["a", "b", "c"]);
assert_eq!(parse_verify_keys(" a , b ,, c,"), vec!["a", "b", "c"]);
assert_eq!(parse_verify_keys("answer"), vec!["answer"]);
assert!(parse_verify_keys(" , , ").is_empty());
}
#[test]
fn verify_reply_accepts_object_with_all_keys() {
let required = vec!["answer".to_string(), "score".to_string()];
assert!(verify_reply(r#"{"answer":"yes","score":9}"#, &required).is_ok());
assert!(verify_reply(" {\"answer\":1,\"score\":2} \n", &required).is_ok());
assert!(verify_reply(r#"{"answer":1,"score":2,"extra":true}"#, &required).is_ok());
assert!(verify_reply(r#"{"x":1}"#, &[]).is_ok());
assert!(verify_reply("```json\n{\"answer\":1,\"score\":2}\n```", &required).is_ok());
assert!(verify_reply("```\n{\"answer\":1,\"score\":2}\n```", &required).is_ok());
assert!(verify_reply("Here you go: {\"answer\":1,\"score\":2}. Done!", &required).is_ok());
}
#[test]
fn verify_reply_rejects_missing_key_non_object_and_non_json() {
let required = vec!["answer".to_string(), "score".to_string()];
let err = verify_reply(r#"{"answer":"yes"}"#, &required).unwrap_err();
assert!(err.contains("missing key 'score'"), "got: {err}");
assert!(verify_reply(r#"["answer","score"]"#, &required)
.unwrap_err()
.contains("not a JSON object"));
assert!(verify_reply("42", &required)
.unwrap_err()
.contains("not a JSON object"));
assert!(verify_reply("the answer is yes", &required)
.unwrap_err()
.contains("not JSON"));
}
#[test]
fn parse_call_message_preserves_internal_spacing_as_single_spaces() {
let p = parse_call_args(&args(&["alice", "a", "b", "c"])).unwrap();
assert_eq!(p.message, "a b c");
}
#[test]
fn parse_call_rejects_missing_message() {
assert!(parse_call_args(&args(&["alice"])).is_err());
}
#[test]
fn parse_call_rejects_empty() {
assert!(parse_call_args(&args(&[])).is_err());
}
#[test]
fn parse_call_rejects_as_without_name() {
assert!(parse_call_args(&args(&["--as"])).is_err());
}
#[test]
fn parse_call_rejects_as_name_without_target_or_message() {
assert!(parse_call_args(&args(&["--as", "bob"])).is_err());
assert!(parse_call_args(&args(&["--as", "bob", "alice"])).is_err());
}
#[test]
fn thread_file_target_parses_own_files_only() {
assert_eq!(
thread_file_target("claude", "claude__alice.gemini.bin").as_deref(),
Some("alice")
);
assert_eq!(
thread_file_target("claude", "claude__alice.anthropic.bin").as_deref(),
Some("alice")
);
assert_eq!(
thread_file_target("claude", "claude__alice.bin").as_deref(),
Some("alice")
);
assert_eq!(
thread_file_target("claude", "claude__a__b.gemini.bin").as_deref(),
Some("a__b")
);
assert_eq!(thread_file_target("claude", "bob__alice.gemini.bin"), None);
assert_eq!(thread_file_target("claude", "claude__alice.txt"), None);
assert_eq!(thread_file_target("claude", "claude__.bin"), None);
assert_eq!(thread_file_target("claude", "claude__.gemini.bin"), None);
assert_eq!(thread_file_target("claude", "unrelated.bin"), None);
}
#[test]
fn thread_file_target_roundtrips_history_path() {
for backend in ["gemini", "anthropic"] {
let p = history_path("claude", "alice", backend);
let name = p.file_name().unwrap().to_str().unwrap();
assert_eq!(thread_file_target("claude", name).as_deref(), Some("alice"));
}
}
#[test]
fn model_backend_tag_routes_claude_to_anthropic() {
assert_eq!(model_backend_tag(Some("claude-opus-4")), "anthropic");
assert_eq!(model_backend_tag(Some("claude")), "anthropic");
assert_eq!(model_backend_tag(Some("gemini-3.5-flash")), "gemini");
assert_eq!(model_backend_tag(None), "gemini");
}
#[test]
fn history_path_keys_on_backend_so_formats_never_collide() {
let g = history_path("claude", "alice", "gemini");
let a = history_path("claude", "alice", "anthropic");
assert_ne!(g, a, "backends must not share a history file");
assert!(g.ends_with("claude__alice.gemini.bin"));
assert!(a.ends_with("claude__alice.anthropic.bin"));
}
#[test]
fn history_path_keys_on_caller_and_target() {
let p = history_path("claude", "alice", "gemini");
assert!(p.ends_with("claude__alice.gemini.bin"));
assert_ne!(
history_path("claude", "alice", "gemini"),
history_path("bob", "alice", "gemini")
);
assert_ne!(
history_path("claude", "alice", "gemini"),
history_path("claude", "bob", "gemini")
);
assert!(p.starts_with(".localharness"));
}
#[test]
fn hint_for_call_error_classifies_common_failures() {
for s in [
"HTTP 402 Payment Required",
"proxy: no session for 0xabc",
"insufficient credit",
] {
assert!(
hint_for_call_error(s).unwrap().contains("$LH"),
"expected $LH hint for {s:?}"
);
}
for s in ["401 Unauthorized", "bad signature", "403 Forbidden"] {
assert!(
hint_for_call_error(s).unwrap().contains("signature"),
"expected auth hint for {s:?}"
);
}
assert!(hint_for_call_error("429 Too Many Requests")
.unwrap()
.contains("rate limited"));
}
#[test]
fn provider_and_model_routes_claude_to_anthropic_else_gemini() {
assert_eq!(provider_and_model(Some("claude-opus-4-8")), ("anthropic", "claude-opus-4-8"));
assert_eq!(provider_and_model(Some("gemini-3.5-flash")), ("gemini", ""));
assert_eq!(provider_and_model(None), ("gemini", ""));
}
#[test]
fn price_wei_for_model_matches_exact_then_falls_back_to_star() {
let prices = serde_json::json!([
{ "provider": "gemini", "model": "*", "price_wei": "10000000000000000" },
{ "provider": "anthropic", "model": "claude-opus-4-8", "price_wei": "200000000000000000" },
{ "provider": "anthropic", "model": "*", "price_wei": "50000000000000000" },
]);
assert_eq!(price_wei_for_model(&prices, "gemini", ""), Some(10_000_000_000_000_000));
assert_eq!(
price_wei_for_model(&prices, "anthropic", "claude-opus-4-8"),
Some(200_000_000_000_000_000)
);
assert_eq!(
price_wei_for_model(&prices, "anthropic", "claude-future"),
Some(50_000_000_000_000_000)
);
assert_eq!(price_wei_for_model(&prices, "openai", "gpt-5"), None);
assert_eq!(price_wei_for_model(&serde_json::json!({}), "gemini", ""), None);
}
#[test]
fn hint_for_call_error_is_case_insensitive_and_silent_on_unknown() {
assert!(hint_for_call_error("PAYMENT REQUIRED").is_some());
assert_eq!(hint_for_call_error("connection reset by peer"), None);
assert_eq!(hint_for_call_error("some unrelated parse error"), None);
}
}