Hand over the M3a plan: 22 tasks, their files, and the check record
Task files, the files they copy in (byte-identical to the reference on m3a-ref), each area's check record, and a README with the per-task table of what each check exposed. The handoff note is done with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
//! Tests for one turn: record sequences and tool dispatch. Do not edit.
|
||||
//! The limits and the append-only property are in `limits.rs`; denials and approvals are in
|
||||
//! `turn_broker.rs`.
|
||||
|
||||
mod support;
|
||||
#[path = "support/turn.rs"]
|
||||
mod turn_support;
|
||||
|
||||
use loopd::tools::Registry;
|
||||
use proto::{DataClass, LogRecord, SessionId, ToolResponse, TurnEvent};
|
||||
use support::{Reply, ok_result};
|
||||
use turn_support::{setup, types};
|
||||
|
||||
const CHAT: &str = "/v1/chat/completions";
|
||||
|
||||
#[test]
|
||||
fn a_plain_turn() {
|
||||
let s = setup(vec![]);
|
||||
s.server.route(CHAT, vec![Reply::fixture("plain")]);
|
||||
let mut session = s.session("a");
|
||||
let (result, events) = s.turn(&mut session, "hello");
|
||||
let outcome = result.unwrap();
|
||||
assert_eq!(
|
||||
outcome.content,
|
||||
support::expected("plain")["content"].as_str().unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
(outcome.usage.prompt_n, outcome.usage.predicted_n),
|
||||
(46, 16)
|
||||
);
|
||||
assert_eq!(
|
||||
types(session.records()),
|
||||
["start", "user", "assistant", "usage"]
|
||||
);
|
||||
assert_eq!(
|
||||
types(&s.home.records("a")),
|
||||
["start", "user", "assistant", "usage"],
|
||||
"on disk too"
|
||||
);
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|e| matches!(e, TurnEvent::Content { .. }))
|
||||
);
|
||||
assert!(
|
||||
!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, TurnEvent::ToolCallStarted { .. }))
|
||||
);
|
||||
assert!(s.port.calls().is_empty());
|
||||
|
||||
let sent = s.server.requests_to(CHAT);
|
||||
assert_eq!(sent.len(), 1);
|
||||
let body = sent[0].json();
|
||||
assert_eq!(body["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
body["messages"][0]["content"],
|
||||
"You are Boxmaker, a test agent."
|
||||
);
|
||||
assert_eq!(
|
||||
body["messages"][1],
|
||||
serde_json::json!({"role": "user", "content": "hello"})
|
||||
);
|
||||
let names: Vec<&str> = body["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|t| t["function"]["name"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(names, ["clock", "find_tool", "call_tool"]);
|
||||
assert_eq!(body["id_slot"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tool_turn_goes_through_the_port_and_records_everything() {
|
||||
let s = setup(vec![ok_result("straylight\n")]);
|
||||
s.server.route(
|
||||
CHAT,
|
||||
vec![Reply::fixture("tool_call"), Reply::fixture("plain")],
|
||||
);
|
||||
let mut session = s.session("a");
|
||||
let (result, events) = s.turn(&mut session, "read the hostname");
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
assert_eq!(
|
||||
types(session.records()),
|
||||
[
|
||||
"start",
|
||||
"user",
|
||||
"assistant",
|
||||
"usage",
|
||||
"tool_result",
|
||||
"assistant",
|
||||
"usage"
|
||||
]
|
||||
);
|
||||
|
||||
let calls = s.port.calls();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].tool, "read_file");
|
||||
assert_eq!(
|
||||
calls[0].arguments, r#"{"path":"/etc/hostname"}"#,
|
||||
"arguments are passed on unparsed"
|
||||
);
|
||||
assert_eq!(calls[0].session, SessionId::new("a").unwrap());
|
||||
assert_eq!(calls[0].call, proto::CallId(1));
|
||||
|
||||
match &session.records()[4] {
|
||||
LogRecord::ToolResult {
|
||||
call,
|
||||
tool_call_id,
|
||||
content,
|
||||
class,
|
||||
untrusted,
|
||||
truncated,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(*call, proto::CallId(1));
|
||||
assert_eq!(
|
||||
tool_call_id, "wgE8iFI58Zni4WCTiCMNp4TzCcM8ou7F",
|
||||
"the server's id, so the template can pair it"
|
||||
);
|
||||
assert_eq!(content, "straylight\n");
|
||||
assert_eq!(
|
||||
(*class, *untrusted, *truncated),
|
||||
(DataClass::Private, true, false)
|
||||
);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
let kinds: Vec<&str> = events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TurnEvent::ToolCallStarted { name } => Some(name.as_str()),
|
||||
TurnEvent::ToolResult { name, .. } => Some(name.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(kinds, ["read_file", "read_file"]);
|
||||
|
||||
// The second request extends the first: the tool result sits after the assistant turn.
|
||||
let sent = s.server.requests_to(CHAT);
|
||||
let m2 = sent[1].json()["messages"].as_array().unwrap().clone();
|
||||
assert_eq!(m2[2]["role"], "assistant");
|
||||
assert_eq!(
|
||||
m2[2]["tool_calls"][0]["id"],
|
||||
"wgE8iFI58Zni4WCTiCMNp4TzCcM8ou7F"
|
||||
);
|
||||
assert_eq!(
|
||||
m2[3],
|
||||
serde_json::json!({"role": "tool", "tool_call_id": "wgE8iFI58Zni4WCTiCMNp4TzCcM8ou7F", "content": "straylight\n"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_tool_and_call_tool_reach_the_port_only_for_the_target() {
|
||||
let s = setup(vec![ok_result("box")]);
|
||||
s.server.route(
|
||||
CHAT,
|
||||
vec![
|
||||
Reply::fixture("find_tool"),
|
||||
Reply::fixture("call_tool"),
|
||||
Reply::fixture("plain"),
|
||||
],
|
||||
);
|
||||
let mut session = s.session("a");
|
||||
let (result, _) = s.turn(&mut session, "echo box");
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
assert_eq!(
|
||||
types(session.records()),
|
||||
[
|
||||
"start",
|
||||
"user",
|
||||
"assistant",
|
||||
"usage",
|
||||
"tool_result",
|
||||
"assistant",
|
||||
"usage",
|
||||
"tool_result",
|
||||
"assistant",
|
||||
"usage"
|
||||
]
|
||||
);
|
||||
let calls = s.port.calls();
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
1,
|
||||
"find_tool is answered by loopd; only echo reaches the port"
|
||||
);
|
||||
assert_eq!(calls[0].tool, "echo");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&calls[0].arguments).unwrap(),
|
||||
serde_json::json!({"text": "box"})
|
||||
);
|
||||
match &session.records()[4] {
|
||||
LogRecord::ToolResult {
|
||||
content,
|
||||
class,
|
||||
untrusted,
|
||||
..
|
||||
} => {
|
||||
assert!(
|
||||
content.contains("\"name\":\"echo\"")
|
||||
&& content.ends_with("Call it with call_tool."),
|
||||
"{content}"
|
||||
);
|
||||
assert_eq!((*class, *untrusted), (DataClass::Public, false));
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_tool_for_an_unknown_tool_never_reaches_the_port() {
|
||||
// The recorded call_tool asks for "echo"; with echo removed from the registry it is unknown.
|
||||
let mut s = setup(vec![]);
|
||||
s.registry = Registry::new(vec![loopd::tools::Entry {
|
||||
schema: loopd::tools::clock_schema(),
|
||||
core: true,
|
||||
}]);
|
||||
s.server.route(
|
||||
CHAT,
|
||||
vec![Reply::fixture("call_tool"), Reply::fixture("plain")],
|
||||
);
|
||||
let mut session = s.session("a");
|
||||
assert!(s.turn(&mut session, "x").0.is_ok());
|
||||
assert!(s.port.calls().is_empty());
|
||||
assert!(
|
||||
matches!(&session.records()[4], LogRecord::ToolResult { content, .. } if content.contains("No tool named \"echo\""))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_result_cap_applies_when_appended() {
|
||||
let mut s = setup(vec![ok_result(&"x".repeat(100))]);
|
||||
s.cfg.r#loop.tool_result_cap = 20;
|
||||
s.server.route(
|
||||
CHAT,
|
||||
vec![Reply::fixture("tool_call"), Reply::fixture("plain")],
|
||||
);
|
||||
let mut session = s.session("a");
|
||||
let (result, events) = s.turn(&mut session, "x");
|
||||
assert!(result.is_ok());
|
||||
match &session.records()[4] {
|
||||
LogRecord::ToolResult {
|
||||
content, truncated, ..
|
||||
} => {
|
||||
assert_eq!(content, &format!("{}\n[truncated]", "x".repeat(20)));
|
||||
assert!(truncated);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert!(events.iter().any(|e| matches!(
|
||||
e,
|
||||
TurnEvent::ToolResult {
|
||||
truncated: true,
|
||||
..
|
||||
}
|
||||
)));
|
||||
let m2 = s.server.requests_to(CHAT)[1].json();
|
||||
assert_eq!(
|
||||
m2["messages"][3]["content"].as_str().unwrap().len(),
|
||||
20 + "\n[truncated]".len(),
|
||||
"the model sees the capped text"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tool_failure_becomes_a_result_the_model_can_read() {
|
||||
let s = setup(vec![ToolResponse::Failed {
|
||||
message: "disk on fire".to_string(),
|
||||
}]);
|
||||
s.server.route(
|
||||
CHAT,
|
||||
vec![Reply::fixture("tool_call"), Reply::fixture("plain")],
|
||||
);
|
||||
let mut session = s.session("a");
|
||||
let (result, events) = s.turn(&mut session, "x");
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
match &session.records()[4] {
|
||||
LogRecord::ToolResult {
|
||||
content,
|
||||
class,
|
||||
untrusted,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(content, "The tool failed: disk on fire");
|
||||
assert_eq!((*class, *untrusted), (DataClass::Public, false));
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert!(
|
||||
!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, TurnEvent::ToolDenied { .. })),
|
||||
"a failure is not a denial"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user