use mentra::{AgentTranscript, EntryId, TranscriptItem, TranscriptKind};
use crate::PreparedRun;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TranscriptEntry {
pub id: String,
pub parent_id: Option<String>,
#[serde(flatten)]
pub kind: EntryKind,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum EntryKind {
UserTurn,
AssistantTurn,
ToolExchange { is_error: bool },
CanonicalContext,
MemoryRecall,
DelegationRequest,
DelegationResult,
CompactionSummary,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum BranchError {
#[error("this conversation has no entry '{0}'")]
UnknownEntry(String),
#[error("the conversation could not be branched: {0}")]
Failed(String),
}
impl PreparedRun {
pub fn transcript(&self) -> Vec<TranscriptEntry> {
self.replay()
.items()
.iter()
.map(TranscriptEntry::from_item)
.collect()
}
pub fn abandoned(&self) -> Vec<TranscriptEntry> {
self.replay()
.archived()
.iter()
.map(TranscriptEntry::from_item)
.collect()
}
pub fn leaf(&self) -> Option<String> {
self.replay().leaf().map(EntryId::to_string)
}
pub fn children(&self, entry: &str) -> Vec<TranscriptEntry> {
let Some(id) = entry_id(self.replay(), entry) else {
return Vec::new();
};
self.session()
.children(&id)
.into_iter()
.map(TranscriptEntry::from_item)
.collect()
}
pub fn branch_from(&mut self, entry: &str) -> Result<usize, BranchError> {
let Some(target) = entry_id(self.replay(), entry) else {
return Err(BranchError::UnknownEntry(entry.to_string()));
};
self.session_mut()
.branch_from(&target)
.map_err(|error| BranchError::Failed(error.to_string()))
}
fn replay(&self) -> &AgentTranscript {
self.session().replay()
}
}
fn entry_id(transcript: &AgentTranscript, entry: &str) -> Option<EntryId> {
transcript
.items()
.iter()
.chain(transcript.archived())
.find(|item| item.id.as_str() == entry)
.map(|item| item.id.clone())
}
impl TranscriptEntry {
fn from_item(item: &TranscriptItem) -> Self {
Self {
id: item.id.to_string(),
parent_id: item.parent_id.as_ref().map(EntryId::to_string),
kind: EntryKind::from_kind(&item.kind),
text: item.text(),
}
}
}
impl EntryKind {
fn from_kind(kind: &TranscriptKind) -> Self {
match kind {
TranscriptKind::UserTurn => Self::UserTurn,
TranscriptKind::AssistantTurn => Self::AssistantTurn,
TranscriptKind::ToolExchange { is_error, .. } => Self::ToolExchange {
is_error: *is_error,
},
TranscriptKind::CanonicalContext => Self::CanonicalContext,
TranscriptKind::MemoryRecall => Self::MemoryRecall,
TranscriptKind::DelegationRequest { .. } => Self::DelegationRequest,
TranscriptKind::DelegationResult { .. } => Self::DelegationResult,
TranscriptKind::CompactionSummary { .. } => Self::CompactionSummary,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use mentra::ContentBlock;
fn transcript() -> AgentTranscript {
let mut transcript = AgentTranscript::default();
transcript.push(TranscriptItem::user_turn(mentra::Message::user(
ContentBlock::text("hello"),
)));
transcript.push(TranscriptItem::assistant_turn(mentra::Message::assistant(
ContentBlock::text("hi"),
)));
transcript
}
#[test]
fn an_entry_carries_its_text_and_its_parent() {
let transcript = transcript();
let entries: Vec<TranscriptEntry> = transcript
.items()
.iter()
.map(TranscriptEntry::from_item)
.collect();
assert_eq!(entries[0].kind, EntryKind::UserTurn);
assert_eq!(entries[0].text, "hello");
assert_eq!(
entries[0].parent_id, None,
"the first entry starts the conversation"
);
assert_eq!(entries[1].kind, EntryKind::AssistantTurn);
assert_eq!(
entries[1].parent_id.as_deref(),
Some(entries[0].id.as_str()),
"an entry names what it continues from"
);
}
#[test]
fn a_failed_tool_exchange_says_so() {
let item = TranscriptItem::tool_exchange(
mentra::Message::user(ContentBlock::text("result")),
Some("call-1".to_string()),
true,
);
assert_eq!(
TranscriptEntry::from_item(&item).kind,
EntryKind::ToolExchange { is_error: true }
);
}
#[test]
fn an_id_is_only_recognized_when_the_tree_has_it() {
let transcript = transcript();
let known = transcript.items()[0].id.to_string();
assert_eq!(
entry_id(&transcript, &known).map(|id| id.to_string()),
Some(known)
);
assert!(
entry_id(&transcript, "entry-made-up").is_none(),
"an id basis cannot find is an id basis will not act on"
);
}
#[test]
fn an_archived_entry_is_still_a_branch_point() {
let mut transcript = transcript();
let first = transcript.items()[0].id.clone();
transcript.branch_from(&first).expect("the first entry");
let abandoned = transcript.archived()[0].id.to_string();
assert!(entry_id(&transcript, &abandoned).is_some());
assert!(
transcript
.branch_from(&entry_id(&transcript, &abandoned).expect("addressable"))
.is_ok(),
"an abandoned entry is somewhere the conversation can return to"
);
}
#[test]
fn an_entry_serializes_with_its_kind_inline() {
let entry = TranscriptEntry {
id: "entry-1".to_string(),
parent_id: None,
kind: EntryKind::ToolExchange { is_error: false },
text: "listed the files".to_string(),
};
let json = serde_json::to_value(&entry).expect("an entry serializes");
assert_eq!(json["kind"], "tool_exchange");
assert_eq!(json["is_error"], false);
assert_eq!(json["id"], "entry-1");
}
}