use std::{
io::{Read, Write},
net::{TcpListener, TcpStream},
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
thread,
};
use basis::{
BudgetPool, CollectingSink, ContextConfig, RunError, RunOutcome, RunSpec, Runtime, TurnOptions,
Workspace, WorkspaceBuilder, hooks::HooksConfig, skills::SkillsConfig,
templates::TemplatesConfig,
};
use mentra::ModelSelector;
const INPUT_TOKENS: u64 = 100;
const OUTPUT_TOKENS: u64 = 20;
const ROUND_COST: u64 = INPUT_TOKENS + OUTPUT_TOKENS;
fn offline(workspace: &Path, endpoint: &ScriptedEndpoint) -> WorkspaceBuilder {
Workspace::builder(workspace)
.with_runtime_builder(
Runtime::builder()
.with_base_url(&endpoint.base_url)
.with_api_key("test-key")
.with_ephemeral_history(),
)
.with_model(ModelSelector::Id("test-model".to_string()))
.with_context(ContextConfig {
file_name: "AGENTS.md".to_string(),
global_dir: None,
walk_parents: false,
})
.with_skills(SkillsConfig {
workspace_subdir: PathBuf::from(".basis/skills"),
global_dir: None,
})
.with_templates(TemplatesConfig {
workspace_subdir: PathBuf::from(".basis/templates"),
global_dir: None,
})
.with_hooks(HooksConfig {
workspace_file: PathBuf::from(".basis/hooks.json"),
global_dir: None,
})
}
async fn workspace_on(endpoint: &ScriptedEndpoint) -> Workspace {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("AGENTS.md"), "house rules").expect("write");
offline(dir.path(), endpoint).open().await.expect("opens")
}
#[tokio::test]
async fn a_fan_out_draws_on_one_figure_rather_than_one_each() {
let endpoint = ScriptedEndpoint::start();
let workspace = workspace_on(&endpoint).await;
let pool = BudgetPool::new(500_000);
let mut first = workspace
.prepare(pool.spec("review the tests"))
.expect("mints");
let mut second = workspace
.prepare(pool.spec("review the docs"))
.expect("mints");
let (left, right) = tokio::join!(
first.execute(CollectingSink::default()),
second.execute(CollectingSink::default()),
);
let left = left.expect("the first run completes");
let right = right.expect("the second run completes");
assert!(matches!(left.outcome, RunOutcome::Ok));
assert!(matches!(right.outcome, RunOutcome::Ok));
assert_eq!(
pool.spent(),
left.usage.total_tokens() + right.usage.total_tokens(),
"one allowance, spent by two runs"
);
assert_eq!(pool.spent(), 2 * ROUND_COST);
assert_eq!(pool.remaining(), 500_000 - 2 * ROUND_COST);
}
#[tokio::test]
async fn a_pooled_run_and_its_own_report_agree_on_what_it_spent() {
let endpoint = ScriptedEndpoint::start();
let workspace = workspace_on(&endpoint).await;
let pool = BudgetPool::new(10_000);
let mut run = workspace.prepare(pool.spec("go")).expect("mints");
let report = run
.execute(CollectingSink::default())
.await
.expect("completes");
assert_eq!(report.usage.total_tokens(), ROUND_COST);
assert_eq!(pool.spent(), report.usage.total_tokens());
}
#[tokio::test]
async fn a_pool_that_runs_dry_ends_the_remaining_runs_minting() {
let endpoint = ScriptedEndpoint::start();
let workspace = workspace_on(&endpoint).await;
let pool = BudgetPool::new(ROUND_COST);
let mut first = workspace
.prepare(pool.spec("the one that runs"))
.expect("mints");
first
.execute(CollectingSink::default())
.await
.expect("completes");
assert!(pool.is_exhausted());
assert_eq!(pool.remaining(), 0);
let mut second = workspace
.prepare(pool.spec("the one that does not"))
.expect("minting is still free — spending is what is refused");
let refused = second
.execute(CollectingSink::default())
.await
.expect_err("a spent pool refuses the turn");
assert!(
matches!(
refused,
RunError::BudgetExhausted { limit, spent }
if limit == ROUND_COST && spent == ROUND_COST
),
"the refusal names the figures rather than reading as a provider failure: {refused}"
);
assert_eq!(
endpoint.served(),
1,
"the refused run never reaches the provider"
);
assert!(
second.history().is_empty(),
"and leaves nothing in the conversation"
);
}
#[tokio::test]
async fn a_zero_token_budget_is_what_refusing_avoids() {
let endpoint = ScriptedEndpoint::start();
let workspace = workspace_on(&endpoint).await;
let mut run = workspace.prepare("go").expect("mints");
let report = run
.execute_with_options(
CollectingSink::default(),
TurnOptions::default().with_token_budget(0),
)
.await
.expect("the turn is taken rather than refused");
let RunOutcome::Error { message } = &report.outcome else {
panic!("a zero-budget turn does not answer");
};
assert!(
message.contains("without a final assistant message"),
"the failure reads as a provider problem: {message}"
);
assert_eq!(
report.stopped_by,
Some(basis::Bound::TokenBudget),
"though the bound names itself, which is what tells the two apart"
);
assert_eq!(endpoint.served(), 0, "no round ever ran");
assert_eq!(
run.history().len(),
1,
"yet the prompt stayed in the conversation, unanswered"
);
}
#[tokio::test]
async fn a_second_prompt_on_one_conversation_draws_on_the_same_pool() {
let endpoint = ScriptedEndpoint::start();
let workspace = workspace_on(&endpoint).await;
let pool = BudgetPool::new(ROUND_COST + 1);
let mut run = workspace
.prepare(RunSpec::new("first").with_budget(pool.clone()))
.expect("mints");
run.execute(CollectingSink::default())
.await
.expect("the first turn completes");
assert_eq!(pool.remaining(), 1, "one token short of another round");
let taken = run
.send("second", CollectingSink::default(), basis::AllowAll)
.await;
assert!(
taken.is_ok(),
"a pool with something left still takes the turn"
);
assert!(pool.is_exhausted());
assert_eq!(pool.spent(), 2 * ROUND_COST);
assert_eq!(pool.limit(), ROUND_COST + 1);
let third = run
.send("third", CollectingSink::default(), basis::AllowAll)
.await
.expect_err("and refuses once it has nothing");
assert!(matches!(third, RunError::BudgetExhausted { .. }));
}
#[tokio::test]
async fn spending_recorded_by_hand_draws_the_same_pool_down() {
let endpoint = ScriptedEndpoint::start();
let workspace = workspace_on(&endpoint).await;
let pool = BudgetPool::new(1_000);
pool.record(basis::RunUsage {
input_tokens: 1_000,
..basis::RunUsage::default()
});
let mut run = workspace.prepare(pool.spec("go")).expect("mints");
let refused = run
.execute(CollectingSink::default())
.await
.expect_err("the pool was spent before any run touched it");
assert!(matches!(refused, RunError::BudgetExhausted { .. }));
assert_eq!(endpoint.served(), 0);
}
struct ScriptedEndpoint {
base_url: String,
served: Arc<AtomicUsize>,
}
impl ScriptedEndpoint {
fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test endpoint");
let address = listener.local_addr().expect("read endpoint address");
let served = Arc::new(AtomicUsize::new(0));
let counted = Arc::clone(&served);
thread::spawn(move || {
while let Ok((stream, _)) = listener.accept() {
let index = counted.fetch_add(1, Ordering::SeqCst) + 1;
thread::spawn(move || answer(stream, index));
}
});
Self {
base_url: format!("http://{address}/"),
served,
}
}
fn served(&self) -> usize {
self.served.load(Ordering::SeqCst)
}
}
fn answer(mut stream: TcpStream, index: usize) {
read_http_request(&mut stream);
let body = sse_body(index);
let response = format!(
"HTTP/1.1 200 OK\r\nconnection: close\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes());
}
fn sse_body(index: usize) -> String {
[
format!(
r#"{{"type":"response.created","response":{{"id":"resp_{index}","model":"test-model","status":"in_progress"}}}}"#
),
r#"{"type":"response.output_item.added","output_index":0,"item":{"type":"message","content":[]}}"#.to_string(),
format!(
r#"{{"type":"response.output_item.done","output_index":0,"item":{{"type":"message","content":[{{"type":"output_text","text":"reply-{index}"}}]}}}}"#
),
format!(
r#"{{"type":"response.completed","response":{{"id":"resp_{index}","model":"test-model","status":"completed","usage":{{"input_tokens":{INPUT_TOKENS},"output_tokens":{OUTPUT_TOKENS},"total_tokens":{ROUND_COST}}}}}}}"#
),
]
.iter()
.map(|event| format!("data: {event}\n\n"))
.collect()
}
fn read_http_request(stream: &mut TcpStream) {
let mut bytes = Vec::new();
let mut buffer = [0_u8; 4096];
let mut header_end = None;
let mut content_length = 0_usize;
loop {
let Ok(read) = stream.read(&mut buffer) else {
return;
};
if read == 0 {
return;
}
bytes.extend_from_slice(&buffer[..read]);
if header_end.is_none()
&& let Some(index) = bytes.windows(4).position(|window| window == b"\r\n\r\n")
{
let end = index + 4;
header_end = Some(end);
content_length = String::from_utf8_lossy(&bytes[..end])
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().unwrap_or_default())
})
.unwrap_or_default();
}
if header_end.is_some_and(|end| bytes.len() >= end + content_length) {
return;
}
}
}