Add M2b plan: ten tasks, tests, recordings and the first system prompt
The tasks build the agent loop on M2a's client: channel messages and the usage record in proto, four config tables, the tool port and registry with find_tool and call_tool, the baseline and replay, the session store, the turn loop with its limits and the append-only property test, the channel server, loopd serve, bxctl chat, and the device checks including a four-turn conversation with a restart. Checked against a private reference implementation: the gate passes after every task in order, the new suites pass under CPU load, and the reference passes make verify-device on straylight with no cache loss. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
//! Tests for the baseline and the replay function. Do not edit.
|
||||
|
||||
mod support;
|
||||
|
||||
use loopd::baseline::{Baseline, messages};
|
||||
use loopd::llama::ChatMessage;
|
||||
use loopd::tools::Registry;
|
||||
use proto::{CallId, DataClass, Epoch, Hash32, LogRecord, SessionId, Timestamp, ToolCall};
|
||||
use std::path::Path;
|
||||
use support::Home;
|
||||
|
||||
fn ts() -> Timestamp {
|
||||
Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assembles_system_prompt_and_core_schemas() {
|
||||
let home = Home::new();
|
||||
let cfg = home.config(Path::new("/tmp/unused.sock"));
|
||||
let b = Baseline::assemble(&cfg, &Registry::m2b()).unwrap();
|
||||
assert_eq!(
|
||||
b.system, "You are Boxmaker, a test agent.",
|
||||
"trailing newline trimmed"
|
||||
);
|
||||
let names: Vec<&str> = b.tools.iter().map(|t| t.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
["clock", "find_tool", "call_tool"],
|
||||
"core tools, then the two meta-tools; not echo"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_memory_is_appended_when_present_and_only_then() {
|
||||
let home = Home::new();
|
||||
let cfg = home.config(Path::new("/tmp/unused.sock"));
|
||||
home.write("memory/core.md", "The owner likes cork.\n\n");
|
||||
let b = Baseline::assemble(&cfg, &Registry::m2b()).unwrap();
|
||||
assert_eq!(
|
||||
b.system,
|
||||
"You are Boxmaker, a test agent.\n\nThe owner likes cork."
|
||||
);
|
||||
home.write("memory/core.md", " \n");
|
||||
let b = Baseline::assemble(&cfg, &Registry::m2b()).unwrap();
|
||||
assert_eq!(
|
||||
b.system, "You are Boxmaker, a test agent.",
|
||||
"an empty core file adds nothing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_system_prompt_is_an_error_naming_the_file() {
|
||||
let home = Home::new();
|
||||
let mut cfg = home.config(Path::new("/tmp/unused.sock"));
|
||||
cfg.baseline.system = home.dir.join("nope.md");
|
||||
let e = Baseline::assemble(&cfg, &Registry::m2b()).unwrap_err();
|
||||
assert!(e.to_string().contains("nope.md"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_round_trip_and_hash() {
|
||||
let home = Home::new();
|
||||
let cfg = home.config(Path::new("/tmp/unused.sock"));
|
||||
let b = Baseline::assemble(&cfg, &Registry::m2b()).unwrap();
|
||||
let text = b.to_json().unwrap();
|
||||
assert_eq!(Baseline::from_json(&text).unwrap(), b);
|
||||
assert_eq!(
|
||||
b.hash().unwrap(),
|
||||
proto::sha256(text.as_bytes()).unwrap(),
|
||||
"the hash is of the JSON as written"
|
||||
);
|
||||
assert_ne!(b.hash().unwrap(), Hash32::ZERO);
|
||||
let mut other = b.clone();
|
||||
other.system.push('!');
|
||||
assert_ne!(other.hash().unwrap(), b.hash().unwrap());
|
||||
assert!(
|
||||
Baseline::from_json(&text.replacen("\"system\"", "\"zz\":1,\"system\"", 1)).is_err(),
|
||||
"unknown keys are rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_keeps_only_messages_in_order_and_unchanged() {
|
||||
let b = Baseline {
|
||||
system: "sys".to_string(),
|
||||
tools: vec![],
|
||||
};
|
||||
let call = ToolCall {
|
||||
id: "c1".to_string(),
|
||||
name: "clock".to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
};
|
||||
let records = vec![
|
||||
LogRecord::SessionStart {
|
||||
time: ts(),
|
||||
session: SessionId::new("s").unwrap(),
|
||||
epoch: Epoch(0),
|
||||
slot: 0,
|
||||
baseline: Hash32::ZERO,
|
||||
},
|
||||
LogRecord::User {
|
||||
time: ts(),
|
||||
content: "hi".to_string(),
|
||||
},
|
||||
LogRecord::Assistant {
|
||||
time: ts(),
|
||||
content: None,
|
||||
reasoning_content: Some("think".to_string()),
|
||||
tool_calls: vec![call.clone()],
|
||||
},
|
||||
LogRecord::Usage {
|
||||
time: ts(),
|
||||
cache_n: 1,
|
||||
prompt_n: 2,
|
||||
predicted_n: 3,
|
||||
reasoning_tokens: 1,
|
||||
thinking_capped: false,
|
||||
},
|
||||
LogRecord::ToolResult {
|
||||
time: ts(),
|
||||
call: CallId(1),
|
||||
tool_call_id: "c1".to_string(),
|
||||
content: "noon".to_string(),
|
||||
class: DataClass::Public,
|
||||
untrusted: false,
|
||||
truncated: false,
|
||||
},
|
||||
LogRecord::CacheLoss {
|
||||
time: ts(),
|
||||
expected: 10,
|
||||
got: 0,
|
||||
},
|
||||
LogRecord::Assistant {
|
||||
time: ts(),
|
||||
content: Some("It is noon.".to_string()),
|
||||
reasoning_content: None,
|
||||
tool_calls: vec![],
|
||||
},
|
||||
LogRecord::EpochEnd {
|
||||
time: ts(),
|
||||
next: Epoch(1),
|
||||
summary: "x".to_string(),
|
||||
},
|
||||
];
|
||||
let want = vec![
|
||||
ChatMessage::System {
|
||||
content: "sys".to_string(),
|
||||
},
|
||||
ChatMessage::User {
|
||||
content: "hi".to_string(),
|
||||
},
|
||||
ChatMessage::Assistant {
|
||||
content: None,
|
||||
reasoning_content: Some("think".to_string()),
|
||||
tool_calls: vec![call],
|
||||
},
|
||||
ChatMessage::Tool {
|
||||
tool_call_id: "c1".to_string(),
|
||||
content: "noon".to_string(),
|
||||
},
|
||||
ChatMessage::Assistant {
|
||||
content: Some("It is noon.".to_string()),
|
||||
reasoning_content: None,
|
||||
tool_calls: vec![],
|
||||
},
|
||||
];
|
||||
assert_eq!(messages(&b, &records), want);
|
||||
assert_eq!(
|
||||
messages(&b, &[]),
|
||||
vec![ChatMessage::System {
|
||||
content: "sys".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
/// Replaying a prefix of the log gives a prefix of the messages: the function never reorders
|
||||
/// or rewrites. Checked for every prefix of a generated log.
|
||||
#[test]
|
||||
fn replay_of_a_prefix_is_a_prefix() {
|
||||
let b = Baseline {
|
||||
system: "sys".to_string(),
|
||||
tools: vec![],
|
||||
};
|
||||
let mut records = Vec::new();
|
||||
let mut seed: u64 = 0x9e3779b97f4a7c15;
|
||||
let mut next = || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed
|
||||
};
|
||||
for i in 0..200u64 {
|
||||
let r = match next() % 5 {
|
||||
0 => LogRecord::User {
|
||||
time: ts(),
|
||||
content: format!("u{i}"),
|
||||
},
|
||||
1 => LogRecord::Assistant {
|
||||
time: ts(),
|
||||
content: Some(format!("a{i}")),
|
||||
reasoning_content: None,
|
||||
tool_calls: vec![],
|
||||
},
|
||||
2 => LogRecord::ToolResult {
|
||||
time: ts(),
|
||||
call: CallId(i),
|
||||
tool_call_id: format!("t{i}"),
|
||||
content: "x".repeat((next() % 50) as usize),
|
||||
class: DataClass::Private,
|
||||
untrusted: true,
|
||||
truncated: false,
|
||||
},
|
||||
3 => LogRecord::Usage {
|
||||
time: ts(),
|
||||
cache_n: i,
|
||||
prompt_n: 1,
|
||||
predicted_n: 1,
|
||||
reasoning_tokens: 0,
|
||||
thinking_capped: false,
|
||||
},
|
||||
_ => LogRecord::CacheLoss {
|
||||
time: ts(),
|
||||
expected: i,
|
||||
got: 0,
|
||||
},
|
||||
};
|
||||
records.push(r);
|
||||
}
|
||||
let whole = messages(&b, &records);
|
||||
for n in 0..=records.len() {
|
||||
let part = messages(&b, &records[..n]);
|
||||
assert_eq!(whole[..part.len()], part[..], "prefix of {n} records");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user