style: rustfmt and one clippy lint over the P5 files

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 15:48:42 -06:00
1 parent 7bac25496e
commit 632938fa97
11 files changed
+302 -108

No files matched your search

+32 -15
View File
@@ -62,18 +62,14 @@ fn resolve(w: &mut Wire, vars: &HashMap<String, Value>, v: &Value) -> Value {
/// Play one transcript; returns the tools it called after `books.start`. /// Play one transcript; returns the tools it called after `books.start`.
fn play(name: &str, script: &Value) -> Vec<String> { fn play(name: &str, script: &Value) -> Vec<String> {
let books = Books::with( let books = Books::with(&format!("eval-{name}"), "USD", script["settings"].as_str());
&format!("eval-{name}"),
"USD",
script["settings"].as_str(),
);
let mut w = books.session("agent-evals"); let mut w = books.session("agent-evals");
let mut vars: HashMap<String, Value> = HashMap::new(); let mut vars: HashMap<String, Value> = HashMap::new();
for s in script["setup"].as_array().into_iter().flatten() { for s in script["setup"].as_array().into_iter().flatten() {
let args = resolve(&mut w, &vars, &s["args"]); let args = resolve(&mut w, &vars, &s["args"]);
let tool = s["call"].as_str().unwrap(); let tool = s["call"].as_str().unwrap();
let out = answer_of(&w.call(tool, args)) let out =
.unwrap_or_else(|e| panic!("{name}: setup {tool}: {e}")); 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() { for (var, pointer) in s["save"].as_object().into_iter().flatten() {
vars.insert( vars.insert(
var.clone(), var.clone(),
@@ -85,15 +81,26 @@ fn play(name: &str, script: &Value) -> Vec<String> {
} }
let mut last: Option<Value> = None; let mut last: Option<Value> = None;
let mut called = Vec::new(); let mut called = Vec::new();
for (i, step) in script["steps"].as_array().expect("steps").iter().enumerate() { for (i, step) in script["steps"]
.as_array()
.expect("steps")
.iter()
.enumerate()
{
let here = format!("{name} step {i}"); let here = format!("{name} step {i}");
if let Some(tool) = step["call"].as_str() { if let Some(tool) = step["call"].as_str() {
assert_eq!(i, 0, "{here}: only the first step may call without following"); 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"); assert_eq!(tool, "books.start", "{here}: the first call is books.start");
let args = resolve(&mut w, &vars, &step["args"]); let args = resolve(&mut w, &vars, &step["args"]);
last = Some(answer_of(&w.call_raw(tool, args)).unwrap_or_else(|e| panic!("{here}: {e}"))); 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() { } else if let Some(want) = step["follow"].as_str() {
let prev = last.as_ref().unwrap_or_else(|| panic!("{here}: follow before start")); 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 offered = prev["next_steps"].as_array().cloned().unwrap_or_default();
let named = offered let named = offered
.iter() .iter()
@@ -101,10 +108,14 @@ fn play(name: &str, script: &Value) -> Vec<String> {
.unwrap_or_else(|| { .unwrap_or_else(|| {
panic!( panic!(
"{here}: {want} is not in the previous result's next_steps: {:?}", "{here}: {want} is not in the previous result's next_steps: {:?}",
offered.iter().map(|s| s["tool"].clone()).collect::<Vec<_>>() offered
.iter()
.map(|s| s["tool"].clone())
.collect::<Vec<_>>()
) )
}); });
let mut args: Map<String, Value> = named["args"].as_object().cloned().unwrap_or_default(); 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() { for (k, v) in step["with"].as_object().into_iter().flatten() {
args.insert(k.clone(), resolve(&mut w, &vars, v)); args.insert(k.clone(), resolve(&mut w, &vars, v));
} }
@@ -113,7 +124,9 @@ fn play(name: &str, script: &Value) -> Vec<String> {
called.push(want.to_string()); called.push(want.to_string());
last = Some(out); last = Some(out);
} else if let Some(want) = step["follow_prompt"].as_str() { } else if let Some(want) = step["follow_prompt"].as_str() {
let prev = last.as_ref().unwrap_or_else(|| panic!("{here}: follow before start")); let prev = last
.as_ref()
.unwrap_or_else(|| panic!("{here}: follow before start"));
assert!( assert!(
prev["next_steps"] prev["next_steps"]
.as_array() .as_array()
@@ -141,7 +154,11 @@ fn play(name: &str, script: &Value) -> Vec<String> {
} else if let Some(expect) = step["expect"].as_object() { } else if let Some(expect) = step["expect"].as_object() {
let prev = last.as_ref().unwrap(); let prev = last.as_ref().unwrap();
for (pointer, want) in expect { for (pointer, want) in expect {
assert_eq!(prev.pointer(pointer), Some(want), "{here}: {pointer} in {prev}"); assert_eq!(
prev.pointer(pointer),
Some(want),
"{here}: {pointer} in {prev}"
);
} }
} else { } else {
panic!("{here}: not a step: {step}"); panic!("{here}: not a step: {step}");
+113 -30
View File
@@ -47,7 +47,11 @@ fn post(w: &mut common::Wire, date: &str, what: &str, amount: &str) -> u64 {
)) ))
.unwrap(); .unwrap();
} }
answer_of(&w.call("books.post_entry", json!({"entry_id": id, "confirmed": true}))).unwrap(); answer_of(&w.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
))
.unwrap();
id id
} }
@@ -65,7 +69,10 @@ fn journal_history_traces_what_the_books_did() {
let all = answer_of(&w.call("books.journal_history", json!({"limit": 200}))).unwrap(); let all = answer_of(&w.call("books.journal_history", json!({"limit": 200}))).unwrap();
let lines = all["lines"].as_array().unwrap(); let lines = all["lines"].as_array().unwrap();
let seqs: Vec<u64> = lines.iter().map(|l| l["seq"].as_u64().unwrap()).collect(); 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!(
seqs.windows(2).all(|p| p[0] < p[1]),
"journal order: {seqs:?}"
);
assert_eq!(all["head_seq"], *seqs.last().unwrap()); assert_eq!(all["head_seq"], *seqs.last().unwrap());
assert_eq!(all["more"], false); assert_eq!(all["more"], false);
let posted = lines let posted = lines
@@ -85,16 +92,22 @@ fn journal_history_traces_what_the_books_did() {
.unwrap(); .unwrap();
let about = about["lines"].as_array().unwrap(); let about = about["lines"].as_array().unwrap();
assert!(about.len() >= 3, "created, lines, posted: {about:?}"); assert!(about.len() >= 3, "created, lines, posted: {about:?}");
assert!(about.iter().all(|l| l["entry_id"] == json!(second)), "{about:?}"); assert!(
about.iter().all(|l| l["entry_id"] == json!(second)),
"{about:?}"
);
// A type group, and an exact type. // A type group, and an exact type.
let group = answer_of(&w.call("books.journal_history", json!({"types": ["entry."]}))).unwrap(); let group = answer_of(&w.call("books.journal_history", json!({"types": ["entry."]}))).unwrap();
assert!(group["lines"] assert!(
.as_array() group["lines"]
.unwrap() .as_array()
.iter() .unwrap()
.all(|l| l["type"].as_str().unwrap().starts_with("entry."))); .iter()
let exact = answer_of(&w.call("books.journal_history", json!({"types": ["entry.posted"]}))).unwrap(); .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); assert_eq!(exact["lines"].as_array().unwrap().len(), 2);
// Paging: two at a time, from where the last page ended. // Paging: two at a time, from where the last page ended.
@@ -111,7 +124,8 @@ fn journal_history_traces_what_the_books_did() {
// A bad subject is refused with the shapes; the journal is unchanged. // A bad subject is refused with the shapes; the journal is unchanged.
let before = books.journal(); let before = books.journal();
let err = answer_of(&w.call("books.journal_history", json!({"subject": "nonsense"}))).unwrap_err(); let err =
answer_of(&w.call("books.journal_history", json!({"subject": "nonsense"}))).unwrap_err();
assert!(err.contains("entry:12"), "{err}"); assert!(err.contains("entry:12"), "{err}");
assert_eq!(books.journal(), before); assert_eq!(books.journal(), before);
// It is a read: no head in the envelope. // It is a read: no head in the envelope.
@@ -129,8 +143,16 @@ fn a_source_only_revision_over_the_wire() {
json!({"id": "close-checklist", "kind": "close_step", "title": "Close checklist", json!({"id": "close-checklist", "kind": "close_step", "title": "Close checklist",
"body_md": body, "decided_by": by, "decided_on": "2026-10-20"}) "body_md": body, "decided_by": by, "decided_on": "2026-10-20"})
}; };
answer_of(&w.call("books.set_convention", convention("Owner", "Reconcile, then close."))).unwrap(); answer_of(&w.call(
let same = answer_of(&w.call("books.set_convention", convention("Pat Smith", "Reconcile, then close."))).unwrap_err(); "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!( assert!(
same.contains("already says exactly that") && same.contains("reason"), same.contains("already says exactly that") && same.contains("reason"),
"{same}" "{same}"
@@ -139,7 +161,10 @@ fn a_source_only_revision_over_the_wire() {
fixed["reason"] = json!("the CPA decided it"); fixed["reason"] = json!("the CPA decided it");
fixed["citation"] = json!("IRS Pub. 538"); fixed["citation"] = json!("IRS Pub. 538");
let out = answer_of(&w.call("books.set_convention", fixed.clone())).unwrap(); 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"]["source"]["decided_by"], "Pat Smith",
"{out}"
);
assert_eq!(out["convention"]["revision"], 1, "the words did not change"); assert_eq!(out["convention"]["revision"], 1, "the words did not change");
let mut other = convention("Pat Smith", "Close first."); let mut other = convention("Pat Smith", "Close first.");
other["reason"] = json!("x"); other["reason"] = json!("x");
@@ -158,7 +183,11 @@ fn a_source_only_revision_over_the_wire() {
"decided_by": ["Owner", "Pat Smith"], "decided_on": "2026-10-21"}); "decided_by": ["Owner", "Pat Smith"], "decided_on": "2026-10-21"});
answer_of(&w.call("books.record_decision", fixed_decision.clone())).unwrap(); answer_of(&w.call("books.record_decision", fixed_decision.clone())).unwrap();
let d = answer_of(&w.call("books.decisions", json!({}))).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]["decided_by"],
json!(["Owner", "Pat Smith"]),
"{d}"
);
assert_eq!(d["decisions"][0]["decision_md"], "Five hundred dollars."); assert_eq!(d["decisions"][0]["decision_md"], "Five hundred dollars.");
let no_reason = answer_of(&w.call( let no_reason = answer_of(&w.call(
"books.record_decision", "books.record_decision",
@@ -180,7 +209,10 @@ fn a_source_only_revision_over_the_wire() {
.unwrap(); .unwrap();
let items = answer_of(&w.call("books.open_items", json!({}))).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]["title"], "Ask the CPA");
assert_eq!(items["items"][0]["raised"]["decided_by"], "Pat Smith", "{items}"); assert_eq!(
items["items"][0]["raised"]["decided_by"], "Pat Smith",
"{items}"
);
// The journal says what happened, in order. // The journal says what happened, in order.
let h = answer_of(&w.call( let h = answer_of(&w.call(
@@ -209,7 +241,10 @@ fn a_source_only_revision_over_the_wire() {
books.restart(); books.restart();
let mut w = books.session("p5"); let mut w = books.session("p5");
let c = answer_of(&w.call("books.conventions", json!({}))).unwrap(); let c = answer_of(&w.call("books.conventions", json!({}))).unwrap();
assert_eq!(c["conventions"][0]["source"]["decided_by"], "Pat Smith", "{c}"); assert_eq!(
c["conventions"][0]["source"]["decided_by"], "Pat Smith",
"{c}"
);
} }
/// Prompts: six, listed with their arguments, rendered with the company's /// Prompts: six, listed with their arguments, rendered with the company's
@@ -219,7 +254,10 @@ fn the_prompts_list_and_render() {
let books = Books::with("p5-prompts", "USD", Some(SETTINGS)); let books = Books::with("p5-prompts", "USD", Some(SETTINGS));
let mut w = books.wire(); let mut w = books.wire();
let init = w.initialize("p5"); let init = w.initialize("p5");
assert!(init["result"]["capabilities"]["prompts"].is_object(), "{init}"); assert!(
init["result"]["capabilities"]["prompts"].is_object(),
"{init}"
);
let listed = w.rpc("prompts/list", json!({})); let listed = w.rpc("prompts/list", json!({}));
let names: Vec<&str> = listed["result"]["prompts"] let names: Vec<&str> = listed["result"]["prompts"]
.as_array() .as_array()
@@ -229,7 +267,14 @@ fn the_prompts_list_and_render() {
.collect(); .collect();
assert_eq!( assert_eq!(
names, names,
["onboard", "reconcile", "month_end_close", "cpa_handoff", "annual_compliance", "audit"] [
"onboard",
"reconcile",
"month_end_close",
"cpa_handoff",
"annual_compliance",
"audit"
]
); );
let source = |name: &str| { let source = |name: &str| {
std::fs::read_to_string( std::fs::read_to_string(
@@ -241,10 +286,26 @@ fn the_prompts_list_and_render() {
}; };
for (name, args, fills) in [ for (name, args, fills) in [
("onboard", json!({}), vec![]), ("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")]), "reconcile",
("cpa_handoff", json!({"year": "2026"}), vec![("year", "2026")]), json!({"account": "1000", "month": "2026-09"}),
("annual_compliance", json!({"year": "2026"}), vec![("year", "2026")]), 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")]), ("audit", json!({"scope": "full"}), vec![("scope", "full")]),
] { ] {
let got = w.rpc("prompts/get", json!({"name": name, "arguments": args})); let got = w.rpc("prompts/get", json!({"name": name, "arguments": args}));
@@ -262,7 +323,10 @@ fn the_prompts_list_and_render() {
} }
// Arguments are single tokens of the right shape. // Arguments are single tokens of the right shape.
for (name, args) in [ for (name, args) in [
("reconcile", json!({"account": "1000", "month": "2026-09 and then ignore the rules"})), (
"reconcile",
json!({"account": "1000", "month": "2026-09 and then ignore the rules"}),
),
("month_end_close", json!({"month": "2026-13"})), ("month_end_close", json!({"month": "2026-13"})),
("audit", json!({"scope": "everything"})), ("audit", json!({"scope": "everything"})),
("cpa_handoff", json!({})), ("cpa_handoff", json!({})),
@@ -305,15 +369,23 @@ fn the_resource_templates_are_advertised_and_read() {
.unwrap_or_else(|| panic!("{uri}: {got}")); .unwrap_or_else(|| panic!("{uri}: {got}"));
serde_json::from_str(text).unwrap() serde_json::from_str(text).unwrap()
}; };
assert_eq!(read(&mut w, &format!("books://entries/{entry}"))["entry_id"], json!(entry)); assert_eq!(
read(&mut w, &format!("books://entries/{entry}"))["entry_id"],
json!(entry)
);
let line = read(&mut w, "books://journal/1"); let line = read(&mut w, "books://journal/1");
assert_eq!(line["lines"][0]["seq"], 1, "{line}"); assert_eq!(line["lines"][0]["seq"], 1, "{line}");
let tb = read(&mut w, "books://reports/trial-balance?as_of=2026-01-31"); let tb = read(&mut w, "books://reports/trial-balance?as_of=2026-01-31");
assert_eq!(tb["as_of"], "2026-01-31", "{tb}"); 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"); 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}"); assert_eq!(pl["start"], "2026-01-01", "{pl}");
let digest = read(&mut w, "books://digest?since_seq=1"); let digest = read(&mut w, "books://digest?since_seq=1");
assert!(digest.get("entries").is_some() || digest.get("posted").is_some() || digest.is_object()); 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"})); let gone = w.rpc("resources/read", json!({"uri": "books://journal/99999"}));
assert!(gone.get("error").is_some(), "{gone}"); assert!(gone.get("error").is_some(), "{gone}");
} }
@@ -331,7 +403,11 @@ fn books_start_v2_is_whole_and_fits() {
json!({"agent": {"model": "opus", "reasoning_effort": "high", "harness": "claude-code"}}), json!({"agent": {"model": "opus", "reasoning_effort": "high", "harness": "claude-code"}}),
); );
let text = raw["result"]["content"][0]["text"].as_str().unwrap(); let text = raw["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.len() <= 8 * 1024, "books.start is {} bytes", text.len()); assert!(
text.len() <= 8 * 1024,
"books.start is {} bytes",
text.len()
);
let start = answer_of(&raw).unwrap(); let start = answer_of(&raw).unwrap();
assert!(start["book"]["id"].is_string()); assert!(start["book"]["id"].is_string());
assert!(start["book"]["url"].as_str().unwrap().starts_with("/b/")); assert!(start["book"]["url"].as_str().unwrap().starts_with("/b/"));
@@ -339,7 +415,7 @@ fn books_start_v2_is_whole_and_fits() {
books.home.assert_hidden(text); books.home.assert_hidden(text);
assert_eq!(start["you"]["kind"], "agent", "{start}"); assert_eq!(start["you"]["kind"], "agent", "{start}");
assert_eq!(start["you"]["client_id"], "claude-code"); assert_eq!(start["you"]["client_id"], "claude-code");
assert!(start["you"]["scopes"].as_array().unwrap().len() >= 1); assert!(!start["you"]["scopes"].as_array().unwrap().is_empty());
assert!(start["drafts"]["building"].is_u64()); assert!(start["drafts"]["building"].is_u64());
assert!(start["drafts"]["awaiting_evidence"].is_array()); assert!(start["drafts"]["awaiting_evidence"].is_array());
for k in ["tracked", "never_reconciled", "behind"] { for k in ["tracked", "never_reconciled", "behind"] {
@@ -395,7 +471,11 @@ fn a_mutating_call_answers_with_the_head_and_a_refusal_names_its_remedy() {
.unwrap(); .unwrap();
let head = added["head"]["seq"].as_u64().unwrap(); let head = added["head"]["seq"].as_u64().unwrap();
let start = answer_of(&w.call("books.start", json!({}))).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_eq!(
start["head"]["seq"],
json!(head),
"the head the call answered is the journal's"
);
assert!(!added["next_steps"].as_array().unwrap().is_empty()); assert!(!added["next_steps"].as_array().unwrap().is_empty());
// A confirm with no preview is refused as stale_preview, naming the // A confirm with no preview is refused as stale_preview, naming the
// tool that previews. // tool that previews.
@@ -413,7 +493,10 @@ fn a_mutating_call_answers_with_the_head_and_a_refusal_names_its_remedy() {
)) ))
.unwrap(); .unwrap();
} }
let refused = w.call_raw("books.post_entry", json!({"entry_id": draft, "confirmed": true})); let refused = w.call_raw(
"books.post_entry",
json!({"entry_id": draft, "confirmed": true}),
);
assert_eq!(refused["result"]["isError"], true, "{refused}"); assert_eq!(refused["result"]["isError"], true, "{refused}");
let guard = &refused["result"]["structuredContent"]["guards"][0]; let guard = &refused["result"]["structuredContent"]["guards"][0];
assert_eq!(guard["code"], "stale_preview", "{refused}"); assert_eq!(guard["code"], "stale_preview", "{refused}");
+9 -9
View File
@@ -8789,10 +8789,7 @@ impl Ledger {
))); )));
} }
if old.source == c.source { if old.source == c.source {
return Err(bad(format!( return Err(bad(format!("convention {} already has that source", c.id)));
"convention {} already has that source",
c.id
)));
} }
let reason = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT) let reason = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT)
.map_err(bad)?; .map_err(bad)?;
@@ -8821,10 +8818,11 @@ impl Ledger {
reason: &str, reason: &str,
) -> Result<(crate::conventions::DecisionSource, String), LedgerError> { ) -> Result<(crate::conventions::DecisionSource, String), LedgerError> {
let bad = LedgerError::InvalidConvention; let bad = LedgerError::InvalidConvention;
let old = self let old = self.conventions.decision(id).ok_or_else(|| {
.conventions bad(format!(
.decision(id) "decision {id} doesn't exist: books.decisions lists them"
.ok_or_else(|| bad(format!("decision {id} doesn't exist: books.decisions lists them")))?; ))
})?;
let source = crate::conventions::check_decision_source(source).map_err(bad)?; let source = crate::conventions::check_decision_source(source).map_err(bad)?;
if old.new.source() == source { if old.new.source() == source {
return Err(bad(format!("decision {id} already has that source"))); return Err(bad(format!("decision {id} already has that source")));
@@ -8858,7 +8856,9 @@ impl Ledger {
let bad = LedgerError::InvalidConvention; let bad = LedgerError::InvalidConvention;
source.check().map_err(bad)?; source.check().map_err(bad)?;
let old = self.conventions.open_item(id).ok_or_else(|| { let old = self.conventions.open_item(id).ok_or_else(|| {
bad(format!("unknown open item {id}: books.open_items lists them")) bad(format!(
"unknown open item {id}: books.open_items lists them"
))
})?; })?;
if old.new.source == *source { if old.new.source == *source {
return Err(bad(format!("open item {id} already has that source"))); return Err(bad(format!("open item {id} already has that source")));
+23 -5
View File
@@ -99,7 +99,11 @@ pub(crate) fn mutating_tools() -> &'static HashSet<String> {
/// The steps a tool's own result implies (no books needed): the confirm /// The steps a tool's own result implies (no books needed): the confirm
/// after a preview, and what follows a particular act. /// after a preview, and what follows a particular act.
pub(crate) fn tool_steps(tool: &str, args: Option<&Map<String, Value>>, result: &Value) -> Vec<Step> { pub(crate) fn tool_steps(
tool: &str,
args: Option<&Map<String, Value>>,
result: &Value,
) -> Vec<Step> {
let mut steps = Vec::new(); let mut steps = Vec::new();
let confirmed = args let confirmed = args
.and_then(|a| a.get("confirmed")) .and_then(|a| a.get("confirmed"))
@@ -319,7 +323,11 @@ pub(crate) fn tool_steps(tool: &str, args: Option<&Map<String, Value>>, result:
} }
/// The attention items about the subject of the call, as steps. /// The attention items about the subject of the call, as steps.
fn attention_steps(server: &BooksServer, subject: Subject, store: &ledger_store::Store) -> Vec<Step> { fn attention_steps(
server: &BooksServer,
subject: Subject,
store: &ledger_store::Store,
) -> Vec<Step> {
let cfg = server.settings(); let cfg = server.settings();
let settings = ledger_package::Settings { let settings = ledger_package::Settings {
company: &cfg.company, company: &cfg.company,
@@ -342,7 +350,9 @@ fn attention_steps(server: &BooksServer, subject: Subject, store: &ledger_store:
match (a.target, tool.as_str()) { match (a.target, tool.as_str()) {
( (
Target::Entry(id), Target::Entry(id),
"books.attach_receipt" | "books.set_entry_party" | "books.set_entry_settles", "books.attach_receipt"
| "books.set_entry_party"
| "books.set_entry_settles",
) => Some(json!({ "entry_id": id })), ) => Some(json!({ "entry_id": id })),
_ => None, _ => None,
} }
@@ -350,7 +360,12 @@ fn attention_steps(server: &BooksServer, subject: Subject, store: &ledger_store:
Some(args) Some(args)
}; };
let title: String = a.title.chars().take(160).collect(); let title: String = a.title.chars().take(160).collect();
Some(Step::tool(&tool, args, &title, a.severity == Severity::Problem)) Some(Step::tool(
&tool,
args,
&title,
a.severity == Severity::Problem,
))
}) })
.take(4) .take(4)
.collect() .collect()
@@ -493,7 +508,10 @@ mod tests {
.chain([digest_step()]) .chain([digest_step()])
{ {
if let Some(t) = s.tool { if let Some(t) = s.tool {
assert!(names.contains(&t), "{tool}: step names {t}, which is no tool"); assert!(
names.contains(&t),
"{tool}: step names {t}, which is no tool"
);
} }
} }
} }
+13 -11
View File
@@ -1316,10 +1316,9 @@ fn prompt_reader(context: &RequestContext<RoleServer>) -> Result<(), rmcp::Error
crate::access::UNAUTHORIZED, crate::access::UNAUTHORIZED,
None, None,
)), )),
Some(c) if !c.may(ledger_store::auth::Scope::Read) => Err(rmcp::ErrorData::invalid_request( Some(c) if !c.may(ledger_store::auth::Scope::Read) => Err(
crate::access::INSUFFICIENT_SCOPE, rmcp::ErrorData::invalid_request(crate::access::INSUFFICIENT_SCOPE, None),
None, ),
)),
Some(_) => Ok(()), Some(_) => Ok(()),
} }
} }
@@ -1468,26 +1467,26 @@ enum ShownDates {
// Tools // Tools
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[path = "guards.rs"]
mod guards;
#[path = "next_steps.rs"]
mod next_steps;
#[path = "tools/access.rs"] #[path = "tools/access.rs"]
mod access_tools; mod access_tools;
#[path = "tools/backup.rs"] #[path = "tools/backup.rs"]
mod backup_tools; mod backup_tools;
#[path = "tools/files.rs"] #[path = "tools/files.rs"]
mod files; mod files;
#[path = "guards.rs"]
mod guards;
#[path = "tools/instruments.rs"] #[path = "tools/instruments.rs"]
mod instruments; mod instruments;
#[path = "tools/journal.rs"] #[path = "tools/journal.rs"]
mod journal_tools; mod journal_tools;
#[path = "tools/prompts.rs"]
mod prompts;
#[path = "tools/kept.rs"] #[path = "tools/kept.rs"]
mod kept; mod kept;
#[path = "next_steps.rs"]
mod next_steps;
#[path = "tools/projects.rs"] #[path = "tools/projects.rs"]
mod projects; mod projects;
#[path = "tools/prompts.rs"]
mod prompts;
pub use kept::SettingKeyOut; pub use kept::SettingKeyOut;
// The tool groups: one file each (plan/served.md 8.2 U0a). A unit adds a // The tool groups: one file each (plan/served.md 8.2 U0a). A unit adds a
@@ -1616,7 +1615,10 @@ impl BooksServer {
// `since_seq` (what was posted since a journal line) wins // `since_seq` (what was posted since a journal line) wins
// over `since`; with neither, this month so far. // over `since`; with neither, this month so far.
let since_seq = query_param(query, "since_seq") let since_seq = query_param(query, "since_seq")
.map(|n| n.parse::<u64>().map_err(|_| format!("bad since_seq in {uri}"))) .map(|n| {
n.parse::<u64>()
.map_err(|_| format!("bad since_seq in {uri}"))
})
.transpose()?; .transpose()?;
let since = if since_seq.is_some() { let since = if since_seq.is_some() {
None None
+19 -6
View File
@@ -19,7 +19,12 @@ fn books_start_is_always_loaded() {
.as_ref() .as_ref()
.and_then(|m| m.0.get("anthropic/alwaysLoad")) .and_then(|m| m.0.get("anthropic/alwaysLoad"))
.and_then(serde_json::Value::as_bool); .and_then(serde_json::Value::as_bool);
assert_eq!(always == Some(true), tool.name == "books.start", "{}", tool.name); assert_eq!(
always == Some(true),
tool.name == "books.start",
"{}",
tool.name
);
} }
} }
@@ -727,7 +732,10 @@ fn tool_descriptions_fit_700_characters() {
.map(|t| t.name.to_string()) .map(|t| t.name.to_string())
.collect(); .collect();
over.sort(); over.sort();
assert!(over.is_empty(), "tool descriptions over 700 characters: {over:?}"); assert!(
over.is_empty(),
"tool descriptions over 700 characters: {over:?}"
);
} }
/// The tools docs/mcp-interface.md documents: a line (or a bullet) /// The tools docs/mcp-interface.md documents: a line (or a bullet)
@@ -1549,13 +1557,16 @@ fn every_mutating_result_has_head_and_next_steps() {
let answer = CallToolResponse::Complete(CallToolResult::structured( let answer = CallToolResponse::Complete(CallToolResult::structured(
serde_json::json!({"status": "ok"}), serde_json::json!({"status": "ok"}),
)); ));
let CallToolResponse::Complete(r) = next_steps::apply(&server, &name, None, Ok(answer)) let CallToolResponse::Complete(r) =
.expect("an answer stays an answer") next_steps::apply(&server, &name, None, Ok(answer)).expect("an answer stays an answer")
else { else {
panic!("{name}: not a complete answer"); panic!("{name}: not a complete answer");
}; };
let body = r.structured_content.expect("structured"); let body = r.structured_content.expect("structured");
assert!(body["guards"].is_array() && body["warnings"].is_array(), "{name}"); assert!(
body["guards"].is_array() && body["warnings"].is_array(),
"{name}"
);
let ContentBlock::Text(t) = &r.content[0] else { let ContentBlock::Text(t) = &r.content[0] else {
panic!("{name}: no text block"); panic!("{name}: no text block");
}; };
@@ -1597,7 +1608,9 @@ fn the_envelope_keeps_what_a_tool_said_and_carries_a_refusal_guard() {
&server, &server,
"books.post_entry", "books.post_entry",
None, None,
Ok(CallToolResponse::Complete(CallToolResult::structured(own.clone()))), Ok(CallToolResponse::Complete(CallToolResult::structured(
own.clone(),
))),
) )
.unwrap() else { .unwrap() else {
panic!() panic!()
+3 -5
View File
@@ -1552,11 +1552,9 @@ impl BooksServer {
if let Some(id) = p.revises { if let Some(id) = p.revises {
// A source correction: the decision's words stay (the ones // A source correction: the decision's words stay (the ones
// given, if any, must be the ones it has). // given, if any, must be the ones it has).
let old = store let old = store.ledger().conventions().decision(id).ok_or_else(|| {
.ledger() format!("decision {id} doesn't exist: books.decisions lists them")
.conventions() })?;
.decision(id)
.ok_or_else(|| format!("decision {id} doesn't exist: books.decisions lists them"))?;
if (!p.subject.is_empty() && p.subject != old.new.subject) if (!p.subject.is_empty() && p.subject != old.new.subject)
|| (!p.decision_md.is_empty() && p.decision_md != old.new.decision_md) || (!p.decision_md.is_empty() && p.decision_md != old.new.decision_md)
{ {
+19 -5
View File
@@ -149,8 +149,11 @@ impl BooksServer {
.with_required(true) .with_required(true)
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut prompt = let mut prompt = Prompt::new(
Prompt::new(p.name, Some(p.description), (!args.is_empty()).then_some(args)); p.name,
Some(p.description),
(!args.is_empty()).then_some(args),
);
prompt.title = Some(p.title.to_string()); prompt.title = Some(p.title.to_string());
prompt prompt
}) })
@@ -171,7 +174,11 @@ impl BooksServer {
format!( format!(
"there is no prompt {:?}: {}", "there is no prompt {:?}: {}",
request.name.chars().take(40).collect::<String>(), request.name.chars().take(40).collect::<String>(),
PROMPTS.iter().map(|p| p.name).collect::<Vec<_>>().join(", ") PROMPTS
.iter()
.map(|p| p.name)
.collect::<Vec<_>>()
.join(", ")
) )
})?; })?;
let mut text = def.text.trim_end().to_string(); let mut text = def.text.trim_end().to_string();
@@ -214,7 +221,10 @@ mod tests {
.collect(); .collect();
for p in &PROMPTS { for p in &PROMPTS {
let mut seen = 0; let mut seen = 0;
for word in p.text.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.')) { for word in p
.text
.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.'))
{
if let Some(tool) = word.strip_prefix("books.") { if let Some(tool) = word.strip_prefix("books.") {
let tool = tool.trim_end_matches('.'); let tool = tool.trim_end_matches('.');
if !tool.is_empty() { if !tool.is_empty() {
@@ -229,7 +239,11 @@ mod tests {
} }
assert!(seen > 0, "{} names no tool", p.name); assert!(seen > 0, "{} names no tool", p.name);
for (name, _, _) in p.args { for (name, _, _) in p.args {
assert!(p.text.contains(&format!("{{{{{name}}}}}")), "{}: {name}", p.name); assert!(
p.text.contains(&format!("{{{{{name}}}}}")),
"{}: {name}",
p.name
);
} }
} }
} }
+30 -6
View File
@@ -61,11 +61,24 @@ pub struct HistoryFilter {
/// A subject as `kind:id`, parsed. /// A subject as `kind:id`, parsed.
pub fn parse_subject(text: &str) -> Result<(String, u64), String> { pub fn parse_subject(text: &str) -> Result<(String, u64), String> {
let bad = || format!("subject {text:?}: give kind:id, like entry:12, invoice:3, bill:3, account:10, party:5 or asset:2"); let bad = || {
format!(
"subject {text:?}: give kind:id, like entry:12, invoice:3, bill:3, account:10, party:5 or asset:2"
)
};
let (kind, id) = text.split_once(':').ok_or_else(bad)?; let (kind, id) = text.split_once(':').ok_or_else(bad)?;
let kinds = [ let kinds = [
"entry", "invoice", "bill", "account", "party", "asset", "period", "schedule", "entry",
"reconciliation", "payroll_run", "project", "invoice",
"bill",
"account",
"party",
"asset",
"period",
"schedule",
"reconciliation",
"payroll_run",
"project",
]; ];
if !kinds.contains(&kind.trim()) { if !kinds.contains(&kind.trim()) {
return Err(bad()); return Err(bad());
@@ -335,12 +348,19 @@ mod tests {
json!({"changes": [{"key": "company.ein", "value": "12-3456789"}]}), json!({"changes": [{"key": "company.ein", "value": "12-3456789"}]}),
); );
let s = describe(&l); let s = describe(&l);
assert!(s.contains("company.ein") && !s.contains("12-3456789"), "{s}"); assert!(
s.contains("company.ein") && !s.contains("12-3456789"),
"{s}"
);
} }
#[test] #[test]
fn a_full_tin_in_a_description_is_masked() { fn a_full_tin_in_a_description_is_masked() {
let l = line(3, "entry.created", json!({"id": 1, "description": "TIN 123-45-6789"})); let l = line(
3,
"entry.created",
json!({"id": 1, "description": "TIN 123-45-6789"}),
);
assert!(!describe(&l).contains("6789"), "{}", describe(&l)); assert!(!describe(&l).contains("6789"), "{}", describe(&l));
} }
@@ -350,7 +370,11 @@ mod tests {
.map(|i| { .map(|i| {
line( line(
i, i,
if i % 2 == 0 { "entry.posted" } else { "entry.created" }, if i % 2 == 0 {
"entry.posted"
} else {
"entry.created"
},
json!({"entry_id": i % 3, "id": i % 3}), json!({"entry_id": i % 3, "id": i % 3}),
) )
}) })
+32 -14
View File
@@ -7,9 +7,11 @@
//! and rules.md B-27 (a feature is declared before its first line). //! and rules.md B-27 (a feature is declared before its first line).
use chrono::NaiveDate; use chrono::NaiveDate;
use ledger_core::conventions::{ConventionKind, DecisionSource, NewConvention, NewDecision, NewOpenItem};
use ledger_core::settings::Source;
use ledger_core::Currency; use ledger_core::Currency;
use ledger_core::conventions::{
ConventionKind, DecisionSource, NewConvention, NewDecision, NewOpenItem,
};
use ledger_core::settings::Source;
use ledger_store::format::{KNOWN_FEATURES, SOURCE_REVISIONS}; use ledger_store::format::{KNOWN_FEATURES, SOURCE_REVISIONS};
use ledger_store::{JournalFormat, SUPPORTED_JOURNAL_FORMAT, Store, StoreError}; use ledger_store::{JournalFormat, SUPPORTED_JOURNAL_FORMAT, Store, StoreError};
use std::path::PathBuf; use std::path::PathBuf;
@@ -97,7 +99,8 @@ fn item(src: Source) -> NewOpenItem {
fn a_source_only_revision_is_journaled() { fn a_source_only_revision_is_journaled() {
let dir = test_dir("source-revised"); let dir = test_dir("source-revised");
let mut s = Store::create(&dir, Currency::USD).unwrap(); let mut s = Store::create(&dir, Currency::USD).unwrap();
s.set_convention(&convention(source("Owner", 20, None))).unwrap(); s.set_convention(&convention(source("Owner", 20, None)))
.unwrap();
let decision_id = s.record_decision(&decision()).unwrap(); let decision_id = s.record_decision(&decision()).unwrap();
let item_id = s.raise_open_item(&item(source("Owner", 20, None))).unwrap(); let item_id = s.raise_open_item(&item(source("Owner", 20, None))).unwrap();
let before = lines_of(&dir).len(); let before = lines_of(&dir).len();
@@ -133,18 +136,33 @@ fn a_source_only_revision_is_journaled() {
] ]
); );
assert_eq!(lines[before]["payload"]["reason"], "the CPA decided it"); assert_eq!(lines[before]["payload"]["reason"], "the CPA decided it");
assert_eq!(lines[before]["payload"]["source"]["decided_by"], "Pat Smith"); assert_eq!(
lines[before]["payload"]["source"]["decided_by"],
"Pat Smith"
);
// The words and the revision are as they were; the source is new. // The words and the revision are as they were; the source is new.
let c = s.ledger().conventions().convention("close-checklist").unwrap(); let c = s
assert_eq!((c.body_md.as_str(), c.revision), ("Reconcile, then close.", 1)); .ledger()
.conventions()
.convention("close-checklist")
.unwrap();
assert_eq!(
(c.body_md.as_str(), c.revision),
("Reconcile, then close.", 1)
);
assert_eq!(c.source.decided_by, "Pat Smith"); assert_eq!(c.source.decided_by, "Pat Smith");
let d = s.ledger().conventions().decision(decision_id).unwrap(); let d = s.ledger().conventions().decision(decision_id).unwrap();
assert_eq!(d.new.decision_md, "Five hundred dollars."); assert_eq!(d.new.decision_md, "Five hundred dollars.");
assert_eq!(d.new.decided_by, ["Owner", "Pat Smith"]); assert_eq!(d.new.decided_by, ["Owner", "Pat Smith"]);
assert_eq!(d.new.citation.as_deref(), Some("IRS Pub. 538")); assert_eq!(d.new.citation.as_deref(), Some("IRS Pub. 538"));
assert_eq!( assert_eq!(
s.ledger().conventions().open_item(item_id).unwrap().new.source, s.ledger()
.conventions()
.open_item(item_id)
.unwrap()
.new
.source,
cpa cpa
); );
@@ -167,7 +185,8 @@ fn a_source_only_revision_is_journaled() {
fn a_source_revision_must_change_only_the_source() { fn a_source_revision_must_change_only_the_source() {
let dir = test_dir("source-refused"); let dir = test_dir("source-refused");
let mut s = Store::create(&dir, Currency::USD).unwrap(); let mut s = Store::create(&dir, Currency::USD).unwrap();
s.set_convention(&convention(source("Owner", 20, None))).unwrap(); s.set_convention(&convention(source("Owner", 20, None)))
.unwrap();
let id = s.record_decision(&decision()).unwrap(); let id = s.record_decision(&decision()).unwrap();
let item_id = s.raise_open_item(&item(source("Owner", 20, None))).unwrap(); let item_id = s.raise_open_item(&item(source("Owner", 20, None))).unwrap();
let len = lines_of(&dir).len(); let len = lines_of(&dir).len();
@@ -202,10 +221,7 @@ fn a_source_revision_must_change_only_the_source() {
s.revise_open_item_source(99, &source("Pat Smith", 21, None), "why"), s.revise_open_item_source(99, &source("Pat Smith", 21, None), "why"),
s.revise_open_item_source(item_id, &source("Owner", 20, None), "same source"), s.revise_open_item_source(item_id, &source("Owner", 20, None), "same source"),
] { ] {
assert!( assert!(matches!(refused, Err(StoreError::Ledger(_))), "{refused:?}");
matches!(refused, Err(StoreError::Ledger(_))),
"{refused:?}"
);
} }
assert_eq!(lines_of(&dir).len(), len, "a refusal journals nothing"); assert_eq!(lines_of(&dir).len(), len, "a refusal journals nothing");
} }
@@ -227,7 +243,8 @@ fn source_revisions_wait_for_the_upgrade() {
.collect(), .collect(),
}; };
let mut s = Store::create_in_format(&dir, Currency::USD, &before).unwrap(); let mut s = Store::create_in_format(&dir, Currency::USD, &before).unwrap();
s.set_convention(&convention(source("Owner", 20, None))).unwrap(); s.set_convention(&convention(source("Owner", 20, None)))
.unwrap();
let id = s.record_decision(&decision()).unwrap(); let id = s.record_decision(&decision()).unwrap();
let item_id = s.raise_open_item(&item(source("Owner", 20, None))).unwrap(); let item_id = s.raise_open_item(&item(source("Owner", 20, None))).unwrap();
let head = s.head(); let head = s.head();
@@ -256,7 +273,8 @@ fn source_revisions_wait_for_the_upgrade() {
assert_eq!(s.head(), head, "a refusal journals nothing"); assert_eq!(s.head(), head, "a refusal journals nothing");
assert_eq!(lines_of(&dir).len(), len); assert_eq!(lines_of(&dir).len(), len);
assert!(s.upgrade_format().unwrap().is_some(), "the go-ahead"); assert!(s.upgrade_format().unwrap().is_some(), "the go-ahead");
s.revise_convention_source(&convention(cpa.clone()), "why").unwrap(); s.revise_convention_source(&convention(cpa.clone()), "why")
.unwrap();
s.revise_open_item_source(item_id, &cpa, "why").unwrap(); s.revise_open_item_source(item_id, &cpa, "why").unwrap();
let live = s.ledger().clone(); let live = s.ledger().clone();
drop(s); drop(s);
+9 -2
View File
@@ -2270,8 +2270,15 @@ fn books_written_under_older_rules_still_open() {
.expect("the convention"); .expect("the convention");
assert_eq!(c.source.decided_by, "Pat Smith"); assert_eq!(c.source.decided_by, "Pat Smith");
assert_eq!(c.source.citation.as_deref(), Some("IRS Pub. 538")); assert_eq!(c.source.citation.as_deref(), Some("IRS Pub. 538"));
assert_eq!((c.body_md.as_str(), c.revision), ("Reconcile, then close.", 1)); assert_eq!(
let d = store.ledger().conventions().decision(1).expect("the decision"); (c.body_md.as_str(), c.revision),
("Reconcile, then close.", 1)
);
let d = store
.ledger()
.conventions()
.decision(1)
.expect("the decision");
assert_eq!(d.new.decided_by, ["Owner", "Pat Smith"]); assert_eq!(d.new.decided_by, ["Owner", "Pat Smith"]);
assert_eq!(d.new.citation.as_deref(), Some("IRS Pub. 538")); assert_eq!(d.new.citation.as_deref(), Some("IRS Pub. 538"));
assert_eq!(d.new.decision_md, "Five hundred dollars."); assert_eq!(d.new.decision_md, "Five hundred dollars.");