Add the turn loop with its limits

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-18 19:14:52 -07:00
parent 9704a64507
commit 9063cf958e
9 changed files with 1218 additions and 0 deletions
+280
View File
@@ -0,0 +1,280 @@
//! Tests for one turn: record sequences and tool dispatch. Do not edit.
//! The limits and the append-only property are in `limits.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 tool_failures_and_denials_become_results_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");
assert!(s.turn(&mut session, "x").0.is_ok());
assert!(
matches!(&session.records()[4], LogRecord::ToolResult { content, class: DataClass::Public, .. } if content.contains("disk on fire"))
);
}