use std::collections::{HashMap, HashSet};
use polyc_eventlog::Event;
use polyc_proto::proto::polychrome::agent::v1::Message;
use polyc_proto::{events_decode::decode_event_payload, kinds};
use crate::message_content::{MessageContent, fold_message_content};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommittedMessageFact {
pub turn_id: String,
pub ordinal: u64,
pub text: String,
}
#[must_use]
pub fn committed_turn_ids<'a>(events: impl IntoIterator<Item = &'a Event>) -> HashSet<uuid::Uuid> {
let mut markers: HashMap<uuid::Uuid, (bool, bool)> = HashMap::new();
for ev in events {
let (base, turn_id) = kinds::parse(&ev.kind);
if let Some(id) = turn_id {
let entry = markers.entry(id).or_default();
if base == kinds::TURN_START {
entry.0 = true;
}
if base == kinds::TURN_COMPLETE {
entry.1 = true;
}
}
}
markers
.into_iter()
.filter_map(|(id, (start, complete))| (start && complete).then_some(id))
.collect()
}
#[must_use]
pub fn committed_message_facts(events: &[Event]) -> Vec<CommittedMessageFact> {
let committed = committed_turn_ids(events);
events
.iter()
.enumerate()
.filter_map(|(ordinal, ev)| {
let (base, turn_id) = kinds::parse(&ev.kind);
if base != kinds::USER_MSG && base != kinds::OUTPUT_MSG {
return None;
}
let turn_id = turn_id.filter(|id| committed.contains(id))?;
let msg = decode_event_payload::<Message>(&ev.payload)?;
let MessageContent::Text(text) =
fold_message_content(&msg, ordinal as u64, None, "").content
else {
return None;
};
if text.text.is_empty() || msg.internal_only {
return None;
}
Some(CommittedMessageFact {
turn_id: turn_id.to_string(),
ordinal: ordinal as u64,
text: text.text,
})
})
.collect()
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
use buffa::Message as _;
use polyc_proto::proto::polychrome::agent::v1::{
Content, TextContent, ToolCallContent, content,
};
fn turn() -> uuid::Uuid {
uuid::Uuid::parse_str("01950000-0000-7000-8000-00000000aaaa").expect("uuid")
}
fn tagged(base: &str) -> String {
format!("{base}:{}", turn().simple())
}
fn text_event(base: &str, text: &str) -> Event {
let msg = Message {
role: "user".to_owned(),
content: buffa::MessageField::some(Content {
r#type: Some(content::Type::Text(Box::new(TextContent {
text: text.to_owned(),
..Default::default()
}))),
..Default::default()
}),
..Default::default()
};
Event::new(tagged(base), msg.encode_to_vec())
}
fn committed_log(text: &str) -> Vec<Event> {
vec![
Event::new(tagged(kinds::TURN_START), Vec::new()),
text_event(kinds::USER_MSG, text),
Event::new(tagged(kinds::TURN_COMPLETE), Vec::new()),
]
}
#[test]
fn only_turns_with_both_markers_project() {
let committed = committed_message_facts(&committed_log("hello"));
assert_eq!(committed.len(), 1);
assert_eq!(committed[0].text, "hello");
assert_eq!(committed[0].turn_id, turn().to_string());
let interrupted = vec![
Event::new(tagged(kinds::TURN_START), Vec::new()),
text_event(kinds::USER_MSG, "hello"),
];
assert!(
committed_message_facts(&interrupted).is_empty(),
"an interrupted turn's text must never surface"
);
let headless = vec![
text_event(kinds::USER_MSG, "hello"),
Event::new(tagged(kinds::TURN_COMPLETE), Vec::new()),
];
assert!(committed_message_facts(&headless).is_empty());
}
#[test]
fn ordinal_counts_skipped_events_too() {
let mut events = committed_log("first");
events.push(text_event(kinds::OUTPUT_MSG, "second"));
events.push(Event::new(tagged(kinds::TURN_COMPLETE), Vec::new()));
let facts = committed_message_facts(&events);
let ordinals: Vec<u64> = facts.iter().map(|f| f.ordinal).collect();
assert_eq!(
ordinals,
vec![1, 3],
"positions 0 and 2 are markers — skipped, but still counted"
);
}
#[test]
fn only_text_blocks_produce_facts() {
let tool_call = Message {
role: "model".to_owned(),
content: buffa::MessageField::some(Content {
r#type: Some(content::Type::ToolCall(Box::new(ToolCallContent {
id: "call-1".to_owned(),
..Default::default()
}))),
..Default::default()
}),
..Default::default()
};
let thought = Message {
role: "model".to_owned(),
content: buffa::MessageField::some(Content {
r#type: Some(content::Type::Thought(Box::default())),
..Default::default()
}),
..Default::default()
};
let events = vec![
Event::new(tagged(kinds::TURN_START), Vec::new()),
Event::new(tagged(kinds::OUTPUT_MSG), tool_call.encode_to_vec()),
Event::new(tagged(kinds::OUTPUT_MSG), thought.encode_to_vec()),
text_event(kinds::OUTPUT_MSG, ""),
Event::new(tagged(kinds::TURN_COMPLETE), Vec::new()),
];
assert!(
committed_message_facts(&events).is_empty(),
"a tool call, a reasoning thought, and an empty text block each yield nothing"
);
}
#[test]
fn non_chat_kinds_are_skipped() {
let mut events = committed_log("hello");
events.insert(2, Event::new(tagged(kinds::USAGE), b"whatever".to_vec()));
let facts = committed_message_facts(&events);
assert_eq!(facts.len(), 1, "only the user message projects");
assert_eq!(facts[0].text, "hello");
}
}