fix(mcp): a read says only what follows; a success adds no empty warnings (P5)
Two existing contracts the envelope broke when every result got every field: a resource and its tool say the same (books://settings is books.settings, checked by the_kept_resources_read), and a tool with no warnings has no warnings key (books.add_account, defect 8). So head and next_steps are on every tool that can change the books, next_steps is on a read only when something follows it, guards is on a refusal, and the envelope adds no warnings of its own (a tool's stay as they are). The happy-path spec test now exercises books.journal_history (it fails on a tool it never called), the demo company gets its books.start size check (Done when 3), and the As-built block of P5 is written. 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
8e683942a2
commit
ad1afb478d
6 files changed
+76
-11
No files matched your search
@@ -429,8 +429,8 @@ fn books_start_v2_is_whole_and_fits() {
|
||||
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 envelope: a read has no head of the envelope's (its own stays).
|
||||
assert!(start["head"]["seq"].is_u64());
|
||||
|
||||
// The self-report is in the access log, beside the call.
|
||||
let log = answer_of(&w.call("books.access_log", json!({"limit": 50}))).unwrap();
|
||||
@@ -502,3 +502,44 @@ fn a_mutating_call_answers_with_the_head_and_a_refusal_names_its_remedy() {
|
||||
assert_eq!(guard["code"], "stale_preview", "{refused}");
|
||||
assert_eq!(guard["remedy_tool"], "books.post_entry");
|
||||
}
|
||||
|
||||
/// Done-when (3): `books.start` is at most 8 KiB on the demo company too
|
||||
/// (the books the viewer ships with, a loose end of every kind), and says
|
||||
/// what it cut.
|
||||
#[test]
|
||||
fn books_start_fits_8_kib_on_the_demo_books() {
|
||||
let today = ledger_core::settings::today_in(None);
|
||||
let place = common::Place::new("p5-start-demo");
|
||||
let source = tempfile::Builder::new()
|
||||
.prefix("wb-p5-demo-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
ledger_gui::demo::build(source.path(), today).unwrap();
|
||||
std::fs::create_dir_all(place.books_data().parent().unwrap()).unwrap();
|
||||
std::fs::rename(source.path().join("books-data"), place.books_data()).unwrap();
|
||||
let mut wire = place.start(None);
|
||||
wire.initialize("p5");
|
||||
let raw = wire.call("books.start", json!({}));
|
||||
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_eq!(start["state"], "active");
|
||||
assert!(
|
||||
!start["needs_attention"]["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"the demo has loose ends: {start}"
|
||||
);
|
||||
assert!(
|
||||
start["needs_attention"]["counts"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.len()
|
||||
>= 2
|
||||
);
|
||||
}
|
||||
@@ -1294,6 +1294,8 @@ fn happy_path(name: &str) -> Mcp {
|
||||
// The access log, the backups' status, and a backup now (none is set
|
||||
// up: it says so).
|
||||
m.ok("books.access_log", json!({}));
|
||||
// P5: the journal in words.
|
||||
m.ok("books.journal_history", json!({"limit": 5}));
|
||||
m.ok("books.backup", json!({}));
|
||||
m.ok("books.back_up_now", json!({}));
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
//! 1. The envelope is applied in one place (`apply`, called by
|
||||
//! `call_tool`), so no tool can forget it.
|
||||
//! 2. Every result of a tool that is not read-only has a `head` (the
|
||||
//! journal after the call's last write) and at least one next step.
|
||||
//! journal after the call's last write) and at least one next step; a
|
||||
//! read has `next_steps` when something follows it and nothing else (a
|
||||
//! resource and its tool agree). `guards` is on a refusal, and a
|
||||
//! success without warnings has no `warnings` key.
|
||||
//! 3. A tool's own fields are never overwritten: a payload that already
|
||||
//! has `next_steps`, `guards`, `warnings` or `head` keeps it.
|
||||
//! 4. A refused call has no head and no steps; a refusal that is a
|
||||
@@ -451,14 +454,18 @@ pub(crate) fn apply(
|
||||
if mutating && steps.is_empty() {
|
||||
steps.push(digest_step());
|
||||
}
|
||||
if !body.contains_key("next_steps") {
|
||||
// A tool that can change the books always says what follows; a read
|
||||
// says it only when something does, so what it says is what a resource
|
||||
// of the same name says (`books://settings` is `books.settings`).
|
||||
// `guards` is on a refusal (above) and `warnings` is the tool's own:
|
||||
// a success that has none says none, as the tools always did.
|
||||
if !body.contains_key("next_steps") && (mutating || !steps.is_empty()) {
|
||||
body.insert(
|
||||
"next_steps".into(),
|
||||
Value::Array(steps.iter().map(Step::to_json).collect()),
|
||||
);
|
||||
}
|
||||
body.entry("guards").or_insert_with(|| json!([]));
|
||||
body.entry("warnings").or_insert_with(|| json!([]));
|
||||
|
||||
if let Some(head) = head {
|
||||
body.entry("head").or_insert(head);
|
||||
}
|
||||
|
||||
@@ -1564,8 +1564,8 @@ fn every_mutating_result_has_head_and_next_steps() {
|
||||
};
|
||||
let body = r.structured_content.expect("structured");
|
||||
assert!(
|
||||
body["guards"].is_array() && body["warnings"].is_array(),
|
||||
"{name}"
|
||||
body.get("guards").is_none() && body.get("warnings").is_none(),
|
||||
"{name}: a success without them says none"
|
||||
);
|
||||
let ContentBlock::Text(t) = &r.content[0] else {
|
||||
panic!("{name}: no text block");
|
||||
|
||||
Reference in new issue
Block a user