blob: 66e53638bc0e750051182f4ed9a60b3e05ad5b0d [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 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::Invocation;
use maka_agent_facts::event::InvocationInput;
use maka_agent_facts::event::InvocationOutcome;
use maka_agent_facts::event::RuntimeEvent;
use maka_agent_facts::event::ToolOutcome;
use maka_agent_facts::tool_call::ToolCallIdentity;
use maka_agent_facts::tool_output::DurableToolProjection;
use maka_agent_facts::tool_output::ToolOutput;
use maka_agent_facts::tool_output::ToolSuccess;
use maka_event_log::{EventLog, artifacts::ArtifactDeletion};
use serde_json::json;
use std::time::{Duration, UNIX_EPOCH};
fn invocation() -> Invocation {
Invocation {
session_id: "session".into(),
turn_id: "turn".into(),
run_id: "run".into(),
invocation_id: "invocation".into(),
}
}
fn plain(fact: Fact) -> EventWrite {
EventWrite::plain(RuntimeEvent::new(invocation(), fact)).unwrap()
}
fn dispatch(operation: &str) -> EventWrite {
plain(Fact::ToolDispatched {
presentation: None,
title: None,
operation_id: operation.into(),
call: ToolCallIdentity::standalone(format!("{operation}-call")),
name: "Read".into(),
input: json!({}),
})
}
fn success(operation: &str, output: &ToolOutput) -> EventWrite {
EventWrite::tool_success(
format!("{operation}-result"),
UNIX_EPOCH + Duration::from_secs(1),
invocation(),
operation.into(),
output.clone().into(),
)
.unwrap()
.0
}
async fn open(path: &std::path::Path) -> EventLog {
let log = EventLog::open(path).await.unwrap();
log.create_session("session", "create", &json!({}), 1)
.await
.unwrap();
log.append(&plain(Fact::InvocationOpened {
configuration: None,
input: InvocationInput::Code {
source: "fixture".into(),
},
}))
.await
.unwrap();
log
}
#[tokio::test]
async fn large_raw_and_model_projection_reopen_exactly() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("events.sqlite");
let log = open(&path).await;
let output = ToolOutput::Text("🦊\u{0001}".repeat(1024 * 1024 / 5));
let operation = "large";
let outcome = success(operation, &output);
let Fact::ToolSettled {
outcome: ToolOutcome::Succeeded {
model_projection, ..
},
..
} = &outcome.event().fact
else {
panic!()
};
let ToolOutput::Text(text) = &output else {
panic!()
};
assert_eq!(
model_projection,
&DurableToolProjection::Json {
value: json!({"kind":"text", "text":text})
}
);
log.append_batch(&[dispatch(operation), outcome.clone()])
.await
.unwrap();
let prefix = log.prefix(100, 4 * 1024 * 1024).await.unwrap();
log.close().await.unwrap();
let log = EventLog::open(&path).await.unwrap();
assert_eq!(
log.resolve_tool_result("session", &outcome.event().id)
.await
.unwrap(),
output
);
assert_eq!(
log.prefix(100, 4 * 1024 * 1024).await.unwrap().digest,
prefix.digest
);
let commits = log.subscribe_commits();
log.append(&outcome).await.unwrap();
assert!(!commits.has_changed().unwrap());
log.close().await.unwrap();
}
#[tokio::test]
async fn raw_media_and_t2_rollback_together_then_replay_after_sealing() {
for workspace_snapshot in [false, true] {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("events.sqlite");
let log = open(&path).await;
log.append(&dispatch("image")).await.unwrap();
let output = ToolOutput::Mcp(CallResult {
content: vec![ContentBlock::Image {
data: "iVBORw0KGgo=".into(),
mime_type: "image/png".into(),
}],
structured_content: None,
});
let pending = if workspace_snapshot {
ToolSuccess::image(b"\x89PNG\r\n\x1a\n".to_vec(), "image/png".into()).unwrap()
} else {
output.into()
};
let (outcome, output) = EventWrite::tool_success(
"image-result".into(),
UNIX_EPOCH + Duration::from_secs(1),
invocation(),
"image".into(),
pending,
)
.unwrap();
if workspace_snapshot {
assert!(matches!(output, ToolOutput::Image(_)));
}
let media_id = &outcome.projection_artifacts()[0].artifact().id;
let invalid = plain(Fact::ToolSettled {
operation_id: "undispatched".into(),
outcome: ToolOutcome::Failed {
message: "rollback preceding successful result".into(),
},
});
let commits = log.subscribe_commits();
assert!(log.append_batch(&[outcome.clone(), invalid]).await.is_err());
assert!(!commits.has_changed().unwrap());
let inspect = rusqlite::Connection::open(&path).unwrap();
for table in ["tool_result_payloads", "artifacts"] {
assert_eq!(
inspect
.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r
.get::<_, i64>(0))
.unwrap(),
0
);
}
assert_eq!(log.prefix(10, 8192).await.unwrap().events.len(), 2);
let sequence = log.append(&outcome).await.unwrap();
assert_eq!(
log.delete_user_artifact("session", media_id).await.unwrap(),
ArtifactDeletion::Protected
);
log.append(&plain(Fact::InvocationEnded {
outcome: InvocationOutcome::Completed,
}))
.await
.unwrap();
log.close().await.unwrap();
let log = EventLog::open(&path).await.unwrap();
let commits = log.subscribe_commits();
assert_eq!(log.append(&outcome).await.unwrap(), sequence);
assert!(!commits.has_changed().unwrap());
assert_eq!(
log.resolve_tool_result("session", &outcome.event().id)
.await
.unwrap(),
output
);
assert_eq!(
log.read_artifact_chunk("session", media_id, 0, 32)
.await
.unwrap()
.unwrap()
.bytes,
b"\x89PNG\r\n\x1a\n"
);
assert!(
log.resolve_tool_result("foreign", &outcome.event().id)
.await
.is_err()
);
let changed = EventWrite::tool_success(
outcome.event().id.clone(),
outcome.event().recorded_at,
invocation(),
"image".into(),
ToolSuccess::image(b"changed!".to_vec(), "image/png".into()).unwrap(),
)
.unwrap()
.0;
assert!(log.append(&changed).await.is_err());
let mut foreign = invocation();
foreign.session_id = "foreign".into();
let conflict = EventWrite::tool_success(
outcome.event().id.clone(),
outcome.event().recorded_at,
foreign,
"image".into(),
output.clone().into(),
)
.unwrap()
.0;
assert!(log.append(&conflict).await.is_err());
assert!(log.append(&success("new", &output)).await.is_err());
// Missing media cannot be repaired by declaring an exact event replay successful.
inspect
.execute("DELETE FROM artifacts WHERE id = ?", [media_id])
.unwrap();
assert!(log.append(&outcome).await.is_err());
log.close().await.unwrap();
}
}
#[tokio::test]
async fn session_binding_missing_payload_and_corrupt_raw_fail_closed() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("events.sqlite");
let log = open(&path).await;
let outcome = success("raw", &json!({"answer":42}).into());
log.append_batch(&[dispatch("raw"), outcome.clone()])
.await
.unwrap();
assert!(
log.resolve_tool_result("other", &outcome.event().id)
.await
.is_err()
);
let source = log
.run_source("session", "run", None)
.await
.unwrap()
.unwrap();
assert_eq!(source.boundary.high_water, 3);
assert_eq!(source.last.event, *outcome.event());
let inspect = rusqlite::Connection::open(&path).unwrap();
let original = outcome.raw_payload().unwrap();
let mut corrupt = original.to_vec();
let index = corrupt.iter().position(|b| *b == b'4').unwrap();
corrupt[index] = b'5';
inspect
.execute(
"UPDATE tool_result_payloads SET payload = ? WHERE event_id = ?",
rusqlite::params![corrupt, outcome.event().id],
)
.unwrap();
// Prefix checks physical binding; selected hydration verifies content hashes.
assert!(log.prefix(10, 8192).await.is_ok());
// Source evidence also checks physical binding without hydrating raw payloads.
assert_eq!(
log.run_source("session", "run", None)
.await
.unwrap()
.unwrap()
.boundary,
source.boundary
);
assert!(
log.resolve_tool_result("session", &outcome.event().id)
.await
.is_err()
);
assert!(log.append(&outcome).await.is_err());
inspect
.execute(
"UPDATE tool_result_payloads SET payload = x'00' WHERE event_id = ?",
[&outcome.event().id],
)
.unwrap();
assert!(log.prefix(10, 8192).await.is_err());
assert!(log.run_source("session", "run", None).await.is_err());
assert!(
log.resolve_tool_result("session", &outcome.event().id)
.await
.is_err()
);
inspect
.execute(
"DELETE FROM tool_result_payloads WHERE event_id = ?",
[&outcome.event().id],
)
.unwrap();
assert!(log.prefix(10, 8192).await.is_err());
assert!(log.run_source("session", "run", None).await.is_err());
assert!(
log.invocation_recovery(&invocation(), 10, 8192)
.await
.is_err()
);
// Damaged payload authority stays local. Another Session can append and
// inspect its own recovery scope without hydrating the broken history.
let other = Invocation {
session_id: "other".into(),
turn_id: "other".into(),
run_id: "other".into(),
invocation_id: "other".into(),
};
log.append(
&EventWrite::plain(RuntimeEvent::new(
other.clone(),
Fact::InvocationOpened {
configuration: None,
input: maka_agent_facts::input::InvocationInput::Code {
source: "independent".into(),
},
},
))
.unwrap(),
)
.await
.unwrap();
assert!(
log.invocation_recovery(&other, 1, 1024)
.await
.unwrap()
.uncertain_operations
.is_empty()
);
assert!(
log.resolve_tool_result("session", &outcome.event().id)
.await
.is_err()
);
assert!(log.append(&outcome).await.is_err());
log.close().await.unwrap();
}
#[tokio::test]
async fn unavailable_borrowed_media_preserves_the_completed_effect_and_raw_result() {
use maka_agent_facts::attachment::StorageRef;
use maka_agent_facts::tool_output::ImageOutput;
use maka_agent_facts::tools::ToolJournal;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use tokio_util::sync::CancellationToken;
for media_session in ["session", "foreign"] {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("events.sqlite");
let log = Arc::new(open(&path).await);
let output = ToolOutput::Image(ImageOutput {
detail: None,
mime_type: "image/gif".into(),
reference: StorageRef::SessionFile {
session_id: media_session.into(),
relative_path: "absent-image".into(),
},
});
let effects = Arc::new(AtomicUsize::new(0));
let effect_count = effects.clone();
let effect_output = output.clone();
let actual = ToolJournal::new(log.clone(), invocation())
.invoke_call_with(
"missing-image".into(),
ToolCallIdentity::standalone("image-call".into()),
"Read".into(),
json!({}),
CancellationToken::new(),
move |_| {
Box::pin(async move {
effect_count.fetch_add(1, Ordering::SeqCst);
Ok(effect_output)
})
},
)
.await
.unwrap();
assert_eq!(actual, output.clone().into_json());
assert_eq!(effects.load(Ordering::SeqCst), 1);
let through = *log.subscribe_commits().borrow();
let page = log
.session_events("session", 0, through, 32, 64 * 1024)
.await
.unwrap();
let settlement = page
.events
.iter()
.find(|stored| {
matches!(
stored.event.fact,
Fact::ToolSettled {
outcome: ToolOutcome::Succeeded { .. },
..
}
)
})
.expect("successful effect must have a durable successful settlement");
let event_id = settlement.event.id.clone();
assert_eq!(
log.resolve_tool_result("session", &event_id).await.unwrap(),
output
);
assert_eq!(
log.delete_user_artifact("session", "absent-image")
.await
.unwrap(),
ArtifactDeletion::NotFound
);
let log = Arc::try_unwrap(log).ok().unwrap();
log.close().await.unwrap();
let reopened = EventLog::open(&path).await.unwrap();
assert_eq!(
reopened
.resolve_tool_result("session", &event_id)
.await
.unwrap(),
output
);
reopened.close().await.unwrap();
}
}