blob: 14fededa82e836a24045b3377756bb62752f1e7b [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 crate::support::code_mode as fixture;
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use maka_agent_engine::Engine;
use maka_agent_facts::event::Fact;
use maka_agent_facts::event::TerminalStatus;
use maka_agent_facts::event::ToolOutcome;
use maka_agent_facts::tool_call::ToolOrigin;
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::Barrier;
use tokio_util::sync::CancellationToken;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn scratch_store_survives_turns_and_is_released_with_the_session() {
tokio::time::timeout(Duration::from_secs(15), async {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}/v1", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
for (index, code) in [
"store('saved', 42);",
"text(load('saved'));",
"text(load('saved'));",
]
.into_iter()
.enumerate()
{
let (mut socket, _) = listener.accept().await.unwrap();
fixture::read_request(&mut socket).await;
crate::dynamic_tools::respond_tools(
&mut socket,
vec![crate::dynamic_tools::call(
"cell",
"exec",
json!({"code":code}),
)],
10,
)
.await;
let (mut socket, _) = listener.accept().await.unwrap();
let request = fixture::read_request(&mut socket).await;
let text = request["messages"].as_array().unwrap().last().unwrap()["content"]
.as_str()
.unwrap();
if index > 0 {
assert_eq!(
text.lines().last().unwrap(),
if index == 1 { "42" } else { "undefined" }
);
}
fixture::respond(&mut socket, false).await;
}
});
let directory = tempfile::tempdir().unwrap();
let log = Arc::new(
EventLog::open(&directory.path().join("events.sqlite"))
.await
.unwrap(),
);
let engine = Engine::new(
log.clone(),
ModelExecutor::new(Duration::from_secs(5)).unwrap(),
CodeExecutor::new(CellLimits::default()).unwrap(),
);
for index in 0..3 {
if index == 2 {
engine.release_session_state("session");
}
engine
.run(
fixture::input(
&base,
&format!("store-{index}"),
Arc::new(fixture::Effects {
log: log.clone(),
count: Arc::new(AtomicUsize::new(0)),
together: Arc::new(Barrier::new(2)),
}),
),
CancellationToken::new(),
)
.await
.unwrap();
}
server.await.unwrap();
engine.drain().await;
drop(engine);
Arc::try_unwrap(log).ok().unwrap().close().await.unwrap();
})
.await
.expect("session store lifecycle must complete");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn streamed_exec_journals_parallel_children_and_reopens_without_reexecution() {
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 server = tokio::spawn(async move {
let mut requests = Vec::new();
for index in 0..3 {
let (mut socket, _) = listener.accept().await.unwrap();
requests.push(fixture::read_request(&mut socket).await);
fixture::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 envelope = json!({
"ok":true,"value":null,
"toolCalls":[{"index":1,"name":"left"},{"index":2,"name":"right"}]
});
let before_reopen;
{
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 input = fixture::input(
&base,
"first",
Arc::new(fixture::Effects {
log: log.clone(),
count: count.clone(),
together: Arc::new(Barrier::new(2)),
}),
);
let invocation = input.invocation.clone();
assert_eq!(
engine.run(input, CancellationToken::new()).await.unwrap(),
invocation
);
engine.drain().await;
let prefix = log.prefix(100, 128 * 1024).await.unwrap();
assert_eq!(
prefix.project_invocation("invocation-first").terminal,
Some(TerminalStatus::Completed)
);
assert_eq!(count.load(Ordering::SeqCst), 2);
assert!(
prefix
.events
.iter()
.all(|event| event.event.invocation == invocation)
);
let dispatches: Vec<_> = prefix
.events
.iter()
.enumerate()
.filter_map(|(index, event)| {
if let Fact::ToolDispatched {
operation_id,
call,
name,
input,
..
} = &event.event.fact
{
Some((index, operation_id, call, name, input))
} else {
None
}
})
.collect();
assert_eq!(dispatches.len(), 4);
let (parent_t1, parent_operation, parent_call, parent_name, parent_input) =
dispatches[0];
assert_eq!(parent_name, "exec");
assert_eq!(parent_input, &json!({"code":fixture::CODE}));
assert_eq!(parent_call.tool_call_id, "exec-provider");
let ToolOrigin::Provider { step_id } = &parent_call.origin else {
panic!("parent must retain provider identity");
};
assert_eq!(parent_operation, &format!("{step_id}:exec-provider"));
let settlements: Vec<_> = prefix
.events
.iter()
.enumerate()
.filter_map(|(index, event)| {
if let Fact::ToolSettled {
operation_id,
outcome,
} = &event.event.fact
{
Some((index, operation_id, outcome))
} else {
None
}
})
.collect();
assert_eq!(settlements.len(), 4);
let (parent_t2, _, parent_outcome) = settlements
.iter()
.copied()
.find(|(_, operation, _)| *operation == parent_operation)
.unwrap();
assert!(matches!(parent_outcome, ToolOutcome::Succeeded { .. }));
assert_eq!(
log.resolve_tool_result("session", &prefix.events[parent_t2].event.id)
.await
.unwrap()
.into_json()["result"],
envelope
);
let mut ids = HashSet::new();
let (cell_t1, cell_operation, cell_call, _, _) = dispatches[1];
assert_eq!(
cell_call.origin,
ToolOrigin::CodeCell {
parent_operation_id: parent_operation.clone(),
parent_tool_call_id: parent_call.tool_call_id.clone(),
}
);
let cell_t2 = settlements
.iter()
.find(|(_, operation, _)| *operation == cell_operation)
.unwrap()
.0;
for (t1, operation, call, name, input) in &dispatches {
assert!(ids.insert(operation.as_str()));
assert!(
ids.insert(call.tool_call_id.as_str()),
"operation IDs and call IDs are distinct"
);
if matches!(name.as_str(), "exec" | "code_cell") {
continue;
}
assert!(matches!(name.as_str(), "left" | "right"));
assert_eq!(
call.origin,
ToolOrigin::CodeMode {
parent_operation_id: cell_operation.clone(),
parent_tool_call_id: cell_call.tool_call_id.clone(),
}
);
let (t2, _, outcome) = settlements
.iter()
.copied()
.find(|(_, settled, _)| settled == operation)
.unwrap();
assert!(
parent_t1 < cell_t1
&& cell_t1 < *t1
&& *t1 < t2
&& t2 < cell_t2
&& cell_t2 < parent_t2
);
assert!(matches!(outcome, ToolOutcome::Succeeded { .. }));
assert_eq!(
log.resolve_tool_result("session", &prefix.events[t2].event.id)
.await
.unwrap()
.into_json(),
**input
);
}
// The concurrency barrier guarantees both children enter their effects
// before either returns; their committed T1 facts must precede either T2.
assert!(
dispatches[1..]
.iter()
.all(|(t1, ..)| *t1 < settlements[0].0)
);
let next_request = prefix
.events
.iter()
.enumerate()
.filter_map(|(index, event)| {
matches!(event.event.fact, Fact::ModelRequested { .. }).then_some(index)
})
.nth(1)
.unwrap();
assert!(
parent_t2 < next_request,
"no model observes a result before T2"
);
before_reopen = prefix.events;
drop(engine);
Arc::try_unwrap(log).ok().unwrap().close().await.unwrap();
}
{
let log = Arc::new(EventLog::open(&path).await.unwrap());
assert_eq!(
serde_json::to_value(log.prefix(100, 128 * 1024).await.unwrap().events).unwrap(),
serde_json::to_value(&before_reopen).unwrap()
);
let engine = Engine::new(
log.clone(),
ModelExecutor::new(Duration::from_secs(10)).unwrap(),
CodeExecutor::new(CellLimits::default()).unwrap(),
);
engine
.run(
fixture::input(
&base,
"next",
Arc::new(fixture::Effects {
log: log.clone(),
count: count.clone(),
together: Arc::new(Barrier::new(2)),
}),
),
CancellationToken::new(),
)
.await
.unwrap();
engine.drain().await;
assert_eq!(
count.load(Ordering::SeqCst),
2,
"recovery must not repeat effects"
);
let prefix = log.prefix(200, 256 * 1024).await.unwrap();
assert_eq!(
serde_json::to_value(&prefix.events[..before_reopen.len()]).unwrap(),
serde_json::to_value(&before_reopen).unwrap()
);
assert_eq!(
prefix.project_invocation("invocation-next").terminal,
Some(TerminalStatus::Completed)
);
}
let requests = server.await.unwrap();
for request in &requests {
let functions: Vec<_> = request["tools"]
.as_array()
.unwrap()
.iter()
.map(|tool| &tool["function"])
.collect();
let names: HashSet<_> = functions
.iter()
.map(|tool| tool["name"].as_str().unwrap())
.collect();
assert_eq!(names, HashSet::from(["exec", "wait"]));
let description = functions[0]["description"].as_str().unwrap();
assert!(description.contains("left(input:"));
assert!(description.contains("right(input:"));
let schema = &functions[0]["parameters"];
assert_eq!(schema["properties"]["code"]["type"], "string");
assert!(schema["properties"].get("yield_time_ms").is_some());
assert_eq!(schema["required"], json!(["code"]));
assert_eq!(schema["additionalProperties"], false);
}
// 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_eq!(second.len(), 3, "nested tool messages must remain hidden");
assert_eq!(second[1]["role"], "assistant");
let calls = second[1]["tool_calls"].as_array().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0]["id"], "exec-provider");
assert_eq!(calls[0]["function"]["name"], "exec");
assert_eq!(
serde_json::from_str::<Value>(calls[0]["function"]["arguments"].as_str().unwrap())
.unwrap(),
json!({"code":fixture::CODE})
);
assert_eq!(second[2]["role"], "tool");
assert_eq!(second[2]["tool_call_id"], "exec-provider");
assert_eq!(
serde_json::from_str::<Value>(
second[2]["content"]
.as_str()
.unwrap()
.lines()
.next()
.unwrap()
)
.unwrap()["result"],
envelope
);
let reopened = conversation(&requests[2]);
assert_eq!(reopened.len(), second.len() + 2);
assert_eq!(
&reopened[..second.len()],
second.as_slice(),
"past conversation input must be identical after reopening"
);
assert_eq!(
reopened[second.len()],
json!({"role":"assistant","content":"done"})
);
assert_eq!(
reopened.last().unwrap(),
&json!({"role":"user","content":"question next"})
);
})
.await
.expect("streamed Code Mode must finish; serial nested execution deadlocks the fixture");
}