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>
298 lines
8.6 KiB
Rust
298 lines
8.6 KiB
Rust
//! 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"
|
|
);
|
|
}
|