YAML configuration, init without a wizard, and openai-chat-completions
ci / check (push) Successful in 15m52s

- /config/agent.yaml and /config/boot.yaml replace the JSON files, read
  with serde_yaml_ng (also in the agent, inside WASM).
- sausage init asks nothing and reads no SAUSAGE_* variables. It writes
  commented templates with an example for each API, and prints the path
  to edit. Until the agent is configured, agent.chat names the missing
  field. allow_no_token: true permits a local model without a key.
- The agent speaks openai-chat-completions, the format of OpenRouter,
  Ollama, and most providers. base_url follows each SDK: with /v1 for
  the OpenAI shapes, without it for Anthropic.
- Checked against the proxy with glm-5.3-flash: the agent wrote, built,
  and activated a counter plugin in 55 s.
This commit is contained in:
2026-10-23 11:27:48 +01:00
parent ab1adbd5e3
commit b536beb069
19 changed files with 481 additions and 123 deletions
+2 -2
View File
@@ -232,7 +232,7 @@ async fn review_shutdown_suppresses_hooks() {
old["x-sausage-grace"] = json!(60);
world.plugin("notes", old, document("serve"));
std::fs::write(
world.0.join("config/boot.json"),
world.0.join("config/boot.yaml"),
json!([{"method":"notes.serve","params":{}}]).to_string(),
)
.unwrap();
@@ -766,7 +766,7 @@ async fn restarts_service() {
let world = TestWorld::new();
world.plugin("notes", document("serve"), document("serve"));
std::fs::write(
world.0.join("config/boot.json"),
world.0.join("config/boot.yaml"),
json!([{"method":"notes.serve","params":{}}]).to_string(),
)
.unwrap();
+84 -15
View File
@@ -13,18 +13,22 @@ use tokio::{
};
/// The two HTTP contracts supported by the agent.
const APIS: [&str; 2] = ["openai-responses", "anthropic-messages"];
const APIS: [&str; 3] = [
"openai-responses",
"anthropic-messages",
"openai-chat-completions",
];
/// A captured request and the script's delayed HTTP response.
type Exchange = (String, Value, oneshot::Sender<(u16, Value)>);
/// The script controls each response, including concurrent requests.
struct Llm {
url: String,
pub(super) struct Llm {
pub(super) url: String,
api: &'static str,
requests: mpsc::UnboundedReceiver<Exchange>,
task: tokio::task::JoinHandle<()>,
}
impl Llm {
async fn new(api: &'static str) -> Self {
pub(super) async fn new(api: &'static str) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let (tx, requests) = mpsc::unbounded_channel::<Exchange>();
@@ -37,6 +41,11 @@ impl Llm {
service_fn(move |request: hyper::Request<hyper::body::Incoming>| {
let tx = tx.clone();
async move {
assert_eq!(request.method(), "POST");
// The local Chat Completions fixture explicitly disables authentication.
if api == APIS[2] {
assert!(!request.headers().contains_key("authorization"));
}
let path = request.uri().path().to_string();
let bytes = request.into_body().collect().await.unwrap().to_bytes();
let body = serde_json::from_slice(&bytes).unwrap();
@@ -66,7 +75,7 @@ impl Llm {
task,
}
}
async fn next(&mut self) -> (Value, oneshot::Sender<(u16, Value)>) {
pub(super) async fn next(&mut self) -> (Value, oneshot::Sender<(u16, Value)>) {
let (path, body, reply) =
tokio::time::timeout(Duration::from_secs(60), self.requests.recv())
.await
@@ -76,6 +85,8 @@ impl Llm {
path,
if self.api == APIS[0] {
"/v1/responses"
} else if self.api == APIS[2] {
"/v1/chat/completions"
} else {
"/v1/messages"
}
@@ -83,8 +94,11 @@ impl Llm {
assert_eq!(body["model"], "scripted-test");
(body, reply)
}
fn answer(&self, text: &str, calls: &[(&str, Value)]) -> Value {
if self.api == APIS[0] {
pub(super) fn answer(&self, text: &str, calls: &[(&str, Value)]) -> Value {
if self.api == APIS[2] {
let calls: Vec<Value> = calls.iter().enumerate().map(|(n,(name,args))| json!({"id":format!("tool-{n}"),"type":"function","function":{"name":name,"arguments":args.to_string()}})).collect();
json!({"choices":[{"message":{"role":"assistant","content":text,"tool_calls":calls}}]})
} else if self.api == APIS[0] {
let mut output =
vec![json!({"type":"message","content":[{"type":"output_text","text":text}]})];
for (n, (name, args)) in calls.iter().enumerate() {
@@ -128,7 +142,7 @@ impl TestWorld {
)
.unwrap();
}
std::fs::write(path.join("config/agent.json"),json!({"api":server.api,"base_url":server.url,"model":"scripted-test","token":"fake-token"}).to_string()).unwrap();
std::fs::write(path.join("config/agent.yaml"), serde_yaml_ng::to_string(&json!({"api":server.api,"base_url":if server.api == APIS[1] {server.url.clone()} else {format!("{}/v1",server.url)},"model":"scripted-test","token":if server.api == APIS[2] {""} else {"fake-token"},"allow_no_token":server.api == APIS[2]})).unwrap()).unwrap();
Self(path)
}
async fn start(&self) -> Host {
@@ -165,7 +179,15 @@ fn input(body: &Value, api: &str) -> Value {
body[if api == APIS[0] { "input" } else { "messages" }].clone()
}
fn outputs(body: &Value, api: &str) -> Vec<Value> {
if api == APIS[0] {
if api == APIS[2] {
body["messages"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["role"] == "tool")
.map(|e| serde_json::from_str(e["content"].as_str().unwrap()).unwrap())
.collect()
} else if api == APIS[0] {
body["input"]
.as_array()
.unwrap()
@@ -185,7 +207,54 @@ fn outputs(body: &Value, api: &str) -> Vec<Value> {
}
}
/// Both API shapes return final text and retain the complete history after a core restart.
/// Invalid configuration names the file and the field before any inference request.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unconfigured_chat_names_the_configuration() {
let server = Llm::new(APIS[0]).await;
let world = TestWorld::new(&server);
let path = world.0.join("config/agent.yaml");
std::fs::remove_file(&path).unwrap();
let host = world.start().await;
let error = host
.call("agent.chat", json!({"text":"hello"}))
.await
.unwrap_err();
assert!(
error["message"]
.as_str()
.unwrap()
.contains("/config/agent.yaml")
);
for (config, field) in [
("", "api"),
("api: unknown", "api"),
("api: openai-responses", "base_url"),
(
"api: openai-responses\nbase_url: http://localhost/v1",
"model",
),
(
"api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: ''",
"token",
),
(
"api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: CHANGE_ME",
"token",
),
] {
std::fs::write(&path, config).unwrap();
let error = host
.call("agent.chat", json!({"text":"hello"}))
.await
.unwrap_err();
let message = error["message"].as_str().unwrap();
assert!(message.contains("/config/agent.yaml"), "{message}");
assert!(message.contains(field), "{message}");
}
host.stop().await;
}
/// All API shapes return final text and retain the complete history after a core restart.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn final_answers_and_history_survive_restart() {
for api in APIS {
@@ -435,14 +504,14 @@ async fn oversized_current_turn_preserves_the_previous_cursor() {
/// Provider-private items are replayed only for their original API and model.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn private_items_are_scoped_to_the_model() {
for api in APIS {
for api in [APIS[0], APIS[1]] {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
let config_path = world.0.join("config/agent.json");
let config_path = world.0.join("config/agent.yaml");
let mut config: Value =
serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap();
serde_yaml_ng::from_slice(&std::fs::read(&config_path).unwrap()).unwrap();
config["thinking"] = json!("high");
std::fs::write(config_path, config.to_string()).unwrap();
std::fs::write(config_path, serde_yaml_ng::to_string(&config).unwrap()).unwrap();
std::fs::create_dir_all(world.0.join("state/agent")).unwrap();
let mut entries = vec![json!({"kind":"user","text":"old turn"})];
for (model, marker) in [
@@ -480,7 +549,7 @@ async fn private_items_are_scoped_to_the_model() {
/// Separate assistant messages retain a separator in the final answer.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn final_texts_have_a_separator() {
for api in APIS {
for api in [APIS[0], APIS[1]] {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
let host = world.start().await;
+42 -11
View File
@@ -59,11 +59,12 @@ fn install_probe(world: &Path) {
async fn init_builds_agent() {
let temporary = tempfile::tempdir().unwrap();
let world = temporary.path().join("world");
// Legacy configuration variables must not affect the templates.
let configured = || {
let mut command = binary();
command
.current_dir(temporary.path())
.env("SAUSAGE_API", "anthropic-messages")
.env("SAUSAGE_API", "not-an-api")
.env("SAUSAGE_BASE_URL", "http://127.0.0.1:1/")
.env("SAUSAGE_MODEL", "init-test")
.env("SAUSAGE_API_TOKEN", "fake-token")
@@ -72,11 +73,13 @@ async fn init_builds_agent() {
};
let failed = configured()
.arg("init")
.env("SAUSAGE_HEADLESS", "not-a-boolean")
.env("RUSTC", "/nonexistent-sausage-rustc")
.output()
.await
.unwrap();
assert!(!failed.status.success());
assert!(String::from_utf8_lossy(&failed.stderr).contains("nonexistent-sausage-rustc"));
assert!(!world.exists());
let output = configured().output().await.unwrap();
assert!(
@@ -88,15 +91,14 @@ async fn init_builds_agent() {
assert!(
String::from_utf8_lossy(&output.stdout).contains("first agent build takes a few minutes")
);
let config: Value =
serde_json::from_slice(&std::fs::read(world.join("config/agent.json")).unwrap()).unwrap();
let mut config: Value =
serde_yaml_ng::from_slice(&std::fs::read(world.join("config/agent.yaml")).unwrap())
.unwrap();
assert_eq!(config["token"], "CHANGE_ME");
assert_eq!(config["api"], "openai-responses");
assert!(String::from_utf8_lossy(&output.stdout).contains("config/agent.yaml"));
assert_eq!(
config,
json!({"api":"anthropic-messages","base_url":"http://127.0.0.1:1","model":"init-test","token":"fake-token"})
);
assert!(String::from_utf8_lossy(&output.stdout).contains("sausage run"));
assert_eq!(
serde_json::from_slice::<Value>(&std::fs::read(world.join("config/boot.json")).unwrap())
serde_yaml_ng::from_slice::<Value>(&std::fs::read(world.join("config/boot.yaml")).unwrap())
.unwrap(),
json!([])
);
@@ -131,6 +133,35 @@ async fn init_builds_agent() {
.iter()
.any(|method| method["name"] == "chat")
);
let error = host
.call("agent.chat", json!({"text":"hello"}))
.await
.unwrap_err();
assert!(
error["message"]
.as_str()
.unwrap()
.contains("/config/agent.yaml")
);
assert!(error["message"].as_str().unwrap().contains("token"));
let mut server = super::agent::Llm::new("anthropic-messages").await;
config["api"] = json!("anthropic-messages");
config["base_url"] = json!(server.url);
config["model"] = json!("scripted-test");
config["token"] = json!("fake-token");
std::fs::write(
world.join("config/agent.yaml"),
serde_yaml_ng::to_string(&config).unwrap(),
)
.unwrap();
let caller = host.clone();
let turn =
tokio::spawn(async move { caller.call("agent.chat", json!({"text":"hello"})).await });
let (_, reply) = server.next().await;
reply
.send((200, server.answer("configured agent", &[])))
.unwrap();
assert_eq!(turn.await.unwrap().unwrap(), "configured agent");
host.stop().await;
}
@@ -178,7 +209,7 @@ async fn signals_work_during_boot() {
] {
let (_folder, world) = world();
std::fs::write(
world.join("config/boot.json"),
world.join("config/boot.yaml"),
json!([
{"method":"core.lock","params":{"path":"boot"}},
{"method":"core.lock","params":{"path":"boot"}}
@@ -251,7 +282,7 @@ async fn console_error_stops_host() {
let (_folder, world) = world();
install_probe(&world);
std::fs::write(
world.join("config/boot.json"),
world.join("config/boot.yaml"),
json!([{"method":"probe.serve","params":{}}]).to_string(),
)
.unwrap();
+6 -6
View File
@@ -667,8 +667,8 @@ async fn reserved_world_roots_prevent_startup() {
async fn boot_waits_for_readiness_in_order() {
let w = TestWorld::new();
std::fs::write(
w.0.join("config/boot.json"),
serde_json::to_vec(&json!([
w.0.join("config/boot.yaml"),
serde_yaml_ng::to_string(&json!([
{"method":"probe.write","params":{"path":"/state/boot","text":"ready"}},
{"method":"probe.read","params":{"path":"/state/boot"}}
]))
@@ -1399,7 +1399,7 @@ async fn failed_boot_services_are_logged_and_boot_continues() {
{"method":"probe.write","params":{"path":"/state/continued","text":"yes"}}
]);
std::fs::write(
w.0.join("config/boot.json"),
w.0.join("config/boot.yaml"),
serde_json::to_vec(&services).unwrap(),
)
.unwrap();
@@ -1423,7 +1423,7 @@ async fn failed_boot_services_are_logged_and_boot_continues() {
async fn boot_services_are_detached() {
let w = TestWorld::new();
std::fs::write(
w.0.join("config/boot.json"),
w.0.join("config/boot.yaml"),
br#"[{"method":"probe.serve","params":{}}]"#,
)
.unwrap();
@@ -1855,7 +1855,7 @@ async fn nested_parent_environment() {
async fn service_parent_environment() {
let w = TestWorld::new();
std::fs::write(
w.0.join("config/boot.json"),
w.0.join("config/boot.yaml"),
br#"[{"method":"probe.forgotten","params":{}}]"#,
)
.unwrap();
@@ -1936,7 +1936,7 @@ async fn detached_processes_need_explicit_termination() {
async fn core_exit_sets_executable_status_75() {
let w = TestWorld::new();
std::fs::write(
w.0.join("config/boot.json"),
w.0.join("config/boot.yaml"),
br#"[{"method":"core.exit","params":{}}]"#,
)
.unwrap();