use std::fmt;
use std::str::FromStr;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SessionMessageKind {
TranscriptChunk,
UserPrompt,
AssistantAnswer,
WorkflowNotice,
LegacyTranscript,
}
impl SessionMessageKind {
pub fn as_str(self) -> &'static str {
match self {
Self::TranscriptChunk => "transcript_chunk",
Self::UserPrompt => "user_prompt",
Self::AssistantAnswer => "assistant_answer",
Self::WorkflowNotice => "workflow_notice",
Self::LegacyTranscript => "legacy_transcript",
}
}
pub fn is_conversation_message(self) -> bool {
matches!(self, Self::UserPrompt | Self::AssistantAnswer)
}
}
impl fmt::Display for SessionMessageKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for SessionMessageKind {
type Err = SessionMessageKindParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"transcript_chunk" => Ok(Self::TranscriptChunk),
"user_prompt" => Ok(Self::UserPrompt),
"assistant_answer" => Ok(Self::AssistantAnswer),
"workflow_notice" => Ok(Self::WorkflowNotice),
"legacy_transcript" => Ok(Self::LegacyTranscript),
_ => Err(SessionMessageKindParseError {
value: value.to_string(),
}),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SessionMessageKindParseError {
value: String,
}
impl fmt::Display for SessionMessageKindParseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "unknown session message kind `{}`", self.value)
}
}
impl std::error::Error for SessionMessageKindParseError {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SessionMessage {
pub content: String,
pub kind: SessionMessageKind,
pub position: i64,
}
impl SessionMessage {
pub fn new(position: i64, kind: SessionMessageKind, content: impl Into<String>) -> Self {
Self {
content: content.into(),
kind,
position,
}
}
pub fn conversation(position: i64, kind: SessionMessageKind, content: impl AsRef<str>) -> Self {
Self {
content: normalized_message_content(content.as_ref()),
kind,
position,
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SessionTranscript {
messages: Vec<SessionMessage>,
}
impl SessionTranscript {
pub fn new(mut messages: Vec<SessionMessage>) -> Self {
messages.sort_by_key(|message| message.position);
if let Some(legacy_checkpoint_index) = messages
.iter()
.rposition(|message| message.kind == SessionMessageKind::LegacyTranscript)
{
messages = messages.split_off(legacy_checkpoint_index);
}
Self { messages }
}
pub fn is_empty(&self) -> bool {
self.messages.is_empty()
}
pub fn messages(&self) -> &[SessionMessage] {
&self.messages
}
pub fn to_legacy_output(&self) -> String {
let mut output = String::new();
for message in &self.messages {
message.append_legacy_output(&mut output);
}
output
}
}
pub fn stored_message_content(kind: SessionMessageKind, content: &str) -> String {
if kind.is_conversation_message() {
return normalized_message_content(content);
}
content.to_string()
}
impl SessionMessage {
fn append_legacy_output(&self, output: &mut String) {
match self.kind {
SessionMessageKind::UserPrompt => {
append_legacy_user_prompt(output, &self.content);
}
SessionMessageKind::AssistantAnswer => {
append_legacy_assistant_answer(output, &self.content);
}
SessionMessageKind::TranscriptChunk
| SessionMessageKind::WorkflowNotice
| SessionMessageKind::LegacyTranscript => output.push_str(&self.content),
}
}
}
pub fn normalized_message_content(content: &str) -> String {
content.trim().to_string()
}
fn append_legacy_user_prompt(output: &mut String, content: &str) {
let content = content.trim();
if content.is_empty() {
return;
}
if !output.is_empty() {
output.push('\n');
}
for (line_index, prompt_line) in content.split('\n').enumerate() {
if line_index == 0 {
output.push_str(" › ");
} else {
output.push_str(" ");
}
output.push_str(prompt_line);
output.push('\n');
}
output.push('\n');
}
fn append_legacy_assistant_answer(output: &mut String, content: &str) {
let content = content.trim();
if content.is_empty() {
return;
}
output.push_str(content);
output.push_str("\n\n");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_message_kind_round_trips_database_value() {
let kind = SessionMessageKind::AssistantAnswer;
let parsed = kind
.as_str()
.parse::<SessionMessageKind>()
.expect("kind should parse");
assert_eq!(parsed, kind);
}
#[test]
fn test_session_transcript_serializes_messages_by_position() {
let messages = vec![
SessionMessage::conversation(2, SessionMessageKind::AssistantAnswer, " answer\n"),
SessionMessage::conversation(1, SessionMessageKind::UserPrompt, "\nprompt "),
];
let transcript = SessionTranscript::new(messages);
assert_eq!(transcript.to_legacy_output(), " › prompt\n\nanswer\n\n");
}
#[test]
fn test_session_transcript_serializes_multiline_user_prompt() {
let messages = vec![SessionMessage::conversation(
1,
SessionMessageKind::UserPrompt,
"first\nsecond",
)];
let transcript = SessionTranscript::new(messages);
assert_eq!(transcript.to_legacy_output(), " › first\n second\n\n");
}
#[test]
fn test_session_transcript_uses_latest_legacy_checkpoint() {
let messages = vec![
SessionMessage::conversation(0, SessionMessageKind::UserPrompt, "old prompt"),
SessionMessage::new(
1,
SessionMessageKind::LegacyTranscript,
" › old prompt\n\nold answer\n\n[Sync Error] failed\n",
),
SessionMessage::conversation(2, SessionMessageKind::AssistantAnswer, "new answer"),
];
let transcript = SessionTranscript::new(messages);
assert_eq!(
transcript.to_legacy_output(),
" › old prompt\n\nold answer\n\n[Sync Error] failed\nnew answer\n\n"
);
}
#[test]
fn test_normalized_message_content_removes_outer_whitespace_only() {
assert_eq!(
normalized_message_content("\n keep\ninner spacing \n"),
"keep\ninner spacing"
);
}
#[test]
fn test_stored_message_content_preserves_compatibility_spacing() {
let workflow_notice = "\n[Sync Error] failed\n";
let stored = stored_message_content(SessionMessageKind::WorkflowNotice, workflow_notice);
assert_eq!(stored, workflow_notice);
}
#[test]
fn test_stored_message_content_normalizes_conversation_spacing() {
assert_eq!(
stored_message_content(SessionMessageKind::UserPrompt, "\n hello \n"),
"hello"
);
}
}