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:
2026-09-18 17:19:02 -07:00
co-authored by Claude Fable 5.1
parent f238e6a260
commit e156975649
41 changed files with 4883 additions and 3 deletions
@@ -0,0 +1,7 @@
{"type":"session_start","time":"2026-09-18T08:05:00.000Z","session":"chat-1789700000-42","epoch":0,"slot":0,"baseline":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"}
{"type":"user","time":"2026-09-18T08:05:01.000Z","content":"What time is it?"}
{"type":"assistant","time":"2026-09-18T08:05:03.000Z","content":null,"reasoning_content":null,"tool_calls":[{"id":"call_a1","name":"clock","arguments":"{}"}]}
{"type":"usage","time":"2026-09-18T08:05:03.000Z","cache_n":539,"prompt_n":27,"predicted_n":24,"reasoning_tokens":8,"thinking_capped":false}
{"type":"tool_result","time":"2026-09-18T08:05:03.100Z","call":1,"tool_call_id":"call_a1","content":"2026-09-18T08:05:03.000Z","class":"public","untrusted":false,"truncated":false}
{"type":"assistant","time":"2026-09-18T08:05:05.000Z","content":"It is five past eight.","reasoning_content":null,"tool_calls":[]}
{"type":"usage","time":"2026-09-18T08:05:05.000Z","cache_n":589,"prompt_n":40,"predicted_n":64,"reasoning_tokens":27,"thinking_capped":true}
@@ -0,0 +1 @@
{"v":1,"id":3,"final":true,"msg":{"kind":"error","body":{"code":"session_full","detail":"this conversation is full; start a new one"}}}
@@ -0,0 +1 @@
{"v":1,"id":3,"final":true,"msg":{"kind":"turn","body":{"session":"chat-1789700000-42","content":"What time is it?","resume":false}}}
@@ -0,0 +1 @@
{"v":1,"id":3,"final":true,"msg":{"kind":"turn_done","body":{"content":"It is noon.","usage":{"cache_n":539,"prompt_n":27,"predicted_n":24,"reasoning_tokens":8,"thinking_capped":false}}}}
@@ -0,0 +1 @@
{"v":1,"id":3,"final":false,"msg":{"kind":"turn_event","body":{"event":"content","text":"It is "}}}
@@ -0,0 +1 @@
{"v":1,"id":3,"final":false,"msg":{"kind":"turn_event","body":{"event":"retrying","attempt":2,"after_ms":1500,"error":"the server went silent"}}}
@@ -0,0 +1 @@
{"v":1,"id":3,"final":false,"msg":{"kind":"turn_event","body":{"event":"tool_result","name":"clock","class":"public","truncated":false}}}
@@ -0,0 +1,126 @@
//! Every JSON object in every fixture must reject an unknown key. Do not edit.
//!
//! The other test files check unknown fields in a few hand-picked places. This one checks all of
//! them: it walks each fixture, adds one unknown key to one object at a time, at every depth, and
//! requires that the result no longer decodes.
use proto::{AuditRecord, Envelope, Grant, LogRecord};
use serde::de::DeserializeOwned;
use serde_json::Value;
/// Every copy of `value` that has exactly one extra key in exactly one object.
fn with_one_unknown_key(value: &Value) -> Vec<Value> {
let mut out = Vec::new();
match value {
Value::Object(map) => {
let mut extended = map.clone();
extended.insert("zz_unknown".to_string(), Value::Bool(true));
out.push(Value::Object(extended));
for (key, child) in map {
for changed in with_one_unknown_key(child) {
let mut copy = map.clone();
copy.insert(key.clone(), changed);
out.push(Value::Object(copy));
}
}
}
Value::Array(items) => {
for (i, child) in items.iter().enumerate() {
for changed in with_one_unknown_key(child) {
let mut copy = items.clone();
copy[i] = changed;
out.push(Value::Array(copy));
}
}
}
_ => {}
}
out
}
/// Returns how many variations were tried, so callers can check the walk reached nested objects.
fn check<T: DeserializeOwned>(what: &str, text: &str) -> usize {
let value: Value = serde_json::from_str(text).unwrap_or_else(|e| panic!("{what}: {e}"));
assert!(
serde_json::from_value::<T>(value.clone()).is_ok(),
"{what}: fixture must decode"
);
let variations = with_one_unknown_key(&value);
for changed in &variations {
assert!(
serde_json::from_value::<T>(changed.clone()).is_err(),
"{what}: accepted an unknown key: {changed}"
);
}
variations.len()
}
fn fixture(path: &str) -> String {
let full = format!("{}/tests/fixtures/{path}", env!("CARGO_MANIFEST_DIR"));
std::fs::read_to_string(&full).unwrap_or_else(|e| panic!("{full}: {e}"))
}
#[test]
fn envelopes_reject_unknown_keys_at_every_depth() {
for name in [
"tool_request.json",
"tool_response_pending.json",
"tool_response_result.json",
"tool_response_failed.json",
"tool_response_denied.json",
"error.json",
"turn.json",
"turn_event_tool_result.json",
"turn_event_content.json",
"turn_event_retrying.json",
"turn_done.json",
"error_session_full.json",
] {
// Envelope, msg and body: three objects; turn_done also has a usage object.
let want = if name == "turn_done.json" { 4 } else { 3 };
assert_eq!(
check::<Envelope>(name, &fixture(&format!("wire/{name}"))),
want,
"{name}"
);
}
}
#[test]
fn audit_records_reject_unknown_keys_at_every_depth() {
for (i, line) in fixture("records/audit.jsonl").lines().enumerate() {
// The record and its decision: two objects.
assert_eq!(
check::<AuditRecord>(&format!("audit.jsonl:{}", i + 1), line),
2
);
}
}
#[test]
fn log_records_reject_unknown_keys_at_every_depth() {
let mut tried = 0;
for (i, line) in fixture("records/session.jsonl").lines().enumerate() {
tried += check::<LogRecord>(&format!("session.jsonl:{}", i + 1), line);
}
// Seven records, plus the one tool call inside the first assistant record.
assert_eq!(tried, 8);
}
#[test]
fn usage_log_records_reject_unknown_keys_at_every_depth() {
let mut tried = 0;
for (i, line) in fixture("records/session_usage.jsonl").lines().enumerate() {
tried += check::<LogRecord>(&format!("session_usage.jsonl:{}", i + 1), line);
}
// Seven records, plus the one tool call inside the first assistant record.
assert_eq!(tried, 8);
}
#[test]
fn grants_reject_unknown_keys_at_every_depth() {
let grant: Grant = toml::from_str(&fixture("grant/full.toml")).unwrap();
let text = serde_json::to_string(&grant).unwrap();
// The grant and its constraints: two objects.
assert_eq!(check::<Grant>("full.toml as JSON", &text), 2);
}
@@ -0,0 +1,251 @@
//! Tests for the channel messages and the usage record, against byte-exact fixtures. Do not edit.
use proto::{
CallId, DataClass, Envelope, Epoch, ErrorCode, Hash32, LogRecord, Message, SessionId,
Timestamp, ToolCall, Turn, TurnDone, TurnEvent, Usage, WireError,
};
fn fixture(kind: &str, name: &str) -> String {
let path = format!(
"{}/tests/fixtures/{kind}/{name}",
env!("CARGO_MANIFEST_DIR")
);
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("{path}: {e}"))
.trim_end_matches('\n')
.to_string()
}
fn check(name: &str, want: Envelope) {
let text = fixture("wire", name);
let got: Envelope = serde_json::from_str(&text).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(got, want, "{name}: decoded value");
assert_eq!(
serde_json::to_string(&want).unwrap(),
text,
"{name}: encoded bytes"
);
}
fn env(id: u64, r#final: bool, msg: Message) -> Envelope {
Envelope {
v: 1,
id,
r#final,
msg,
}
}
fn usage() -> Usage {
Usage {
cache_n: 539,
prompt_n: 27,
predicted_n: 24,
reasoning_tokens: 8,
thinking_capped: false,
}
}
#[test]
fn turn() {
let body = Turn {
session: SessionId::new("chat-1789700000-42").unwrap(),
content: "What time is it?".to_string(),
resume: false,
};
check("turn.json", env(3, true, Message::Turn(body)));
}
#[test]
fn turn_events() {
check(
"turn_event_tool_result.json",
env(
3,
false,
Message::TurnEvent(TurnEvent::ToolResult {
name: "clock".to_string(),
class: DataClass::Public,
truncated: false,
}),
),
);
check(
"turn_event_content.json",
env(
3,
false,
Message::TurnEvent(TurnEvent::Content {
text: "It is ".to_string(),
}),
),
);
check(
"turn_event_retrying.json",
env(
3,
false,
Message::TurnEvent(TurnEvent::Retrying {
attempt: 2,
after_ms: 1500,
error: "the server went silent".to_string(),
}),
),
);
}
#[test]
fn turn_done_and_the_new_error_codes() {
check(
"turn_done.json",
env(
3,
true,
Message::TurnDone(TurnDone {
content: "It is noon.".to_string(),
usage: usage(),
}),
),
);
let body = WireError {
code: ErrorCode::SessionFull,
detail: "this conversation is full; start a new one".to_string(),
};
check(
"error_session_full.json",
env(3, true, Message::Error(body)),
);
let codes = [
(ErrorCode::SessionFull, "session_full"),
(ErrorCode::TurnLimit, "turn_limit"),
(ErrorCode::SessionBusy, "session_busy"),
(ErrorCode::NoSuchSession, "no_such_session"),
(ErrorCode::SessionExists, "session_exists"),
(ErrorCode::Inference, "inference"),
];
for (value, text) in codes {
assert_eq!(
serde_json::to_string(&value).unwrap(),
format!("\"{text}\"")
);
}
}
#[test]
fn every_turn_event_kind_round_trips() {
let all = vec![
TurnEvent::Queued { ahead: 1 },
TurnEvent::Waiting { slot_busy: true },
TurnEvent::Progress {
total: 100,
cache: 50,
processed: 75,
},
TurnEvent::Reasoning {
text: "hm".to_string(),
},
TurnEvent::Content {
text: "hi".to_string(),
},
TurnEvent::ToolCallStarted {
name: "clock".to_string(),
},
TurnEvent::ToolResult {
name: "clock".to_string(),
class: DataClass::Secret,
truncated: true,
},
TurnEvent::ThinkingCapped { tokens: 4096 },
TurnEvent::Retrying {
attempt: 2,
after_ms: 10,
error: "x".to_string(),
},
TurnEvent::CacheLoss {
expected: 500,
got: 20,
},
];
for event in all {
let text = serde_json::to_string(&event).unwrap();
assert!(text.starts_with("{\"event\":\""), "{text}");
assert_eq!(serde_json::from_str::<TurnEvent>(&text).unwrap(), event);
}
assert!(serde_json::from_str::<TurnEvent>(r#"{"event":"content","text":"x","zz":1}"#).is_err());
assert!(serde_json::from_str::<TurnEvent>(r#"{"event":"dance"}"#).is_err());
}
#[test]
fn usage_records() {
let text = fixture("records", "session_usage.jsonl");
let lines: Vec<&str> = text.lines().collect();
let want = [
LogRecord::SessionStart {
time: Timestamp::parse("2026-09-18T08:05:00.000Z").unwrap(),
session: SessionId::new("chat-1789700000-42").unwrap(),
epoch: Epoch(0),
slot: 0,
baseline: Hash32::from_hex(
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
)
.unwrap(),
},
LogRecord::User {
time: Timestamp::parse("2026-09-18T08:05:01.000Z").unwrap(),
content: "What time is it?".to_string(),
},
LogRecord::Assistant {
time: Timestamp::parse("2026-09-18T08:05:03.000Z").unwrap(),
content: None,
reasoning_content: None,
tool_calls: vec![ToolCall {
id: "call_a1".to_string(),
name: "clock".to_string(),
arguments: "{}".to_string(),
}],
},
LogRecord::Usage {
time: Timestamp::parse("2026-09-18T08:05:03.000Z").unwrap(),
cache_n: 539,
prompt_n: 27,
predicted_n: 24,
reasoning_tokens: 8,
thinking_capped: false,
},
LogRecord::ToolResult {
time: Timestamp::parse("2026-09-18T08:05:03.100Z").unwrap(),
call: CallId(1),
tool_call_id: "call_a1".to_string(),
content: "2026-09-18T08:05:03.000Z".to_string(),
class: DataClass::Public,
untrusted: false,
truncated: false,
},
LogRecord::Assistant {
time: Timestamp::parse("2026-09-18T08:05:05.000Z").unwrap(),
content: Some("It is five past eight.".to_string()),
reasoning_content: None,
tool_calls: vec![],
},
LogRecord::Usage {
time: Timestamp::parse("2026-09-18T08:05:05.000Z").unwrap(),
cache_n: 589,
prompt_n: 40,
predicted_n: 64,
reasoning_tokens: 27,
thinking_capped: true,
},
];
assert_eq!(lines.len(), want.len());
for (i, (line, want)) in lines.iter().zip(&want).enumerate() {
let got: LogRecord =
serde_json::from_str(line).unwrap_or_else(|e| panic!("line {}: {e}", i + 1));
assert_eq!(&got, want, "line {}", i + 1);
assert_eq!(
&serde_json::to_string(want).unwrap(),
line,
"line {}: bytes",
i + 1
);
}
}