blob: c17c5ffbbad1b77734279f63b1695c48cf03176f [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::{
diagnostics::{DiagnosticDetail, ExecutionFailureReason, TitleFailureCause},
event::{
EventWrite, Fact, Invocation, InvocationOutcome, LogScope, ModelInterruption, RuntimeEvent,
},
execution::*,
input::InvocationInput,
model::error::ModelFailureCause,
session::{CopyPurpose, CopyRequest},
};
use maka_event_log::{EventLog, StoreError, sessions::SessionCopyResult};
use serde_json::{Value, json};
async fn append(log: &EventLog, invocation: &Invocation, fact: Fact) -> u64 {
log.append(&EventWrite::plain(RuntimeEvent::new(invocation.clone(), fact)).unwrap())
.await
.unwrap()
}
async fn opening(log: &EventLog, session: &str, id: &str) -> Invocation {
let invocation = Invocation {
session_id: session.into(),
turn_id: id.into(),
run_id: id.into(),
invocation_id: id.into(),
};
append(
log,
&invocation,
Fact::InvocationOpened {
configuration: Some(Box::new(InvocationConfiguration {
language: maka_agent_facts::language::Language::En,
compaction_prompt: None,
workspace_origin: WorkspaceOrigin::Selected,
approval_policy: ApprovalPolicy::OnRequest,
boundary_revision: 0,
cwd: "/tmp".into(),
workspace_identity: None,
model: Some(ModelBinding {
connection_id: "connection".into(),
connection_slug: "original".into(),
model: "requested".into(),
}),
editing_tools: None,
tool_discovery: false,
tool_composition: None,
system_prompt: None,
thinking_level: None,
sandbox_mode: SandboxMode::ReadOnly,
collaboration_mode: CollaborationMode::Agent,
orchestration_mode: BehaviorId::default(),
tool_mode: ToolMode::Direct,
code_language: Default::default(),
})),
input: InvocationInput::Message {
source_messages: vec![],
content: "original question".into(),
request_fingerprint: None,
},
},
)
.await;
invocation
}
#[tokio::test]
async fn diagnostic_pages_preserve_sources_fences_and_operational_copy_scope() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("diagnostics.sqlite");
let log = EventLog::open(&path).await.unwrap();
log.create_session("session", "create", &json!({}), 1)
.await
.unwrap();
log.create_session("other", "other", &json!({}), 1)
.await
.unwrap();
let invocation = opening(&log, "session", "first").await;
append(
&log,
&invocation,
Fact::ModelRequested {
context_breakdown: None,
item_acceptance: false,
step_id: "step".into(),
model_id: "requested".into(),
source_scope: LogScope::Root,
source_high_water: 0,
source_digest: "fixture".into(),
input_digest: "fixture".into(),
route_identity: "route".into(),
checkpoint_event_id: None,
purpose: maka_agent_facts::context::ModelPurpose::Main,
context: None,
effective_source_digest: None,
},
)
.await;
let step = append(
&log,
&invocation,
Fact::ModelInterrupted {
step_id: "step".into(),
status: ModelInterruption::Failed,
diagnostic: Some("provider-secret-must-not-be-exported".into()),
model_failure: Some(ModelFailureCause::Http { status: 400 }),
},
)
.await;
let terminal = append(
&log,
&invocation,
Fact::InvocationEnded {
outcome: InvocationOutcome::Failed {
class: "model_http".into(),
message: Some("provider-secret-must-not-be-exported".into()),
model_failure: Some(ModelFailureCause::Http { status: 400 }),
},
},
)
.await;
let old_page = log
.diagnostics_page("session", Some(terminal), None, 32, 48 * 1024)
.await
.unwrap();
assert_eq!(
old_page
.entries
.iter()
.map(|entry| entry.sequence)
.collect::<Vec<_>>(),
vec![terminal, step]
);
assert_eq!(
old_page.entries[0].detail,
DiagnosticDetail::Execution {
invocation: invocation.clone(),
reason: ExecutionFailureReason::ModelHttp,
model_failure: Some(ModelFailureCause::Http { status: 400 }),
}
);
assert_eq!(
old_page.entries[1].detail,
DiagnosticDetail::ModelStep {
invocation: invocation.clone(),
step_id: "step".into(),
status: ModelInterruption::Failed,
model_failure: Some(ModelFailureCause::Http { status: 400 }),
}
);
assert!(
!serde_json::to_string(&old_page)
.unwrap()
.contains("provider-secret")
);
let revision = log
.get_session::<Value>("session")
.await
.unwrap()
.unwrap()
.revision;
assert!(matches!(
log.copy_session(
CopyRequest {
source_session_id: "session".into(),
target_session_id: "copy".into(),
expected_source_revision: revision,
purpose: CopyPurpose::Branch {
turn_id: None,
side_conversation: false
},
},
&json!({}),
2
)
.await
.unwrap(),
SessionCopyResult::Committed(_)
));
log.set_session_archived::<Value>("session", true, 3)
.await
.unwrap();
let title = log
.record_title_failure(invocation.clone(), TitleFailureCause::EmptyOutput)
.await
.unwrap();
let newest = log
.diagnostics_page("session", None, None, 1, 48 * 1024)
.await
.unwrap();
assert_eq!(
newest.entries[0].detail,
DiagnosticDetail::SessionTitle {
invocation: invocation.clone(),
cause: TitleFailureCause::EmptyOutput
}
);
assert_eq!(newest.next_before, Some(title));
assert_eq!(
log.latest_diagnostic_sequence("session", terminal)
.await
.unwrap(),
Some(terminal)
);
assert_eq!(
log.latest_diagnostic_sequence("session", title)
.await
.unwrap(),
Some(title)
);
let older = log
.diagnostics_page(
"session",
Some(newest.through),
newest.next_before,
32,
48 * 1024,
)
.await
.unwrap();
assert_eq!(older.entries, old_page.entries);
assert_eq!(
log.diagnostics_page("session", Some(terminal), None, 32, 48 * 1024)
.await
.unwrap()
.entries,
old_page.entries
);
assert!(
log.diagnostics_page("other", None, None, 32, 48 * 1024)
.await
.unwrap()
.entries
.is_empty()
);
assert!(
log.diagnostics_page("copy", None, None, 32, 48 * 1024)
.await
.unwrap()
.entries
.is_empty()
);
let inherited = log
.scoped_prefix(LogScope::Session { id: "copy".into() }, 32, 128 * 1024)
.await
.unwrap();
assert!(
inherited
.events
.iter()
.any(|entry| entry.event.invocation == invocation
&& matches!(entry.event.fact, Fact::InvocationEnded { .. }))
);
assert!(matches!(
log.diagnostics_page("session", Some(title), Some(0), 32, 48 * 1024)
.await,
Err(StoreError::InvalidTransition(_))
));
assert!(matches!(
log.diagnostics_page("session", Some(title), Some(title + 1), 32, 48 * 1024)
.await,
Err(StoreError::InvalidTransition(_))
));
let all = log
.diagnostics_page("session", None, None, 32, 48 * 1024)
.await
.unwrap();
let mut before = None;
let mut complete = Vec::new();
loop {
let page = log
.diagnostics_page("session", Some(all.through), before, 32, 1)
.await
.unwrap();
assert_eq!(page.through, all.through);
assert_eq!(
page.entries.len(),
1,
"a complete oversized first record must make progress"
);
complete.extend(page.entries);
match page.next_before {
Some(next) => {
assert!(before.is_none_or(|old| next < old));
before = Some(next);
}
None => break,
}
}
assert_eq!(complete, all.entries);
let timestamp = newest.entries[0].recorded_at;
log.close().await.unwrap();
let log = EventLog::open(&path).await.unwrap();
let reopened = log
.diagnostics_page("session", Some(title), None, 32, 48 * 1024)
.await
.unwrap();
assert_eq!(reopened.entries[0].sequence, title);
assert_eq!(reopened.entries[0].recorded_at, timestamp);
assert_eq!(reopened.entries[0].detail, newest.entries[0].detail);
log.close().await.unwrap();
}
#[tokio::test]
async fn title_failure_requires_original_opening_without_inventing_attempts() {
let directory = tempfile::tempdir().unwrap();
let log = EventLog::open(&directory.path().join("title.sqlite"))
.await
.unwrap();
log.create_session("session", "create", &json!({}), 1)
.await
.unwrap();
let original = opening(&log, "session", "first").await;
let mut forged = original.clone();
forged.run_id = "not-original".into();
assert!(matches!(
log.record_title_failure(forged, TitleFailureCause::Preparation)
.await,
Err(StoreError::InvalidTransition(_))
));
let invalid = EventWrite::plain(RuntimeEvent::new(
original.clone(),
Fact::InvocationEnded {
outcome: InvocationOutcome::Failed {
class: "model_http".into(),
message: None,
model_failure: Some(ModelFailureCause::Http { status: 200 }),
},
},
))
.unwrap();
assert!(matches!(
log.append(&invalid).await,
Err(maka_agent_facts::event::CommitError::Rejected(_))
));
append(
&log,
&original,
Fact::InvocationEnded {
outcome: InvocationOutcome::Completed,
},
)
.await;
let later = opening(&log, "session", "later").await;
assert!(matches!(
log.record_title_failure(later.clone(), TitleFailureCause::Preparation)
.await,
Err(StoreError::InvalidTransition(_))
));
append(
&log,
&later,
Fact::InvocationEnded {
outcome: InvocationOutcome::Completed,
},
)
.await;
assert!(matches!(
log.record_title_failure(
original.clone(),
TitleFailureCause::Model {
cause: ModelFailureCause::Http { status: 200 }
}
)
.await,
Err(StoreError::InvalidTransition(_))
));
let sequence = log
.record_title_failure(original.clone(), TitleFailureCause::Preparation)
.await
.unwrap();
let page = log
.diagnostics_page("session", None, None, 32, 48 * 1024)
.await
.unwrap();
assert_eq!(page.entries.len(), 1);
assert_eq!(page.entries[0].sequence, sequence);
assert_eq!(
page.entries[0].detail,
DiagnosticDetail::SessionTitle {
invocation: original,
cause: TitleFailureCause::Preparation
}
);
let attempts = log
.model_attempts(
maka_event_log::usage::Query {
from: 0.0,
to: f64::MAX,
session_id: Some("session".into()),
through: None,
},
0,
32,
)
.await
.unwrap();
assert_eq!(attempts.total, 0);
log.close().await.unwrap();
}