412 lines
14 KiB
Rust
412 lines
14 KiB
Rust
//! The worker sending memory writes (M5c spec, sections 4 and 5), stepped by hand against the fake
|
|
//! Honcho: a conclusion made in the session's `<id>_memory` session with the agent's and the
|
|
//! owner's peers; one deleted by the id its making got, or nothing to delete; a 404 on delete as
|
|
//! done; an untrusted note as a fleet message from the agent; the text neutralised; one outbox
|
|
//! across turns and edits; errors as for turns; a restart sending nothing again. Do not edit.
|
|
|
|
#[path = "support/honcho.rs"]
|
|
mod honcho;
|
|
#[path = "support/logs.rs"]
|
|
mod logs;
|
|
#[path = "support/tmp.rs"]
|
|
mod tmp;
|
|
#[path = "support/turns.rs"]
|
|
mod turns;
|
|
#[path = "support/worker.rs"]
|
|
mod worker;
|
|
|
|
use bxcore::sanitize::TAGS;
|
|
use bxcore::{SessionId, Timestamp};
|
|
use honcho::{FakeHoncho, Mode, Request};
|
|
use logs::assistant;
|
|
use memoryd::honcho::edits::{MEMORY_SUFFIX, memory_session};
|
|
use memoryd::honcho::ledger::{Action, Item, Record, Workspace};
|
|
use memoryd::honcho::worker::{MAX_PART, Step};
|
|
use serde_json::{Value, json};
|
|
use turns::spoken;
|
|
use worker::{FAST, Home};
|
|
|
|
/// The id fixture 11 gives the first conclusion it makes.
|
|
const CONCLUSION: &str = "YUBMqIqHhuJprV4o-inrg";
|
|
|
|
fn sid(s: &str) -> SessionId {
|
|
SessionId::new(s).unwrap()
|
|
}
|
|
|
|
fn edit(id: &str, part: u32) -> Item {
|
|
Item::Edit {
|
|
id: id.to_string(),
|
|
part,
|
|
}
|
|
}
|
|
|
|
fn conclude(peer: &str, text: &str) -> Action {
|
|
Action::Conclude {
|
|
workspace: Workspace::Trusted,
|
|
peer: peer.to_string(),
|
|
session: sid("mm-a"),
|
|
text: text.to_string(),
|
|
}
|
|
}
|
|
|
|
fn unconclude(text: &str) -> Action {
|
|
Action::Unconclude {
|
|
workspace: Workspace::Trusted,
|
|
text: text.to_string(),
|
|
}
|
|
}
|
|
|
|
fn fleet(text: &str) -> Action {
|
|
Action::Fleet {
|
|
session: sid("mm-c"),
|
|
text: text.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Decide `action` for `item`, as task 12 does when an edit is journalled.
|
|
fn decide(home: &Home, item: Item, action: Action) {
|
|
let sync = home.sync.lock().unwrap();
|
|
sync.ledger()
|
|
.append(Record::Decided {
|
|
time: Timestamp::parse("2026-09-28T10:00:00.000Z").unwrap(),
|
|
item,
|
|
action,
|
|
})
|
|
.unwrap();
|
|
}
|
|
|
|
fn sent_ids(home: &Home, item: &Item) -> Option<Vec<String>> {
|
|
home.records().into_iter().find_map(|r| match r {
|
|
Record::Sent { item: i, ids, .. } if i == *item => Some(ids),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
fn posts(fake: &FakeHoncho, suffix: &str) -> Vec<Request> {
|
|
fake.requests()
|
|
.into_iter()
|
|
.filter(|r| r.path.ends_with(suffix))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn the_memory_session_is_named_for_the_boxmaker_session() {
|
|
assert_eq!(MEMORY_SUFFIX, "_memory");
|
|
assert_eq!(memory_session(&sid("mm-a")), "mm-a_memory");
|
|
}
|
|
|
|
#[test]
|
|
fn a_conclusion_goes_into_the_sessions_memory_session() {
|
|
let fake = FakeHoncho::start();
|
|
let home = Home::new("edits-conclude");
|
|
decide(&home, edit("e1", 0), conclude("kyle", "Prefers UTC."));
|
|
let mut w = home.worker(&fake, FAST);
|
|
assert_eq!(w.step(), Step::Done);
|
|
let w_path = "/v3/workspaces/boxmaker0-trusted";
|
|
let seen: Vec<(String, Value)> = fake
|
|
.requests()
|
|
.into_iter()
|
|
.map(|r| (r.path, r.body.unwrap_or(Value::Null)))
|
|
.collect();
|
|
assert_eq!(
|
|
seen,
|
|
vec![
|
|
(
|
|
"/v3/workspaces".to_string(),
|
|
json!({ "id": "boxmaker0-trusted" })
|
|
),
|
|
(
|
|
format!("{w_path}/peers"),
|
|
json!({ "id": "boxmaker0", "configuration": { "observe_me": false } })
|
|
),
|
|
(format!("{w_path}/peers"), json!({ "id": "kyle" })),
|
|
(
|
|
format!("{w_path}/sessions"),
|
|
json!({ "id": "mm-a_memory", "peers": {
|
|
"boxmaker0": { "observe_me": false, "observe_others": false },
|
|
"kyle": { "observe_me": false, "observe_others": false },
|
|
} })
|
|
),
|
|
(
|
|
format!("{w_path}/conclusions"),
|
|
json!({ "conclusions": [{
|
|
"content": "Prefers UTC.",
|
|
"observer_id": "kyle",
|
|
"observed_id": "kyle",
|
|
"session_id": "mm-a_memory",
|
|
}] })
|
|
),
|
|
]
|
|
);
|
|
assert_eq!(
|
|
sent_ids(&home, &edit("e1", 0)),
|
|
Some(vec![CONCLUSION.to_string()])
|
|
);
|
|
let sync = home.sync.lock().unwrap();
|
|
assert_eq!(
|
|
sync.ledger().conclusion(Workspace::Trusted, "Prefers UTC."),
|
|
Some((edit("e1", 0), CONCLUSION.to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_replace_deletes_then_makes_and_an_undo_reverses_it() {
|
|
let fake = FakeHoncho::start();
|
|
let home = Home::new("edits-replace");
|
|
decide(&home, edit("e1", 0), conclude("kyle", "old"));
|
|
decide(&home, edit("e2", 0), unconclude("old"));
|
|
decide(&home, edit("e2", 1), conclude("kyle", "new"));
|
|
// The owner's undo of e2: the new deleted, the old made again.
|
|
decide(&home, edit("e3", 0), unconclude("new"));
|
|
decide(&home, edit("e3", 1), conclude("kyle", "old"));
|
|
let mut w = home.worker(&fake, FAST);
|
|
for _ in 0..5 {
|
|
assert_eq!(w.step(), Step::Done);
|
|
}
|
|
assert_eq!(w.step(), Step::Idle);
|
|
let order: Vec<(String, String)> = fake
|
|
.requests()
|
|
.into_iter()
|
|
.filter(|r| r.path.contains("/conclusions"))
|
|
.map(|r| {
|
|
let text = r.body.map_or(String::new(), |b| {
|
|
b["conclusions"][0]["content"].as_str().unwrap().to_string()
|
|
});
|
|
(r.method, text)
|
|
})
|
|
.collect();
|
|
let post = |t: &str| ("POST".to_string(), t.to_string());
|
|
let delete = ("DELETE".to_string(), String::new());
|
|
assert_eq!(
|
|
order,
|
|
vec![
|
|
post("old"),
|
|
delete.clone(),
|
|
post("new"),
|
|
delete,
|
|
post("old")
|
|
]
|
|
);
|
|
// Each deletion names the id its conclusion got, and records it.
|
|
let deletes = fake
|
|
.requests()
|
|
.into_iter()
|
|
.filter(|r| r.method == "DELETE")
|
|
.map(|r| r.path)
|
|
.collect::<Vec<_>>();
|
|
let path = format!("/v3/workspaces/boxmaker0-trusted/conclusions/{CONCLUSION}");
|
|
assert_eq!(deletes, vec![path.clone(), path]);
|
|
// The memory session, its workspace and peers are made sure of once in this run.
|
|
assert_eq!(posts(&fake, "/sessions").len(), 1);
|
|
assert_eq!(posts(&fake, "/peers").len(), 2);
|
|
assert_eq!(
|
|
sent_ids(&home, &edit("e2", 0)),
|
|
Some(vec![CONCLUSION.to_string()])
|
|
);
|
|
let sync = home.sync.lock().unwrap();
|
|
assert_eq!(sync.ledger().conclusion(Workspace::Trusted, "new"), None);
|
|
assert_eq!(
|
|
sync.ledger().conclusion(Workspace::Trusted, "old"),
|
|
Some((edit("e3", 1), CONCLUSION.to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_deletion_with_nothing_to_delete_is_sent_with_no_id() {
|
|
let fake = FakeHoncho::start();
|
|
let home = Home::new("edits-nothing");
|
|
decide(&home, edit("e1", 0), unconclude("never made"));
|
|
// A conclusion that failed is not live either.
|
|
decide(&home, edit("e2", 0), conclude("kyle", "refused"));
|
|
decide(&home, edit("e3", 0), unconclude("refused"));
|
|
let mut w = home.worker(&fake, FAST);
|
|
assert_eq!(w.step(), Step::Done);
|
|
assert!(fake.requests().is_empty());
|
|
assert_eq!(sent_ids(&home, &edit("e1", 0)), Some(Vec::new()));
|
|
fake.set(Mode::Status(422));
|
|
assert_eq!(w.step(), Step::Done);
|
|
fake.set(Mode::Fixtures);
|
|
fake.clear();
|
|
assert_eq!(w.step(), Step::Done);
|
|
assert!(fake.requests().is_empty(), "{:?}", fake.requests());
|
|
assert_eq!(sent_ids(&home, &edit("e3", 0)), Some(Vec::new()));
|
|
assert_eq!(
|
|
home.lines().len(),
|
|
1,
|
|
"only the refusal: {:?}",
|
|
home.lines()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_404_on_delete_is_done() {
|
|
let fake = FakeHoncho::start();
|
|
let home = Home::new("edits-404");
|
|
decide(&home, edit("e1", 0), conclude("kyle", "x"));
|
|
decide(&home, edit("e2", 0), unconclude("x"));
|
|
let mut w = home.worker(&fake, FAST);
|
|
assert_eq!(w.step(), Step::Done);
|
|
fake.set(Mode::Status(404));
|
|
assert_eq!(w.step(), Step::Done);
|
|
assert_eq!(
|
|
sent_ids(&home, &edit("e2", 0)),
|
|
Some(vec![CONCLUSION.to_string()])
|
|
);
|
|
assert!(home.lines().is_empty(), "{:?}", home.lines());
|
|
}
|
|
|
|
#[test]
|
|
fn an_untrusted_note_is_a_fleet_message_from_the_agent() {
|
|
let fake = FakeHoncho::start();
|
|
let home = Home::new("edits-fleet");
|
|
decide(&home, edit("e1", 0), fleet("The page says 3.2."));
|
|
let mut w = home.worker(&fake, FAST);
|
|
assert_eq!(w.step(), Step::Done);
|
|
let seen: Vec<(String, Value)> = fake
|
|
.requests()
|
|
.into_iter()
|
|
.map(|r| (r.path, r.body.unwrap_or(Value::Null)))
|
|
.collect();
|
|
assert_eq!(seen.len(), 4);
|
|
assert_eq!(seen[0].1["configuration"]["reasoning"]["enabled"], false);
|
|
assert_eq!(seen[2].0, "/v3/workspaces/fleet/sessions");
|
|
assert_eq!(
|
|
seen[2].1,
|
|
json!({ "id": "mm-c", "peers": {
|
|
"boxmaker0": { "observe_me": false, "observe_others": false },
|
|
} })
|
|
);
|
|
assert_eq!(
|
|
seen[3],
|
|
(
|
|
"/v3/workspaces/fleet/sessions/mm-c/messages".to_string(),
|
|
json!({ "messages": [{
|
|
"content": "The page says 3.2.",
|
|
"peer_id": "boxmaker0",
|
|
"metadata": { "agent": "boxmaker0", "boxmaker_session": "mm-c", "edit": "e1" },
|
|
}] })
|
|
)
|
|
);
|
|
assert_eq!(sent_ids(&home, &edit("e1", 0)).map(|i| i.len()), Some(1));
|
|
// A long note in parts, in one batch.
|
|
decide(&home, edit("e2", 0), fleet(&"n".repeat(MAX_PART + 1)));
|
|
assert_eq!(w.step(), Step::Done);
|
|
let last = posts(&fake, "/messages").pop().unwrap().body.unwrap();
|
|
assert_eq!(last["messages"].as_array().unwrap().len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn a_note_too_long_for_one_batch_is_marked_failed() {
|
|
let fake = FakeHoncho::start();
|
|
let home = Home::new("edits-long");
|
|
decide(&home, edit("e1", 0), fleet(&"n".repeat(MAX_PART * 100 + 1)));
|
|
let mut w = home.worker(&fake, FAST);
|
|
assert_eq!(w.step(), Step::Done);
|
|
assert!(matches!(
|
|
home.records().last(),
|
|
Some(Record::Failed { status: None, kind, .. }) if kind == "too-long"
|
|
));
|
|
assert!(fake.requests().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn the_text_is_neutralised() {
|
|
let fake = FakeHoncho::start();
|
|
let home = Home::new("edits-neutral");
|
|
decide(
|
|
&home,
|
|
edit("e1", 0),
|
|
conclude("boxmaker0", &format!("a{}b", TAGS[0].text)),
|
|
);
|
|
decide(&home, edit("e2", 0), fleet(&format!("c{}d", TAGS[1].text)));
|
|
let mut w = home.worker(&fake, FAST);
|
|
assert_eq!(w.step(), Step::Done);
|
|
assert_eq!(w.step(), Step::Done);
|
|
let concluded = posts(&fake, "/conclusions")[0].body.clone().unwrap();
|
|
let content = concluded["conclusions"][0]["content"].as_str().unwrap();
|
|
assert!(
|
|
!content.contains(TAGS[0].text) && content.contains("<"),
|
|
"{content}"
|
|
);
|
|
let message = posts(&fake, "/messages")[0].body.clone().unwrap();
|
|
let content = message["messages"][0]["content"].as_str().unwrap();
|
|
assert!(
|
|
!content.contains(TAGS[1].text) && content.contains("<"),
|
|
"{content}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn one_outbox_in_order_across_turns_and_edits() {
|
|
let fake = FakeHoncho::start();
|
|
let mut home = Home::new("edits-order");
|
|
home.log(
|
|
"mm-a",
|
|
&[
|
|
vec![spoken("one", Some("kyle")), assistant(2, Some("a"), &[])],
|
|
vec![spoken("two", Some("kyle")), assistant(2, Some("b"), &[])],
|
|
],
|
|
);
|
|
home.report("mm-a", 1);
|
|
decide(&home, edit("e1", 0), conclude("kyle", "Prefers UTC."));
|
|
home.report("mm-a", 2);
|
|
let mut w = home.worker(&fake, FAST);
|
|
for _ in 0..3 {
|
|
assert_eq!(w.step(), Step::Done);
|
|
}
|
|
let order: Vec<String> = fake
|
|
.requests()
|
|
.into_iter()
|
|
.filter(|r| r.path.ends_with("/messages") || r.path.ends_with("/conclusions"))
|
|
.map(|r| r.path)
|
|
.collect();
|
|
assert_eq!(
|
|
order,
|
|
vec![
|
|
"/v3/workspaces/boxmaker0-trusted/sessions/mm-a/messages".to_string(),
|
|
"/v3/workspaces/boxmaker0-trusted/conclusions".to_string(),
|
|
"/v3/workspaces/boxmaker0-trusted/sessions/mm-a/messages".to_string(),
|
|
]
|
|
);
|
|
// A restart sends nothing again.
|
|
home.restart();
|
|
let fake2 = FakeHoncho::start();
|
|
let mut w = home.worker(&fake2, FAST);
|
|
assert_eq!(w.step(), Step::Idle);
|
|
assert!(fake2.requests().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn errors_as_for_turns() {
|
|
let fake = FakeHoncho::start();
|
|
let home = Home::new("edits-errors");
|
|
decide(&home, edit("e1", 0), conclude("kyle", "private-fact"));
|
|
decide(&home, edit("e2", 0), fleet("private-note"));
|
|
decide(&home, edit("e3", 0), conclude("kyle", "second"));
|
|
let mut w = home.worker(&fake, FAST);
|
|
fake.set(Mode::Status(503));
|
|
assert!(matches!(w.step(), Step::Backoff(_)));
|
|
fake.set(Mode::Status(422));
|
|
assert_eq!(w.step(), Step::Done);
|
|
assert!(matches!(
|
|
home.records().last(),
|
|
Some(Record::Failed { status: Some(422), kind, .. }) if kind == "status"
|
|
));
|
|
fake.set(Mode::Status(401));
|
|
assert_eq!(w.step(), Step::Done, "the fleet's key refused");
|
|
fake.set(Mode::Fixtures);
|
|
assert_eq!(w.step(), Step::Done, "the trusted workspace goes on");
|
|
assert!(sent_ids(&home, &edit("e3", 0)).is_some());
|
|
assert_eq!(w.step(), Step::Idle, "the fleet note waits");
|
|
let lines = home.lines();
|
|
assert_eq!(lines.len(), 4, "{lines:?}");
|
|
assert!(
|
|
lines[2].starts_with("memoryd: part 0 of edit e1 was not sent: "),
|
|
"{lines:?}"
|
|
);
|
|
assert!(lines[3].contains("workspace fleet"), "{lines:?}");
|
|
for line in lines {
|
|
assert!(!line.contains("private"), "{line}");
|
|
}
|
|
}
|