blob: 62096d80f7b4b7b2602ab90066c21abb05b9851b [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::image_projection as support;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use maka_agent_engine::project_model_history;
use maka_agent_facts::capability::CallResult;
use maka_agent_facts::capability::ContentBlock;
use maka_agent_facts::event::EventWrite;
use maka_agent_facts::event::Fact;
use maka_agent_facts::event::InvocationOutcome;
use maka_agent_facts::event::RuntimeEvent;
use maka_agent_facts::input::InvocationInput;
use maka_agent_facts::tool_call::ToolCallIdentity;
use maka_agent_facts::tool_output::ToolOutput;
use maka_event_log::EventLog;
use serde_json::json;
use std::{sync::Arc, time::SystemTime};
use support::*;
use tokio::net::TcpListener;
use tokio_util::sync::CancellationToken;
async fn settled(log: &EventLog, output: &ToolOutput) {
let prior = identity("prior");
for fact in [
Fact::InvocationOpened {
configuration: None,
input: InvocationInput::Message { source_messages: Vec::new(), content: "prior".into(), request_fingerprint: None },
},
Fact::ModelRequested { item_acceptance: false, effective_source_digest: None, purpose: maka_agent_facts::context::ModelPurpose::Main, context: None, context_breakdown: None, checkpoint_event_id: None, step_id: "step".into(), model_id: "test".into(),
source_scope: maka_agent_facts::event::LogScope::Session { id: "session".into() },
source_high_water: 1, source_digest: "fixture".into(), input_digest: "fixture".into(),
route_identity: "fixture".into() },
Fact::ModelCompleted { step_id: "step".into(), output: serde_json::from_value(json!({
"parts":[{"kind":"tool_call","call":{"id":"call","name":"Read","input":{"kind":"json","data":{}},"provider_executed":false}}],
"finish_reason":"tool-calls","usage":{}
})).unwrap() },
Fact::ToolDispatched { presentation: None,
title: None, operation_id: "step:call".into(),
call: ToolCallIdentity::provider("step".into(), "call".into()), name: "Read".into(), input: json!({}) },
] {
log.append(&EventWrite::plain(RuntimeEvent::new(prior.clone(), fact)).unwrap()).await.unwrap();
}
log.append(
&EventWrite::tool_success(
"result".into(),
SystemTime::now(),
prior.clone(),
"step:call".into(),
output.clone().into(),
)
.unwrap()
.0,
)
.await
.unwrap();
log.append(
&EventWrite::plain(RuntimeEvent::new(
prior,
Fact::InvocationEnded {
outcome: InvocationOutcome::Completed,
},
))
.unwrap(),
)
.await
.unwrap();
}
#[tokio::test]
async fn frozen_string_and_attachment_text_keep_distinct_sdk_shapes() {
for (raw, expected) in [
(
ToolOutput::Json(json!("plain")),
json!({"type":"text","value":"plain"}),
),
(
ToolOutput::Text("attachment".into()),
json!({"type":"json","value":{"kind":"text","text":"attachment"}}),
),
] {
let directory = tempfile::tempdir().unwrap();
let log = EventLog::open(&directory.path().join("events.sqlite"))
.await
.unwrap();
settled(&log, &raw).await;
let prefix = log.prefix(100, 128 * 1024).await.unwrap();
assert_eq!(
serde_json::to_value(
project_model_history(&prefix, "session", maka_agent_facts::language::Language::En)
.unwrap()
)
.unwrap()[2]["content"][0]["output"],
expected
);
}
}
#[tokio::test]
async fn reopened_history_uses_frozen_projection_without_raw_store_access() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("events.sqlite");
let log = EventLog::open(&path).await.unwrap();
let raw = ToolOutput::Text("raw-only-marker".repeat(800_000));
settled(&log, &raw).await;
let original = log.prefix(100, 16 * 1024 * 1024).await.unwrap();
let expected = project_model_history(
&original,
"session",
maka_agent_facts::language::Language::En,
)
.unwrap();
assert_eq!(
serde_json::to_value(&expected).unwrap()[2]["content"][0]["output"],
json!({"type":"json","value":{"kind":"text","text":"raw-only-marker".repeat(800_000)}})
);
log.close().await.unwrap();
let log = EventLog::open(&path).await.unwrap();
let reopened = log.prefix(100, 16 * 1024 * 1024).await.unwrap();
assert_eq!(
serde_json::to_value(&reopened).unwrap(),
serde_json::to_value(&original).unwrap()
);
assert_eq!(
log.resolve_tool_result("session", "result").await.unwrap(),
raw
);
log.close().await.unwrap();
// A detached committed prefix is sufficient: there is no live store from
// which a current projector could accidentally fetch or reinterpret raw.
assert_eq!(
project_model_history(
&reopened,
"session",
maka_agent_facts::language::Language::En
)
.unwrap(),
expected
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reopened_mcp_images_materialize_multiple_ordered_parts_from_protected_artifacts() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("events.sqlite");
let log = EventLog::open(&path).await.unwrap();
log.create_session("session", "create", &json!({}), 1)
.await
.unwrap();
artifact(&log, "user", 8, true).await;
let bytes = [
b"\x89PNG\r\n\x1a\nfirst".to_vec(),
b"\x89PNG\r\n\x1a\nsecond".to_vec(),
];
let image = |index: usize| ContentBlock::Image {
data: STANDARD.encode(&bytes[index]),
mime_type: "image/png".into(),
};
settled(
&log,
&ToolOutput::Mcp(CallResult {
content: vec![
ContentBlock::Text {
text: "before".into(),
},
image(0),
ContentBlock::Text {
text: "between".into(),
},
image(1),
ContentBlock::Text {
text: "after".into(),
},
],
structured_content: None,
}),
)
.await;
log.close().await.unwrap();
let log = Arc::new(EventLog::open(&path).await.unwrap());
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 socket, _) = listener.accept().await.unwrap();
let body = request(&mut socket).await;
respond(&mut socket).await;
body
});
let engine = engine(log.clone());
engine
.run(
input(&base, json!([attachment("user")])),
CancellationToken::new(),
)
.await
.unwrap();
let body = server.await.unwrap();
let messages = body["messages"].as_array().unwrap();
let relocated = messages
.iter()
.find(|message| {
message["content"]
.as_array()
.is_some_and(|parts| parts.iter().any(|part| part["type"] == "image_url"))
})
.unwrap();
let urls: Vec<_> = relocated["content"]
.as_array()
.unwrap()
.iter()
.filter_map(|part| part["image_url"]["url"].as_str())
.collect();
assert_eq!(
urls,
bytes
.iter()
.map(|bytes| format!("data:image/png;base64,{}", STANDARD.encode(bytes)))
.collect::<Vec<_>>()
);
let tool = messages
.iter()
.find(|message| message["role"] == "tool")
.unwrap();
let text = tool["content"].as_str().unwrap();
assert!(text.find("before").unwrap() < text.find("between").unwrap());
assert!(text.find("between").unwrap() < text.find("after").unwrap());
engine.drain().await;
}