blob: 996842f94037a314a2304f9abcbdef76cd3170d6 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use maka_agent_engine::{Engine, RunError};
use maka_agent_facts::event::Fact;
use maka_agent_facts::event::TerminalStatus;
use maka_code_runtime::CellLimits;
use maka_event_log::EventLog;
use maka_model::ModelExecutor;
use maka_tools::CodeExecutor;
use serde_json::{Value, json};
use tokio::net::TcpListener;
use tokio::sync::Notify;
use tokio_util::sync::CancellationToken;
use crate::support::agent_loop as fixture;
use fixture::{Effect, input, respond};
use crate::support::http;
use http::read_request;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn loop_commits_before_effects_replays_history_after_reopen_and_rejects_duplicate_admission()
{
tokio::time::timeout(Duration::from_secs(30), async {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}/v1", listener.local_addr().unwrap());
let received = Arc::new(Notify::new());
let release = Arc::new(Notify::new());
let server_received = received.clone();
let server_release = release.clone();
let server = tokio::spawn(async move {
let mut requests = Vec::new();
for index in 0..3 {
let (mut socket, _) = listener.accept().await.unwrap();
requests.push(read_request(&mut socket).await);
if index == 0 {
server_received.notify_one();
server_release.notified().await;
}
respond(&mut socket, index == 0).await;
}
requests
});
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("events.sqlite");
let count = Arc::new(AtomicUsize::new(0));
{
let log = Arc::new(EventLog::open(&path).await.unwrap());
let engine = Engine::new(
log.clone(),
ModelExecutor::new(Duration::from_secs(10)).unwrap(),
CodeExecutor::new(CellLimits::default()).unwrap(),
);
let effect = Arc::new(Effect {
log: log.clone(),
count: count.clone(),
});
let mut first = input(&base, "first", effect.clone());
let maka_agent_engine::RunWork::Message { message, .. } = &mut first.work else {
unreachable!()
};
message.display_text = Some("Display-only first question".into());
for invalid_cwd in ["relative", "/invalid\0path"] {
let mut invalid = input(&base, "invalid", effect.clone());
invalid.configuration.cwd = invalid_cwd.into();
assert!(matches!(
engine.start(invalid, CancellationToken::new()).await,
Err(RunError::InvalidInput(_))
));
}
let mut invalid = input(&base, "invalid-model", effect.clone());
invalid.configuration.model = Some(maka_agent_facts::execution::ModelBinding {
connection_id: "connection".into(),
connection_slug: "connection".into(),
model: "not-the-dispatched-model".into(),
});
assert!(matches!(
engine.start(invalid, CancellationToken::new()).await,
Err(RunError::InvalidInput(_))
));
assert!(
log.prefix(100, 128 * 1024).await.unwrap().events.is_empty(),
"invalid context cannot publish an opening or dispatch"
);
assert_eq!(count.load(Ordering::SeqCst), 0);
fixture::admit_root(&log, &mut first).await;
let admitted = engine.start(first, CancellationToken::new()).await.unwrap();
let steering_invocation = admitted.invocation().clone();
assert_eq!(admitted.invocation().invocation_id, "invocation-first");
assert!(
log.prefix(100, 128 * 1024)
.await
.unwrap()
.events
.iter()
.any(
|event| event.event.invocation.invocation_id == "invocation-first"
&& matches!(event.event.fact, Fact::InvocationOpened { .. })
)
);
let mut running = tokio::spawn(admitted.wait());
tokio::select! {
_ = received.notified() => {},
result = &mut running => panic!("engine ended before HTTP request: {result:?}"),
}
assert!(matches!(
engine
.run(
input(&base, "duplicate", effect.clone()),
CancellationToken::new()
)
.await,
Err(RunError::Busy)
));
fixture::enqueue(&log, steering_invocation).await;
release.notify_one();
running.await.unwrap().unwrap();
let prefix = log.prefix(100, 128 * 1024).await.unwrap();
assert!(log.pending_messages("session").await.unwrap().is_empty());
assert!(
log.root_message("session", "user-first")
.await
.unwrap()
.is_some()
);
assert!(
log.steering_message("session", "steer-first")
.await
.unwrap()
.is_some()
);
assert!(
matches!(&prefix.events[0].event.fact, Fact::InvocationOpened {
input: maka_agent_facts::input::InvocationInput::Message { content, .. },
..
} if content.text == "question first"
&& content.display_text.as_deref() == Some("Display-only first question"))
);
assert_eq!(
prefix.project_invocation("invocation-first").terminal,
Some(TerminalStatus::Completed)
);
assert_eq!(count.load(Ordering::SeqCst), 1);
assert!(
!serde_json::to_string(&prefix)
.unwrap()
.contains("do-not-persist-this-secret")
);
engine.drain().await;
drop(engine);
drop(effect);
Arc::try_unwrap(log).ok().unwrap().close().await.unwrap();
}
{
let log = Arc::new(EventLog::open(&path).await.unwrap());
let engine = Engine::new(
log.clone(),
ModelExecutor::new(Duration::from_secs(10)).unwrap(),
CodeExecutor::new(CellLimits::default()).unwrap(),
);
let effect = Arc::new(Effect {
log: log.clone(),
count: count.clone(),
});
engine
.run(input(&base, "next", effect), CancellationToken::new())
.await
.unwrap();
assert_eq!(
count.load(Ordering::SeqCst),
1,
"committed tools are projected, never rerun"
);
assert_eq!(
log.prefix(100, 128 * 1024)
.await
.unwrap()
.project_invocation("invocation-next")
.terminal,
Some(TerminalStatus::Completed)
);
}
let requests = server.await.unwrap();
// This test owns conversation replay; system instructions have their own contract.
let conversation = |request: &serde_json::Value| {
request["messages"]
.as_array()
.unwrap()
.iter()
.filter(|message| message["role"] != "system")
.cloned()
.collect::<Vec<_>>()
};
assert_eq!(
conversation(&requests[0]),
vec![json!({"role":"user","content":"question first"})]
);
let second = conversation(&requests[1]);
assert!(second.iter().any(|m| m["role"] == "user"
&& m["content"].as_str().is_some_and(|text| {
text.contains("<user_query>\nA queued correction\n</user_query>")
})));
let result = second
.iter()
.find(|message| message["role"] == "tool")
.unwrap();
assert_eq!(result["tool_call_id"], "call-1");
assert_eq!(
serde_json::from_str::<Value>(result["content"].as_str().unwrap()).unwrap(),
json!({"value":42})
);
let after_reopen = conversation(&requests[2]);
assert_eq!(&after_reopen[..second.len()], second.as_slice());
assert_eq!(
after_reopen.last().unwrap(),
&json!({"role":"user","content":"question next"})
);
})
.await
.expect("agent loop must make bounded progress");
}