test(agent): scripted evals follow only next_steps; the surface over the wire (P5)
Agent reason: the envelope is only worth having if an agent can do its work by following it. crates/ledger-cli/tests/agent_evals/ holds six scripted transcripts, no model: onboarding to a first post, a receipt-backed post, a draft that waits for its receipt and posts when it arrives, a reversal, a reconciliation, a month-end close. agent_evals_follow_only_next_steps plays each against a real books server: after books.start a step may call only a tool (or fetch only a prompt) the previous result's next_steps named, with the arguments the step gave and the ones the agent decides; setup calls (the chart and the books an agent finds) are not the agent's moves. agent_surface.rs holds the over-the-wire tests of the rest: the journal in words (order, subject and type filters, paging, a bad subject refused), source corrections through the three tools, the prompts rendered against their files and refused when malformed, the resource templates advertised and read, books.start v2 whole and under 8 KiB with the self-report in the access log only, a new book told to onboard, and a mutating call answering with the head the journal has and a refusal naming its remedy. Docs: testing.md's P5 section (every test, and the For-P12 paragraph naming what a mutant would flip), parity.md (no feature moved; one row), and architecture.md (the envelope and the guards); mcp-interface.md's overview sections now describe the surface as built. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
1 parent
a319a64c3a
commit
7bac25496e
12 files changed
+1358
-5
No files matched your search
@@ -0,0 +1,185 @@
|
||||
//! Agent evals (plan P5 step 7): scripted transcripts, no model. Each is a
|
||||
//! JSON file in `agent_evals/`, played against a real books server through
|
||||
//! the wire as an agent reaches it. The rule they enforce is the point of
|
||||
//! the result envelope: **an agent that begins with `books.start` can do
|
||||
//! its work by calling only what the previous result's `next_steps` named.**
|
||||
//!
|
||||
//! A transcript is `{ "setup": [..], "settings": "<toml>", "steps": [..] }`.
|
||||
//! `setup` calls are made directly (the chart and the books an agent finds
|
||||
//! when it arrives; they are not the agent's moves). A step is one of:
|
||||
//!
|
||||
//! - `{"call": "books.start", "args": {..}}`: only the first step, and
|
||||
//! only `books.start`.
|
||||
//! - `{"follow": "books.x", "with": {..}}`: the previous result's
|
||||
//! `next_steps` must name tool `books.x`; the call is made with that
|
||||
//! step's `args` and `with` laid over them (what the agent decides: an
|
||||
//! amount, a date).
|
||||
//! - `{"follow_prompt": "onboard"}`: the previous result's `next_steps`
|
||||
//! must name that prompt; it is fetched with `prompts/get`.
|
||||
//! - `{"expect_next_steps_contains": "books.x"}` and
|
||||
//! `{"expect": {"/json/pointer": value}}` on the previous result.
|
||||
//!
|
||||
//! In `with` and `args`, `{"$upload": {"name", "mime", "text"}}` is the
|
||||
//! upload_id of that file, uploaded first as an agent does, and
|
||||
//! `{"$var": "x"}` is a value a setup call saved with `"save": {"x":
|
||||
//! "/pointer"}`.
|
||||
//!
|
||||
//! Expected behavior comes from `docs/mcp-interface.md` ("The result
|
||||
//! envelope") and plan 4.6.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{Books, Wire, answer_of};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A value with `$upload` and `$var` laid in.
|
||||
fn resolve(w: &mut Wire, vars: &HashMap<String, Value>, v: &Value) -> Value {
|
||||
match v {
|
||||
Value::Object(o) if o.len() == 1 && o.contains_key("$upload") => {
|
||||
let u = &o["$upload"];
|
||||
let text = u["text"].as_str().expect("an upload has text");
|
||||
json!(w.upload(
|
||||
text.as_bytes(),
|
||||
u["name"].as_str().unwrap_or("file.pdf"),
|
||||
u["mime"].as_str().unwrap_or("application/pdf"),
|
||||
u["purpose"].as_str().unwrap_or("receipt"),
|
||||
))
|
||||
}
|
||||
Value::Object(o) if o.len() == 1 && o.contains_key("$var") => vars
|
||||
.get(o["$var"].as_str().unwrap())
|
||||
.unwrap_or_else(|| panic!("no saved value {}", o["$var"]))
|
||||
.clone(),
|
||||
Value::Object(o) => Value::Object(
|
||||
o.iter()
|
||||
.map(|(k, x)| (k.clone(), resolve(w, vars, x)))
|
||||
.collect(),
|
||||
),
|
||||
Value::Array(a) => Value::Array(a.iter().map(|x| resolve(w, vars, x)).collect()),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Play one transcript; returns the tools it called after `books.start`.
|
||||
fn play(name: &str, script: &Value) -> Vec<String> {
|
||||
let books = Books::with(
|
||||
&format!("eval-{name}"),
|
||||
"USD",
|
||||
script["settings"].as_str(),
|
||||
);
|
||||
let mut w = books.session("agent-evals");
|
||||
let mut vars: HashMap<String, Value> = HashMap::new();
|
||||
for s in script["setup"].as_array().into_iter().flatten() {
|
||||
let args = resolve(&mut w, &vars, &s["args"]);
|
||||
let tool = s["call"].as_str().unwrap();
|
||||
let out = answer_of(&w.call(tool, args))
|
||||
.unwrap_or_else(|e| panic!("{name}: setup {tool}: {e}"));
|
||||
for (var, pointer) in s["save"].as_object().into_iter().flatten() {
|
||||
vars.insert(
|
||||
var.clone(),
|
||||
out.pointer(pointer.as_str().unwrap())
|
||||
.unwrap_or_else(|| panic!("{name}: setup {tool} has no {pointer}: {out}"))
|
||||
.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut last: Option<Value> = None;
|
||||
let mut called = Vec::new();
|
||||
for (i, step) in script["steps"].as_array().expect("steps").iter().enumerate() {
|
||||
let here = format!("{name} step {i}");
|
||||
if let Some(tool) = step["call"].as_str() {
|
||||
assert_eq!(i, 0, "{here}: only the first step may call without following");
|
||||
assert_eq!(tool, "books.start", "{here}: the first call is books.start");
|
||||
let args = resolve(&mut w, &vars, &step["args"]);
|
||||
last = Some(answer_of(&w.call_raw(tool, args)).unwrap_or_else(|e| panic!("{here}: {e}")));
|
||||
} else if let Some(want) = step["follow"].as_str() {
|
||||
let prev = last.as_ref().unwrap_or_else(|| panic!("{here}: follow before start"));
|
||||
let offered = prev["next_steps"].as_array().cloned().unwrap_or_default();
|
||||
let named = offered
|
||||
.iter()
|
||||
.find(|s| s["tool"] == json!(want))
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"{here}: {want} is not in the previous result's next_steps: {:?}",
|
||||
offered.iter().map(|s| s["tool"].clone()).collect::<Vec<_>>()
|
||||
)
|
||||
});
|
||||
let mut args: Map<String, Value> = named["args"].as_object().cloned().unwrap_or_default();
|
||||
for (k, v) in step["with"].as_object().into_iter().flatten() {
|
||||
args.insert(k.clone(), resolve(&mut w, &vars, v));
|
||||
}
|
||||
let out = answer_of(&w.call_raw(want, Value::Object(args)))
|
||||
.unwrap_or_else(|e| panic!("{here}: {want} was refused: {e}"));
|
||||
called.push(want.to_string());
|
||||
last = Some(out);
|
||||
} else if let Some(want) = step["follow_prompt"].as_str() {
|
||||
let prev = last.as_ref().unwrap_or_else(|| panic!("{here}: follow before start"));
|
||||
assert!(
|
||||
prev["next_steps"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|s| s["prompt"] == json!(want)),
|
||||
"{here}: prompt {want} is not in next_steps"
|
||||
);
|
||||
let got = w.rpc("prompts/get", json!({"name": want, "arguments": {}}));
|
||||
let text = got["result"]["messages"][0]["content"]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("{here}: the prompt did not render: {got}"));
|
||||
assert!(text.contains("books."), "{here}: {text}");
|
||||
} else if let Some(tool) = step["expect_next_steps_contains"].as_str() {
|
||||
let prev = last.as_ref().unwrap();
|
||||
assert!(
|
||||
prev["next_steps"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|s| s["tool"] == json!(tool)),
|
||||
"{here}: next_steps lack {tool}: {}",
|
||||
prev["next_steps"]
|
||||
);
|
||||
} else if let Some(expect) = step["expect"].as_object() {
|
||||
let prev = last.as_ref().unwrap();
|
||||
for (pointer, want) in expect {
|
||||
assert_eq!(prev.pointer(pointer), Some(want), "{here}: {pointer} in {prev}");
|
||||
}
|
||||
} else {
|
||||
panic!("{here}: not a step: {step}");
|
||||
}
|
||||
}
|
||||
called
|
||||
}
|
||||
|
||||
/// Every transcript follows only what it was told, and ends where it says.
|
||||
#[test]
|
||||
fn agent_evals_follow_only_next_steps() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/agent_evals");
|
||||
let mut files: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "json"))
|
||||
.collect();
|
||||
files.sort();
|
||||
let names: Vec<String> = files
|
||||
.iter()
|
||||
.map(|p| p.file_stem().unwrap().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"draft_awaiting_a_receipt",
|
||||
"month_end_close",
|
||||
"onboarding_to_first_post",
|
||||
"receipt_backed_post",
|
||||
"reconciliation",
|
||||
"reversal"
|
||||
],
|
||||
"the six evals the plan names"
|
||||
);
|
||||
for (name, path) in names.iter().zip(&files) {
|
||||
let script: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap())
|
||||
.unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let called = play(name, &script);
|
||||
assert!(!called.is_empty(), "{name} calls nothing");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"description": "A draft that waits for its receipt, and posts when it arrives.",
|
||||
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
|
||||
"setup": [
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "1000",
|
||||
"name": "Cash",
|
||||
"account_type": "asset"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "3000",
|
||||
"name": "Capital",
|
||||
"account_type": "equity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "6000",
|
||||
"name": "Rent",
|
||||
"account_type": "expense"
|
||||
}
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"call": "books.start",
|
||||
"args": {}
|
||||
},
|
||||
{
|
||||
"follow": "books.draft_entry",
|
||||
"with": {
|
||||
"date": "2026-01-10",
|
||||
"description": "Landlord, January",
|
||||
"no_party_reason": "test"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "6000",
|
||||
"side": "debit",
|
||||
"amount": "40.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "1000",
|
||||
"side": "credit",
|
||||
"amount": "40.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"follow": "books.set_draft_state",
|
||||
"with": {
|
||||
"state": "awaiting_evidence",
|
||||
"note": "the landlord's receipt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/state": "awaiting_evidence"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.attach_receipt",
|
||||
"with": {
|
||||
"upload_id": {
|
||||
"$upload": {
|
||||
"name": "receipt.pdf",
|
||||
"mime": "application/pdf",
|
||||
"text": "%PDF-1.4 a receipt for rent",
|
||||
"purpose": "receipt"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "posted"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"description": "A period that ended is closed: the open period is found, previewed, closed, and the books verified.",
|
||||
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
|
||||
"setup": [
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "1000",
|
||||
"name": "Cash",
|
||||
"account_type": "asset"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "3000",
|
||||
"name": "Capital",
|
||||
"account_type": "equity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "6000",
|
||||
"name": "Rent",
|
||||
"account_type": "expense"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.create_period",
|
||||
"args": {
|
||||
"name": "2025-12",
|
||||
"start": "2025-12-01",
|
||||
"end": "2025-12-31"
|
||||
}
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"call": "books.start",
|
||||
"args": {
|
||||
"focus": "close"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.list_periods"
|
||||
},
|
||||
{
|
||||
"follow": "books.list_periods",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"follow": "books.close_period",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "preview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.close_period",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "closed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.verify"
|
||||
},
|
||||
{
|
||||
"follow": "books.verify",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "ok"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"description": "A new book: onboarding to the first posted entry, following only next_steps.",
|
||||
"steps": [
|
||||
{
|
||||
"call": "books.start",
|
||||
"args": {
|
||||
"agent": {
|
||||
"model": "scripted",
|
||||
"reasoning_effort": "none",
|
||||
"harness": "agent_evals"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/state": "needs_onboarding"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.set_setting"
|
||||
},
|
||||
{
|
||||
"follow_prompt": "onboard"
|
||||
},
|
||||
{
|
||||
"follow": "books.set_setting",
|
||||
"with": {
|
||||
"changes": [
|
||||
{
|
||||
"key": "company.name",
|
||||
"value": "Eval Co"
|
||||
}
|
||||
],
|
||||
"decided_by": "Avery Quill"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.add_account"
|
||||
},
|
||||
{
|
||||
"follow": "books.add_account",
|
||||
"with": {
|
||||
"code": "1000",
|
||||
"name": "Cash",
|
||||
"account_type": "asset"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_account",
|
||||
"with": {
|
||||
"code": "3000",
|
||||
"name": "Capital",
|
||||
"account_type": "equity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.draft_entry",
|
||||
"with": {
|
||||
"date": "2026-01-05",
|
||||
"description": "Opening capital",
|
||||
"no_party_reason": "opening balance"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "1000",
|
||||
"side": "debit",
|
||||
"amount": "10.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "3000",
|
||||
"side": "credit",
|
||||
"amount": "10.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "preview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "posted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.show"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"description": "A post the receipt policy covers: the preview asks for the receipt, the receipt is attached, the entry posts.",
|
||||
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
|
||||
"setup": [
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "1000",
|
||||
"name": "Cash",
|
||||
"account_type": "asset"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "3000",
|
||||
"name": "Capital",
|
||||
"account_type": "equity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "6000",
|
||||
"name": "Rent",
|
||||
"account_type": "expense"
|
||||
}
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"call": "books.start",
|
||||
"args": {}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.draft_entry"
|
||||
},
|
||||
{
|
||||
"follow": "books.draft_entry",
|
||||
"with": {
|
||||
"date": "2026-01-10",
|
||||
"description": "January rent",
|
||||
"no_party_reason": "test"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "6000",
|
||||
"side": "debit",
|
||||
"amount": "40.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "1000",
|
||||
"side": "credit",
|
||||
"amount": "40.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.attach_receipt"
|
||||
},
|
||||
{
|
||||
"follow": "books.attach_receipt",
|
||||
"with": {
|
||||
"upload_id": {
|
||||
"$upload": {
|
||||
"name": "receipt.pdf",
|
||||
"mime": "application/pdf",
|
||||
"text": "%PDF-1.4 a receipt for rent",
|
||||
"purpose": "receipt"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "posted"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"description": "A cash account reconciled to its statement: record it, mark the lines, reconcile.",
|
||||
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
|
||||
"setup": [
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "1000",
|
||||
"name": "Cash",
|
||||
"account_type": "asset"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "3000",
|
||||
"name": "Capital",
|
||||
"account_type": "equity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "6000",
|
||||
"name": "Rent",
|
||||
"account_type": "expense"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.draft_entry",
|
||||
"args": {
|
||||
"date": "2026-01-05",
|
||||
"description": "Opening capital",
|
||||
"no_party_reason": "opening balance"
|
||||
},
|
||||
"save": {
|
||||
"entry": "/entry_id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_line",
|
||||
"args": {
|
||||
"entry_id": {
|
||||
"$var": "entry"
|
||||
},
|
||||
"account": "1000",
|
||||
"side": "debit",
|
||||
"amount": "10.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_line",
|
||||
"args": {
|
||||
"entry_id": {
|
||||
"$var": "entry"
|
||||
},
|
||||
"account": "3000",
|
||||
"side": "credit",
|
||||
"amount": "10.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.post_entry",
|
||||
"args": {
|
||||
"entry_id": {
|
||||
"$var": "entry"
|
||||
},
|
||||
"confirmed": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.file_document",
|
||||
"args": {
|
||||
"category": "statement",
|
||||
"file_name": "jan.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"upload_id": {
|
||||
"$upload": {
|
||||
"name": "jan.pdf",
|
||||
"mime": "application/pdf",
|
||||
"text": "%PDF-1.4 January statement",
|
||||
"purpose": "document"
|
||||
}
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"statement": "/attachment_id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"call": "books.start",
|
||||
"args": {}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.record_statement"
|
||||
},
|
||||
{
|
||||
"follow": "books.record_statement",
|
||||
"with": {
|
||||
"attachment_id": {
|
||||
"$var": "statement"
|
||||
},
|
||||
"account": "1000",
|
||||
"period_start": "2026-01-01",
|
||||
"period_end": "2026-01-31",
|
||||
"opening_balance": "0.00",
|
||||
"closing_balance": "10.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.mark_cleared",
|
||||
"with": {
|
||||
"entry_id": {
|
||||
"$var": "entry"
|
||||
},
|
||||
"line_index": 0,
|
||||
"cleared": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.reconcile",
|
||||
"with": {
|
||||
"account": "1000",
|
||||
"statement_date": "2026-01-31",
|
||||
"statement_balance": "10.00",
|
||||
"statement_attachment": {
|
||||
"$var": "statement"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "preview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.reconcile",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "reconciled"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"description": "A posted entry reversed: previewed, confirmed, never edited.",
|
||||
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
|
||||
"setup": [
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "1000",
|
||||
"name": "Cash",
|
||||
"account_type": "asset"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "3000",
|
||||
"name": "Capital",
|
||||
"account_type": "equity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "6000",
|
||||
"name": "Rent",
|
||||
"account_type": "expense"
|
||||
}
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"call": "books.start",
|
||||
"args": {}
|
||||
},
|
||||
{
|
||||
"follow": "books.draft_entry",
|
||||
"with": {
|
||||
"date": "2026-01-10",
|
||||
"description": "Coffee beans",
|
||||
"no_party_reason": "test"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "6000",
|
||||
"side": "debit",
|
||||
"amount": "10.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "1000",
|
||||
"side": "credit",
|
||||
"amount": "10.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "posted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.reverse_entry",
|
||||
"with": {
|
||||
"date": "2026-01-11",
|
||||
"description": "Reverse: entered in error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "preview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.reverse_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "posted"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
//! The agent surface over the wire (plan P5): `books.start` v2, the
|
||||
//! journal in words, source corrections, the prompts and the resource
|
||||
//! templates. Every call goes through a real books server as an agent
|
||||
//! reaches it (`common.rs`).
|
||||
//!
|
||||
//! Expected behavior comes from `docs/mcp-interface.md` ("Tools — the
|
||||
//! journal in words (P5)", "The result envelope", "Prompts", "Resources")
|
||||
//! and plan 4.2, 4.4, 4.5.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{Books, answer_of};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
const SETTINGS: &str = "[company]\nname = \"Surface Co\"\nfiscal_year_start = \"01-01\"\n\
|
||||
[books]\ncash_accounts = [\"1000\"]\n";
|
||||
|
||||
fn chart(w: &mut common::Wire) {
|
||||
for (code, name, ty) in [
|
||||
("1000", "Cash", "asset"),
|
||||
("3000", "Capital", "equity"),
|
||||
("6000", "Rent", "expense"),
|
||||
] {
|
||||
answer_of(&w.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": ty}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A posted entry of `amount` rent paid from cash (under the receipt
|
||||
/// threshold); its id.
|
||||
fn post(w: &mut common::Wire, date: &str, what: &str, amount: &str) -> u64 {
|
||||
let id = answer_of(&w.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": date, "description": what, "no_party_reason": "test"}),
|
||||
))
|
||||
.unwrap()["entry_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
|
||||
answer_of(&w.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
answer_of(&w.call("books.post_entry", json!({"entry_id": id, "confirmed": true}))).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// `books.journal_history` reads the journal in order, in words; filters
|
||||
/// by subject and type; pages with `more`; refuses a bad subject; and a
|
||||
/// refusal changes nothing.
|
||||
#[test]
|
||||
fn journal_history_traces_what_the_books_did() {
|
||||
let books = Books::with("p5-history", "USD", Some(SETTINGS));
|
||||
let mut w = books.session("p5");
|
||||
chart(&mut w);
|
||||
let first = post(&mut w, "2026-01-05", "January rent", "10.00");
|
||||
let second = post(&mut w, "2026-01-06", "Printer paper", "11.00");
|
||||
|
||||
let all = answer_of(&w.call("books.journal_history", json!({"limit": 200}))).unwrap();
|
||||
let lines = all["lines"].as_array().unwrap();
|
||||
let seqs: Vec<u64> = lines.iter().map(|l| l["seq"].as_u64().unwrap()).collect();
|
||||
assert!(seqs.windows(2).all(|p| p[0] < p[1]), "journal order: {seqs:?}");
|
||||
assert_eq!(all["head_seq"], *seqs.last().unwrap());
|
||||
assert_eq!(all["more"], false);
|
||||
let posted = lines
|
||||
.iter()
|
||||
.find(|l| l["type"] == "entry.posted" && l["entry_id"] == json!(first))
|
||||
.unwrap_or_else(|| panic!("no entry.posted for {first}: {all}"));
|
||||
assert!(
|
||||
posted["summary"].as_str().unwrap().contains("January rent"),
|
||||
"{posted}"
|
||||
);
|
||||
|
||||
// A subject keeps the lines about it, and only those.
|
||||
let about = answer_of(&w.call(
|
||||
"books.journal_history",
|
||||
json!({"subject": format!("entry:{second}")}),
|
||||
))
|
||||
.unwrap();
|
||||
let about = about["lines"].as_array().unwrap();
|
||||
assert!(about.len() >= 3, "created, lines, posted: {about:?}");
|
||||
assert!(about.iter().all(|l| l["entry_id"] == json!(second)), "{about:?}");
|
||||
|
||||
// A type group, and an exact type.
|
||||
let group = answer_of(&w.call("books.journal_history", json!({"types": ["entry."]}))).unwrap();
|
||||
assert!(group["lines"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|l| l["type"].as_str().unwrap().starts_with("entry.")));
|
||||
let exact = answer_of(&w.call("books.journal_history", json!({"types": ["entry.posted"]}))).unwrap();
|
||||
assert_eq!(exact["lines"].as_array().unwrap().len(), 2);
|
||||
|
||||
// Paging: two at a time, from where the last page ended.
|
||||
let page = answer_of(&w.call("books.journal_history", json!({"limit": 2}))).unwrap();
|
||||
assert_eq!(page["lines"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(page["more"], true);
|
||||
let after = page["lines"][1]["seq"].as_u64().unwrap();
|
||||
let next = answer_of(&w.call(
|
||||
"books.journal_history",
|
||||
json!({"after_seq": after, "limit": 2}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(next["lines"][0]["seq"].as_u64().unwrap(), after + 1);
|
||||
|
||||
// A bad subject is refused with the shapes; the journal is unchanged.
|
||||
let before = books.journal();
|
||||
let err = answer_of(&w.call("books.journal_history", json!({"subject": "nonsense"}))).unwrap_err();
|
||||
assert!(err.contains("entry:12"), "{err}");
|
||||
assert_eq!(books.journal(), before);
|
||||
// It is a read: no head in the envelope.
|
||||
assert!(all.get("head").is_none(), "{all}");
|
||||
}
|
||||
|
||||
/// A source-only correction over the wire: the three tools take `reason`
|
||||
/// (or `revises` with it), the words stay, the journal says so, and a
|
||||
/// change of the words is not a correction.
|
||||
#[test]
|
||||
fn a_source_only_revision_over_the_wire() {
|
||||
let books = Books::with("p5-source", "USD", Some(SETTINGS));
|
||||
let mut w = books.session("p5");
|
||||
let convention = |by: &str, body: &str| {
|
||||
json!({"id": "close-checklist", "kind": "close_step", "title": "Close checklist",
|
||||
"body_md": body, "decided_by": by, "decided_on": "2026-10-20"})
|
||||
};
|
||||
answer_of(&w.call("books.set_convention", convention("Owner", "Reconcile, then close."))).unwrap();
|
||||
let same = answer_of(&w.call("books.set_convention", convention("Pat Smith", "Reconcile, then close."))).unwrap_err();
|
||||
assert!(
|
||||
same.contains("already says exactly that") && same.contains("reason"),
|
||||
"{same}"
|
||||
);
|
||||
let mut fixed = convention("Pat Smith", "Reconcile, then close.");
|
||||
fixed["reason"] = json!("the CPA decided it");
|
||||
fixed["citation"] = json!("IRS Pub. 538");
|
||||
let out = answer_of(&w.call("books.set_convention", fixed.clone())).unwrap();
|
||||
assert_eq!(out["convention"]["source"]["decided_by"], "Pat Smith", "{out}");
|
||||
assert_eq!(out["convention"]["revision"], 1, "the words did not change");
|
||||
let mut other = convention("Pat Smith", "Close first.");
|
||||
other["reason"] = json!("x");
|
||||
let err = answer_of(&w.call("books.set_convention", other)).unwrap_err();
|
||||
assert!(err.contains("more than its source"), "{err}");
|
||||
|
||||
// A decision and an open item.
|
||||
let decision = answer_of(&w.call(
|
||||
"books.record_decision",
|
||||
json!({"subject": "Materiality", "decision_md": "Five hundred dollars.",
|
||||
"decided_by": ["Owner"], "decided_on": "2026-10-20"}),
|
||||
))
|
||||
.unwrap()["id"]
|
||||
.clone();
|
||||
let fixed_decision = json!({"revises": decision, "reason": "both decided it",
|
||||
"decided_by": ["Owner", "Pat Smith"], "decided_on": "2026-10-21"});
|
||||
answer_of(&w.call("books.record_decision", fixed_decision.clone())).unwrap();
|
||||
let d = answer_of(&w.call("books.decisions", json!({}))).unwrap();
|
||||
assert_eq!(d["decisions"][0]["decided_by"], json!(["Owner", "Pat Smith"]), "{d}");
|
||||
assert_eq!(d["decisions"][0]["decision_md"], "Five hundred dollars.");
|
||||
let no_reason = answer_of(&w.call(
|
||||
"books.record_decision",
|
||||
json!({"revises": decision, "decided_by": ["Pat Smith"], "decided_on": "2026-10-22"}),
|
||||
))
|
||||
.unwrap_err();
|
||||
assert!(no_reason.contains("reason"), "{no_reason}");
|
||||
let item = answer_of(&w.call(
|
||||
"books.raise_item",
|
||||
json!({"title": "Ask the CPA", "decided_by": "Owner", "decided_on": "2026-10-20"}),
|
||||
))
|
||||
.unwrap()["id"]
|
||||
.clone();
|
||||
answer_of(&w.call(
|
||||
"books.raise_item",
|
||||
json!({"revises": item, "reason": "raised by the CPA", "decided_by": "Pat Smith",
|
||||
"decided_on": "2026-10-21"}),
|
||||
))
|
||||
.unwrap();
|
||||
let items = answer_of(&w.call("books.open_items", json!({}))).unwrap();
|
||||
assert_eq!(items["items"][0]["title"], "Ask the CPA");
|
||||
assert_eq!(items["items"][0]["raised"]["decided_by"], "Pat Smith", "{items}");
|
||||
|
||||
// The journal says what happened, in order.
|
||||
let h = answer_of(&w.call(
|
||||
"books.journal_history",
|
||||
json!({"types": ["convention.", "decision.", "open_item."]}),
|
||||
))
|
||||
.unwrap();
|
||||
let types: Vec<&str> = h["lines"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|l| l["type"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
types,
|
||||
[
|
||||
"convention.set",
|
||||
"convention.source_revised",
|
||||
"decision.recorded",
|
||||
"decision.source_revised",
|
||||
"open_item.raised",
|
||||
"open_item.source_revised"
|
||||
]
|
||||
);
|
||||
// A restart reads the same books.
|
||||
books.restart();
|
||||
let mut w = books.session("p5");
|
||||
let c = answer_of(&w.call("books.conventions", json!({}))).unwrap();
|
||||
assert_eq!(c["conventions"][0]["source"]["decided_by"], "Pat Smith", "{c}");
|
||||
}
|
||||
|
||||
/// Prompts: six, listed with their arguments, rendered with the company's
|
||||
/// name and the arguments; an argument is one token of the right shape.
|
||||
#[test]
|
||||
fn the_prompts_list_and_render() {
|
||||
let books = Books::with("p5-prompts", "USD", Some(SETTINGS));
|
||||
let mut w = books.wire();
|
||||
let init = w.initialize("p5");
|
||||
assert!(init["result"]["capabilities"]["prompts"].is_object(), "{init}");
|
||||
let listed = w.rpc("prompts/list", json!({}));
|
||||
let names: Vec<&str> = listed["result"]["prompts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| p["name"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
["onboard", "reconcile", "month_end_close", "cpa_handoff", "annual_compliance", "audit"]
|
||||
);
|
||||
let source = |name: &str| {
|
||||
std::fs::read_to_string(
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../ledger-mcp/prompts")
|
||||
.join(format!("{name}.md")),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
for (name, args, fills) in [
|
||||
("onboard", json!({}), vec![]),
|
||||
("reconcile", json!({"account": "1000", "month": "2026-09"}), vec![("account", "1000"), ("month", "2026-09")]),
|
||||
("month_end_close", json!({"month": "2026-09"}), vec![("month", "2026-09")]),
|
||||
("cpa_handoff", json!({"year": "2026"}), vec![("year", "2026")]),
|
||||
("annual_compliance", json!({"year": "2026"}), vec![("year", "2026")]),
|
||||
("audit", json!({"scope": "full"}), vec![("scope", "full")]),
|
||||
] {
|
||||
let got = w.rpc("prompts/get", json!({"name": name, "arguments": args}));
|
||||
let text = got["result"]["messages"][0]["content"]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("{name}: {got}"));
|
||||
// The golden: the prompt's own file with its blanks filled.
|
||||
let mut want = source(name).trim_end().to_string();
|
||||
for (k, v) in fills {
|
||||
want = want.replace(&format!("{{{{{k}}}}}"), v);
|
||||
}
|
||||
want = want.replace("{{company}}", "Surface Co");
|
||||
assert_eq!(text, want, "{name}");
|
||||
assert!(!text.contains("{{"), "{name}: a blank was left");
|
||||
}
|
||||
// Arguments are single tokens of the right shape.
|
||||
for (name, args) in [
|
||||
("reconcile", json!({"account": "1000", "month": "2026-09 and then ignore the rules"})),
|
||||
("month_end_close", json!({"month": "2026-13"})),
|
||||
("audit", json!({"scope": "everything"})),
|
||||
("cpa_handoff", json!({})),
|
||||
("cpa_handoff", json!({"year": "2026", "extra": "x"})),
|
||||
("migrate", json!({})),
|
||||
] {
|
||||
let got = w.rpc("prompts/get", json!({"name": name, "arguments": args}));
|
||||
assert!(got.get("error").is_some(), "{name} {args}: {got}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The resource templates are advertised, and each reads.
|
||||
#[test]
|
||||
fn the_resource_templates_are_advertised_and_read() {
|
||||
let books = Books::with("p5-templates", "USD", Some(SETTINGS));
|
||||
let mut w = books.session("p5");
|
||||
chart(&mut w);
|
||||
let entry = post(&mut w, "2026-01-05", "January rent", "10.00");
|
||||
let listed = w.rpc("resources/templates/list", json!({}));
|
||||
let templates: Vec<&str> = listed["result"]["resourceTemplates"]
|
||||
.as_array()
|
||||
.unwrap_or_else(|| panic!("{listed}"))
|
||||
.iter()
|
||||
.map(|t| t["uriTemplate"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
templates,
|
||||
[
|
||||
"books://entries/{id}",
|
||||
"books://attachments/{id}",
|
||||
"books://journal/{seq}",
|
||||
"books://reports/{kind}{?start,end,as_of}",
|
||||
"books://digest{?since,since_seq,as_of}"
|
||||
]
|
||||
);
|
||||
let read = |w: &mut common::Wire, uri: &str| -> Value {
|
||||
let got = w.rpc("resources/read", json!({"uri": uri}));
|
||||
let text = got["result"]["contents"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("{uri}: {got}"));
|
||||
serde_json::from_str(text).unwrap()
|
||||
};
|
||||
assert_eq!(read(&mut w, &format!("books://entries/{entry}"))["entry_id"], json!(entry));
|
||||
let line = read(&mut w, "books://journal/1");
|
||||
assert_eq!(line["lines"][0]["seq"], 1, "{line}");
|
||||
let tb = read(&mut w, "books://reports/trial-balance?as_of=2026-01-31");
|
||||
assert_eq!(tb["as_of"], "2026-01-31", "{tb}");
|
||||
let pl = read(&mut w, "books://reports/profit-and-loss?start=2026-01-01&end=2026-01-31");
|
||||
assert_eq!(pl["start"], "2026-01-01", "{pl}");
|
||||
let digest = read(&mut w, "books://digest?since_seq=1");
|
||||
assert!(digest.get("entries").is_some() || digest.get("posted").is_some() || digest.is_object());
|
||||
let gone = w.rpc("resources/read", json!({"uri": "books://journal/99999"}));
|
||||
assert!(gone.get("error").is_some(), "{gone}");
|
||||
}
|
||||
|
||||
/// `books.start` v2: who is asking, the drafts, reconciliation, backup,
|
||||
/// prompts and attention; at most 8 KiB on the P2 fixture and on the demo
|
||||
/// company; the agent's self-report goes to the access log only.
|
||||
#[test]
|
||||
fn books_start_v2_is_whole_and_fits() {
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/p2-served");
|
||||
let books = Books::placed("p5-start-v2", &fixture);
|
||||
let mut w = books.session("claude-code");
|
||||
let raw = w.call(
|
||||
"books.start",
|
||||
json!({"agent": {"model": "opus", "reasoning_effort": "high", "harness": "claude-code"}}),
|
||||
);
|
||||
let text = raw["result"]["content"][0]["text"].as_str().unwrap();
|
||||
assert!(text.len() <= 8 * 1024, "books.start is {} bytes", text.len());
|
||||
let start = answer_of(&raw).unwrap();
|
||||
assert!(start["book"]["id"].is_string());
|
||||
assert!(start["book"]["url"].as_str().unwrap().starts_with("/b/"));
|
||||
assert!(start["book"]["url"].as_str().unwrap().ends_with("/mcp"));
|
||||
books.home.assert_hidden(text);
|
||||
assert_eq!(start["you"]["kind"], "agent", "{start}");
|
||||
assert_eq!(start["you"]["client_id"], "claude-code");
|
||||
assert!(start["you"]["scopes"].as_array().unwrap().len() >= 1);
|
||||
assert!(start["drafts"]["building"].is_u64());
|
||||
assert!(start["drafts"]["awaiting_evidence"].is_array());
|
||||
for k in ["tracked", "never_reconciled", "behind"] {
|
||||
assert!(start["reconciliation"][k].is_array(), "{k}");
|
||||
}
|
||||
assert!(start["backup"]["stale"].is_boolean());
|
||||
assert_eq!(start["prompts"].as_array().unwrap().len(), 6);
|
||||
assert!(start["needs_attention"]["items"].as_array().unwrap().len() <= 12);
|
||||
assert!(start["needs_attention"]["counts"].is_object());
|
||||
for s in start["next_steps"].as_array().unwrap() {
|
||||
assert!(s["why"].is_string() && s["required"].is_boolean(), "{s}");
|
||||
assert!(s["tool"].is_string() || s["prompt"].is_string(), "{s}");
|
||||
}
|
||||
// The envelope: a read has steps and no head.
|
||||
assert!(start.get("guards").is_some_and(Value::is_array));
|
||||
|
||||
// The self-report is in the access log, beside the call.
|
||||
let log = answer_of(&w.call("books.access_log", json!({"limit": 50}))).unwrap();
|
||||
let agent = log["records"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|r| r["subject"] == "books.start" && r.get("agent").is_some())
|
||||
.unwrap_or_else(|| panic!("no agent in the log: {log}"));
|
||||
assert_eq!(agent["agent"]["model"], "opus");
|
||||
assert_eq!(agent["agent"]["harness"], "claude-code");
|
||||
// And nowhere else: not in the answer.
|
||||
assert!(!text.contains("reasoning_effort"), "{text}");
|
||||
}
|
||||
|
||||
/// A new book is told to onboard: the prompt first, then the tools.
|
||||
#[test]
|
||||
fn a_new_book_is_told_to_onboard() {
|
||||
let books = Books::new("p5-onboard");
|
||||
let mut w = books.session("p5");
|
||||
let start = answer_of(&w.call("books.start", json!({}))).unwrap();
|
||||
assert_eq!(start["state"], "needs_onboarding");
|
||||
let steps = start["next_steps"].as_array().unwrap();
|
||||
assert_eq!(steps[0]["prompt"], "onboard", "{steps:?}");
|
||||
assert!(steps.iter().any(|s| s["tool"] == "books.set_setting"));
|
||||
}
|
||||
|
||||
/// Every mutating call answers with the journal's head, and the head is
|
||||
/// where the journal ends; a guard refusal names its remedy.
|
||||
#[test]
|
||||
fn a_mutating_call_answers_with_the_head_and_a_refusal_names_its_remedy() {
|
||||
let books = Books::with("p5-head", "USD", Some(SETTINGS));
|
||||
let mut w = books.session("p5");
|
||||
let added = answer_of(&w.call(
|
||||
"books.add_account",
|
||||
json!({"code": "7000", "name": "Misc", "account_type": "expense"}),
|
||||
))
|
||||
.unwrap();
|
||||
let head = added["head"]["seq"].as_u64().unwrap();
|
||||
let start = answer_of(&w.call("books.start", json!({}))).unwrap();
|
||||
assert_eq!(start["head"]["seq"], json!(head), "the head the call answered is the journal's");
|
||||
assert!(!added["next_steps"].as_array().unwrap().is_empty());
|
||||
// A confirm with no preview is refused as stale_preview, naming the
|
||||
// tool that previews.
|
||||
chart(&mut w);
|
||||
let draft = answer_of(&w.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": "2026-01-05", "description": "Rent", "no_party_reason": "test"}),
|
||||
))
|
||||
.unwrap()["entry_id"]
|
||||
.clone();
|
||||
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
|
||||
answer_of(&w.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": draft, "account": account, "side": side, "amount": "5.00"}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
let refused = w.call_raw("books.post_entry", json!({"entry_id": draft, "confirmed": true}));
|
||||
assert_eq!(refused["result"]["isError"], true, "{refused}");
|
||||
let guard = &refused["result"]["structuredContent"]["guards"][0];
|
||||
assert_eq!(guard["code"], "stale_preview", "{refused}");
|
||||
assert_eq!(guard["remedy_tool"], "books.post_entry");
|
||||
}
|
||||
+18
-1
@@ -31,7 +31,7 @@ the specification in `bookspec/` (which never depends on the engine).
|
||||
| `ledger-core` | the semantics engine: money, chart, journal, `Ledger` commands, reports, periods, assets, schedules, parties, A/R and A/P, payroll, projects, settings and conventions, the kernel and its Kani harnesses | nothing |
|
||||
| `ledger-vault` | the TIN vault: XChaCha20-Poly1305 records under the book's key | nothing |
|
||||
| `ledger-config` | the `wanderbooks.toml` codec (adoption in, `books.export_settings` out) and the receipt policy until P4 moves it into the core | `ledger-core` |
|
||||
| `ledger-store` | the source of truth: the hash-chained JSONL journal, its format gate, the SQLite projection (schema 18; a file per schema), the blob store, backups, the access log, `Snapshot` for readers | `ledger-core`, `ledger-reader` (P6, D-61) |
|
||||
| `ledger-store` | the source of truth: the hash-chained JSONL journal, its format gate, the SQLite projection (schema 18; a file per schema), the blob store, backups, the access log, `describe.rs` (the journal in words, for `books.journal_history`), `Snapshot` for readers | `ledger-core`, `ledger-reader` (P6, D-61) |
|
||||
| `ledger-reader` | the one reader: a PDF's text and pages, an XLSX's cells as exact decimal strings, plain text (P6; a plain library, in-process, D-61); `ledger-import` re-exports it as `extract` | none (`hayro`, `zip`, `quick-xml`) |
|
||||
| `ledger-import` | formats: QuickBooks CSV in; IIF, XLSX, Typst PDFs (statements, the invoice, labels) out | `ledger-core`, `ledger-reader` |
|
||||
| `ledger-package` | the shared read-only view over `Store` or `Snapshot` (`PackageBooks`): the CPA package, the 1099 worksheet, the invoice document, Needs attention | `ledger-core`, `ledger-store`, `ledger-config`, `ledger-import`, `ledger-vault` |
|
||||
@@ -353,3 +353,20 @@ package, which repacks the release's tarball), `build-linux.sh`,
|
||||
`repo-indexes.sh` (the apt and dnf indexes), `local-release.sh` (a
|
||||
throwaway-keyed feed on this machine) and `test-linux-packages.sh`.
|
||||
`.github/workflows/release.yml` builds them on a version tag.
|
||||
|
||||
## The result envelope and the guards (`ledger-mcp`, P5)
|
||||
|
||||
Every tool result is given the same fields in one place: `call_tool` runs the
|
||||
tool, then `next_steps::apply` adds `next_steps`, `guards`, `warnings` and
|
||||
(on a tool that is not read-only) `head`, in the structured content and in
|
||||
the text block alike, never overwriting a field the tool wrote. Next steps
|
||||
come from the tool's own result (`next_steps::tool_steps`: the confirm after
|
||||
a preview, what a post, a draft, a statement or a close leads to) and, after
|
||||
a change to an entry or an invoice, from the viewer's own Needs-attention
|
||||
items about it (`ledger_package::find_with`). `guards.rs` is the registry of
|
||||
named refusals, each with the tool that remedies it; a refusal whose text
|
||||
carries a guard gets it as `structuredContent.guards`. The prompts
|
||||
(`tools/prompts.rs`, text in `prompts/*.md`) and the resource templates are
|
||||
served by the same `ServerHandler`; `books.start` is loaded up front by
|
||||
clients that defer tools (`_meta`). `ledger-store`'s `describe.rs` is the
|
||||
one place that turns a journal line into a sentence for an agent.
|
||||
@@ -12,12 +12,16 @@ locked to one assistant.
|
||||
|
||||
| Crate | Role | Spec |
|
||||
|---|---|---|
|
||||
| `ledger-core` | the engine: money, entries, the laws; every rule the tools enforce | `docs/spec/rules.md`, `docs/verification.md` |
|
||||
| `ledger-package` | the read-only views shared with the viewer: reports, aging, the CPA package, Needs attention (the one list `books.start` and the viewer both draw) | `docs/architecture.md` |
|
||||
| `ledger-store` | the hash-chained journal (the source of truth), the SQLite projection, the blob store | `docs/receipt-store.md` |
|
||||
| `ledger-mcp` | the books server: the tools, resources and handbook over store + core | this document |
|
||||
| `ledger-host` | the agent bridge (`wanderbooks mcp`), the host and its leases, tokens | `plan/served.md` 3.3, 3.9 |
|
||||
|
||||
(The overview sections of this document date from the first MCP phase; P5
|
||||
rewrites them. The per-tool sections are held to the code by a test.)
|
||||
(P5 rewrote the overview sections for the surface as it is: the result
|
||||
envelope, `books.start` v2, the prompts, the resource templates and the
|
||||
journal in words, below. The per-tool sections are held to the code by a
|
||||
test: `every_tool_is_documented_and_every_documented_tool_exists`.)
|
||||
|
||||
Rust, `rmcp` SDK (the Rust MCP SDK). The MCP crate holds no accounting
|
||||
logic: it translates tool calls into `Ledger` commands and formats results.
|
||||
@@ -389,8 +393,8 @@ books' files; no tool title or description, connect text or handbook
|
||||
section names `wanderbooks.toml` (`tool_text_lint`). A rule set by law that the books
|
||||
don't know for a year names its official source (the statute or
|
||||
publication, with its address) and says to tell the human.
|
||||
Tool descriptions are at most 700 characters; eight longer ones are listed
|
||||
in `tool_descriptions_fit_700_characters`, to be shortened in P5.
|
||||
Tool descriptions are at most 700 characters (`tool_descriptions_fit_700_characters`
|
||||
allows none longer; the long text lives in the handbook).
|
||||
|
||||
**Annotations.** Every tool has a `title` and a `readOnlyHint`; every tool
|
||||
that changes anything also has a `destructiveHint`, true for these 31:
|
||||
|
||||
@@ -223,3 +223,18 @@ backdated entries included: defect 6), `zero_net_accounts`,
|
||||
`drafts_by_state` and the reconciliation split; reports gained
|
||||
`as_of_status` and `strict`; the CPA package is refused while a draft
|
||||
dated in it is open (4.8).
|
||||
|
||||
### P5: the agent surface (2026-10-29)
|
||||
|
||||
No semantic feature moved, so no status changed and the floor stands. P5
|
||||
changes how an agent is told what to do, not how a figure is computed: the
|
||||
result envelope on every tool result (`next_steps`, `guards`, `warnings`,
|
||||
`head`), `books.start` v2, the prompts, the resource templates,
|
||||
`books.journal_history` (the journal in words) and source corrections
|
||||
(`source_revisions`: `convention.source_revised`, `decision.source_revised`,
|
||||
`open_item.source_revised`). One new row, `partial` until a hand-verified
|
||||
golden covers it:
|
||||
|
||||
| Area | Counterpart (a cross-check, not the spec) | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| Audit trail in words (`books.journal_history`, the `describe` sentences) | QBO "Audit log", Xero "History and notes" | partial | tests `journal_history_traces_what_the_books_did`, `every_event_type_has_a_sentence`; the viewer keeps its own sentences (the shared describer is the server's) |
|
||||
@@ -3745,3 +3745,89 @@ covers the feature one; a case with a higher format number is a gap to
|
||||
add), `swap_in`'s temporary-then-rename (`update_is_atomic`), `healthy`
|
||||
(`failed_update_rolls_back`), `install_kind` (the marker test), and
|
||||
`parse_version`'s rejections.
|
||||
|
||||
## P5: the agent surface (2026-10-29)
|
||||
|
||||
Every tool result carries the envelope (`next_steps`, `guards`, `warnings`,
|
||||
`head`), `books.start` is v2, the prompts and resource templates are
|
||||
served, `books.journal_history` reads the journal in words, and a
|
||||
convention, decision or open item can have its source corrected. Journal
|
||||
feature `source_revisions`; events `convention.source_revised`,
|
||||
`decision.source_revised`, `open_item.source_revised`; no schema change
|
||||
(the projection updates the rows' existing source columns).
|
||||
|
||||
- **`ledger-mcp` `src/server_tests.rs`:**
|
||||
`every_mutating_result_has_head_and_next_steps` (every tool of the
|
||||
router is given the envelope; the non-read-only ones have `head` equal to
|
||||
the journal's and a step each with `why` and `required`; the text block
|
||||
says what the structure says),
|
||||
`the_envelope_keeps_what_a_tool_said_and_carries_a_refusal_guard`,
|
||||
`books_start_is_always_loaded`, `tool_descriptions_fit_700_characters`
|
||||
(the allowance list is gone: every description is at most 700
|
||||
characters). `guards.rs`: `every_guard_names_an_existing_tool`,
|
||||
`a_refusal_becomes_its_guard`. `next_steps.rs`:
|
||||
`a_preview_gets_its_confirm_step`, `every_step_a_tool_names_exists`.
|
||||
`tools/prompts.rs`: `the_prompts_name_only_tools_that_exist`,
|
||||
`an_argument_is_one_token_of_the_right_shape`.
|
||||
- **`ledger-store`:** `tests/source_revisions.rs`:
|
||||
`a_source_only_revision_is_journaled` (the three events, words and
|
||||
revision unchanged, live equals replay equals a rebuilt database),
|
||||
`a_source_revision_must_change_only_the_source` (other words, the same
|
||||
source, no reason, unknown record: refused, journal unchanged),
|
||||
`source_revisions_wait_for_the_upgrade` (refused before the go-ahead with
|
||||
`format_upgrade_pending`, journaling nothing). `format.rs`:
|
||||
`source_revisions_is_its_own_feature`. `describe.rs`:
|
||||
`every_event_type_has_a_sentence`, `settings_show_their_keys_never_their_values`,
|
||||
`a_full_tin_in_a_description_is_masked`,
|
||||
`history_filters_in_order_and_says_when_more_remain`.
|
||||
- **8.0.6:** `books_written_under_older_rules_still_open` hand-writes the
|
||||
three events; the fixture `old-books/p5_before` (writer
|
||||
`old-books-writer/src/bin/p5_before.rs`, run against b97234ac, the
|
||||
revision before P5: a convention revised once, two decisions, two open
|
||||
items, a draft) opens to that version's figures, so no figure moved;
|
||||
`books_p2_served_cut_over_whole` refuses `books.raise_item` with `revises`
|
||||
before consent and takes it after.
|
||||
- **`ledger-cli` `tests/agent_surface.rs`:**
|
||||
`journal_history_traces_what_the_books_did`,
|
||||
`a_source_only_revision_over_the_wire`, `the_prompts_list_and_render`
|
||||
(each prompt rendered equals its file with the blanks filled; an
|
||||
argument that is not one token of the right shape, a missing or extra
|
||||
one, and a struck prompt are refused),
|
||||
`the_resource_templates_are_advertised_and_read`,
|
||||
`books_start_v2_is_whole_and_fits` (at most 8 KiB on the P2 fixture; the
|
||||
agent's self-report in the access log only),
|
||||
`a_new_book_is_told_to_onboard`,
|
||||
`a_mutating_call_answers_with_the_head_and_a_refusal_names_its_remedy`.
|
||||
- **`ledger-cli` `tests/agent_evals.rs`** (transcripts in `tests/agent_evals/`):
|
||||
`agent_evals_follow_only_next_steps` plays six scripted transcripts (no
|
||||
model): onboarding to a first post, a receipt-backed post, a draft that
|
||||
waits for its receipt and posts when it arrives, a reversal, a
|
||||
reconciliation, a month-end close. After `books.start` a transcript may
|
||||
call only a tool (or fetch only a prompt) the previous result's
|
||||
`next_steps` named, with the arguments the step gave and the ones the
|
||||
agent decides. `setup` calls (the chart, the books an agent finds) are
|
||||
not the agent's moves.
|
||||
- **The sweep:** the `party_id`, `document_id` and `cash_account` aliases of
|
||||
`books.create_invoice`, `books.create_bill`, `books.receive_payment` and
|
||||
`books.pay_bill` are gone (S4, S6 said "until P5"); the over-the-wire
|
||||
tests that used them name `customer`/`vendor`, `invoice`/`bill` and
|
||||
`deposit_to`/`paid_from`.
|
||||
|
||||
**For P12 (mutation targets):** `next_steps::apply`'s three branches (an
|
||||
error, a result without an object, the normal path) and the `mutating`
|
||||
test that gives `head`; `tool_steps`'s confirm branch (`!confirmed` and a
|
||||
`preview`), the `receipt_missing` pair before it, and each tool's arm;
|
||||
`attention_steps`'s subject match (entry against invoice) and its
|
||||
`Problem` severity for `required`; `subject_of`'s precedence;
|
||||
`guards::from_refusal`'s structured and worded branches and
|
||||
`Guard::remedy_tool`'s table; `prompts::checked`'s token test and each
|
||||
`Check` shape (month length and calendar, year length, the word list);
|
||||
`prompt_get`'s extra-argument and missing-argument refusals;
|
||||
`describe::subjects`'s per-prefix id choice, `describe`'s `settings.set`
|
||||
key list and TIN mask, `history`'s `>` on `after_seq`, its `limit` cut and
|
||||
`more`; `check_convention_source`'s three equalities (title/body/structure,
|
||||
source, kind), `check_decision_source` and `check_open_item_source`'s
|
||||
equal-source refusals and the empty-reason refusals; the three
|
||||
`apply_*_source` arms (a mutant that also bumps `revision` or `seq` shows
|
||||
in `a_source_only_revision_is_journaled`); `books.start`'s fit order and
|
||||
`reconciliation` split (`reconciled_through.is_some()`).
|
||||
Reference in new issue
Block a user