Skip to main content

ag_session/
message.rs

1use std::fmt;
2use std::hash::Hasher;
3use std::str::FromStr;
4
5use rustc_hash::FxHasher;
6
7const CLARIFICATION_HEADER: &str = "Clarifications:";
8const USER_PROMPT_CONTINUATION_PREFIX: &str = "   ";
9const USER_PROMPT_PREFIX: &str = " › ";
10
11/// Durable category for one saved session transcript message.
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum SessionMessageKind {
14    /// Raw user prompt text without TUI prompt markers or transcript padding.
15    UserPrompt,
16    /// Raw assistant answer text without transcript padding.
17    AssistantAnswer,
18    /// Generic workflow notice emitted by Agentty session workflows.
19    WorkflowNotice,
20}
21
22impl SessionMessageKind {
23    /// Returns the stable database string for this message kind.
24    pub fn as_str(self) -> &'static str {
25        match self {
26            Self::UserPrompt => "user_prompt",
27            Self::AssistantAnswer => "assistant_answer",
28            Self::WorkflowNotice => "workflow_notice",
29        }
30    }
31
32    /// Returns whether this kind represents a raw conversation message that
33    /// belongs in the normal `session_message` store.
34    pub fn is_conversation_message(self) -> bool {
35        matches!(self, Self::UserPrompt | Self::AssistantAnswer)
36    }
37}
38
39impl fmt::Display for SessionMessageKind {
40    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
41        formatter.write_str(self.as_str())
42    }
43}
44
45impl FromStr for SessionMessageKind {
46    type Err = SessionMessageKindParseError;
47
48    fn from_str(value: &str) -> Result<Self, Self::Err> {
49        match value {
50            "user_prompt" => Ok(Self::UserPrompt),
51            "assistant_answer" => Ok(Self::AssistantAnswer),
52            "workflow_notice" => Ok(Self::WorkflowNotice),
53            _ => Err(SessionMessageKindParseError {
54                value: value.to_string(),
55            }),
56        }
57    }
58}
59
60/// Error returned when a stored session message kind is unknown.
61#[derive(Clone, Debug, Eq, PartialEq)]
62pub struct SessionMessageKindParseError {
63    value: String,
64}
65
66impl fmt::Display for SessionMessageKindParseError {
67    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68        write!(formatter, "unknown session message kind `{}`", self.value)
69    }
70}
71
72impl std::error::Error for SessionMessageKindParseError {}
73
74/// One persisted transcript message for a session.
75#[derive(Clone, Debug, Eq, PartialEq)]
76pub struct SessionMessage {
77    /// Canonical transcript text for this message.
78    pub content: String,
79    /// Durable message category.
80    pub kind: SessionMessageKind,
81    /// Monotonic position within the owning session transcript.
82    pub position: i64,
83}
84
85impl SessionMessage {
86    /// Creates one transcript message at a stable transcript position.
87    pub fn new(position: i64, kind: SessionMessageKind, content: impl Into<String>) -> Self {
88        Self {
89            content: content.into(),
90            kind,
91            position,
92        }
93    }
94
95    /// Creates one raw user or assistant message using kind-specific storage
96    /// normalization.
97    pub fn conversation(position: i64, kind: SessionMessageKind, content: impl AsRef<str>) -> Self {
98        Self {
99            content: stored_message_content(kind, content.as_ref()),
100            kind,
101            position,
102        }
103    }
104}
105
106/// Ordered transcript view assembled from persisted session messages.
107#[derive(Clone, Debug, Default, Eq, PartialEq)]
108pub struct SessionTranscript {
109    content_hash: u64,
110    messages: Vec<SessionMessage>,
111    total_content_len: usize,
112}
113
114impl SessionTranscript {
115    /// Creates an ordered transcript from persisted messages.
116    pub fn new(mut messages: Vec<SessionMessage>) -> Self {
117        messages.sort_by_key(|message| message.position);
118
119        let content_hash = transcript_content_hash(&messages);
120        let total_content_len = messages.iter().map(|message| message.content.len()).sum();
121
122        Self {
123            content_hash,
124            messages,
125            total_content_len,
126        }
127    }
128
129    /// Returns whether the transcript contains no saved messages.
130    pub fn is_empty(&self) -> bool {
131        self.messages.is_empty()
132    }
133
134    /// Returns the ordered transcript messages.
135    pub fn messages(&self) -> &[SessionMessage] {
136        &self.messages
137    }
138
139    /// Returns the cached content identity for render and projection caches.
140    pub fn content_hash(&self) -> u64 {
141        self.content_hash
142    }
143
144    /// Returns the total byte length of message content in this transcript.
145    pub fn total_content_len(&self) -> usize {
146        self.total_content_len
147    }
148
149    /// Appends one message after the ordered transcript tail using the same
150    /// content normalization as durable storage.
151    ///
152    /// [`Self::new`] sorts persisted input before this method derives the next
153    /// position, so the message slice and its cached content hash retain the
154    /// same ordering as a newly reconstructed transcript.
155    pub fn append_message(&mut self, kind: SessionMessageKind, content: &str) {
156        let content = stored_message_content(kind, content);
157        if content.trim().is_empty() {
158            return;
159        }
160
161        let position = self
162            .messages
163            .last()
164            .map_or(0, |message| message.position.saturating_add(1));
165        self.total_content_len = self.total_content_len.saturating_add(content.len());
166        self.messages
167            .push(SessionMessage::new(position, kind, content));
168        self.content_hash = transcript_content_hash(&self.messages);
169    }
170
171    /// Returns formatted transcript text for replay when content exists.
172    ///
173    /// User and assistant rows store raw content, so replay injects the prompt
174    /// marker and transcript spacing only for display and provider replay.
175    pub fn replay_text(&self) -> Option<String> {
176        let output = Self::display_text_for_messages(&self.messages);
177        if output.trim().is_empty() {
178            return None;
179        }
180
181        Some(output)
182    }
183
184    /// Returns formatted user and assistant transcript text when any
185    /// conversation messages exist.
186    pub fn conversation_replay_text(&self) -> Option<String> {
187        let mut output = String::new();
188
189        for message in self
190            .messages
191            .iter()
192            .filter(|message| message.kind.is_conversation_message())
193        {
194            message.append_display_text(&mut output);
195        }
196
197        if output.trim().is_empty() {
198            return None;
199        }
200
201        Some(output)
202    }
203
204    /// Formats an ordered message slice using canonical transcript display
205    /// markers and spacing.
206    ///
207    /// Unlike [`Self::new`], this method preserves the caller-provided order.
208    /// It is useful when rendering a selected subset of an existing transcript
209    /// without constructing another transcript aggregate.
210    pub fn display_text_for_messages(messages: &[SessionMessage]) -> String {
211        let mut output = String::new();
212
213        for message in messages {
214            message.append_display_text(&mut output);
215        }
216
217        output
218    }
219}
220
221/// Computes one ordered identity across message positions, kinds, and raw
222/// content so render caches can compare transcripts without rescanning them on
223/// every frame.
224fn transcript_content_hash(messages: &[SessionMessage]) -> u64 {
225    let mut hasher = FxHasher::default();
226
227    for message in messages {
228        hasher.write_i64(message.position);
229        hasher.write(message.kind.as_str().as_bytes());
230        hasher.write_u8(0xff);
231        hasher.write(message.content.as_bytes());
232        hasher.write_u8(0xfe);
233    }
234
235    hasher.finish()
236}
237
238/// Returns the durable message content for one kind.
239///
240/// User prompts preserve leading horizontal whitespace so pasted indentation
241/// survives persistence while outer line breaks and trailing whitespace are
242/// normalized. Assistant rows remove outer whitespace, while workflow notices
243/// preserve exact content so status blocks keep their spacing.
244pub fn stored_message_content(kind: SessionMessageKind, content: &str) -> String {
245    match kind {
246        SessionMessageKind::UserPrompt => normalized_user_prompt_content(content),
247        SessionMessageKind::AssistantAnswer => normalized_message_content(content),
248        SessionMessageKind::WorkflowNotice => content.to_string(),
249    }
250}
251
252impl SessionMessage {
253    /// Appends this message to a formatted transcript display buffer.
254    fn append_display_text(&self, output: &mut String) {
255        match self.kind {
256            SessionMessageKind::UserPrompt => {
257                append_user_prompt_display_text(output, &self.content);
258            }
259            SessionMessageKind::AssistantAnswer => {
260                append_assistant_answer_display_text(output, &self.content);
261            }
262            SessionMessageKind::WorkflowNotice => output.push_str(&self.content),
263        }
264    }
265}
266
267/// Returns raw persisted message content with only outer whitespace removed.
268pub fn normalized_message_content(content: &str) -> String {
269    content.trim().to_string()
270}
271
272/// Appends one raw user prompt using the session transcript marker and spacing.
273fn append_user_prompt_display_text(output: &mut String, content: &str) {
274    let content = normalized_user_prompt_content(content);
275    if content.trim().is_empty() {
276        return;
277    }
278
279    if !output.is_empty() {
280        output.push('\n');
281    }
282
283    let is_clarification_prompt = content
284        .lines()
285        .next()
286        .is_some_and(|line| line.trim() == CLARIFICATION_HEADER);
287
288    for (line_index, prompt_line) in content.split('\n').enumerate() {
289        if is_clarification_prompt && line_index > 0 && is_clarification_question_line(prompt_line)
290        {
291            output.push_str(USER_PROMPT_CONTINUATION_PREFIX);
292            output.push('\n');
293        }
294
295        if line_index == 0 {
296            output.push_str(USER_PROMPT_PREFIX);
297        } else {
298            output.push_str(USER_PROMPT_CONTINUATION_PREFIX);
299        }
300        output.push_str(prompt_line);
301        output.push('\n');
302    }
303    output.push('\n');
304}
305
306/// Normalizes prompt boundaries without consuming indentation on the first
307/// content line.
308fn normalized_user_prompt_content(content: &str) -> String {
309    content
310        .trim_end()
311        .trim_start_matches(['\r', '\n'])
312        .to_string()
313}
314
315/// Returns true for raw clarification question rows like `1. Q: Need tests?`.
316fn is_clarification_question_line(line: &str) -> bool {
317    let trimmed_line = line.trim_start();
318    let digit_count = trimmed_line
319        .chars()
320        .take_while(char::is_ascii_digit)
321        .count();
322    if digit_count == 0 {
323        return false;
324    }
325
326    let (_, suffix) = trimmed_line.split_at(digit_count);
327
328    suffix.starts_with(". Q: ")
329}
330
331/// Appends one raw assistant answer using session transcript spacing.
332fn append_assistant_answer_display_text(output: &mut String, content: &str) {
333    let content = content.trim();
334    if content.is_empty() {
335        return;
336    }
337
338    output.push_str(content);
339    output.push_str("\n\n");
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn test_session_message_kind_round_trips_database_value() {
348        // Arrange
349        let kind = SessionMessageKind::AssistantAnswer;
350
351        // Act
352        let parsed = kind
353            .as_str()
354            .parse::<SessionMessageKind>()
355            .expect("kind should parse");
356
357        // Assert
358        assert_eq!(parsed, kind);
359        assert_eq!(kind.to_string(), "assistant_answer");
360    }
361
362    #[test]
363    fn test_session_message_kind_rejects_unknown_database_value() {
364        // Arrange / Act
365        let error = "unknown"
366            .parse::<SessionMessageKind>()
367            .expect_err("unknown kind should fail");
368
369        // Assert
370        assert_eq!(error.to_string(), "unknown session message kind `unknown`");
371    }
372
373    #[test]
374    fn test_session_transcript_formats_messages_by_position() {
375        // Arrange
376        let messages = vec![
377            SessionMessage::conversation(2, SessionMessageKind::AssistantAnswer, " answer\n"),
378            SessionMessage::conversation(1, SessionMessageKind::UserPrompt, "\nprompt "),
379        ];
380
381        // Act
382        let transcript = SessionTranscript::new(messages);
383
384        // Assert
385        assert_eq!(
386            transcript.replay_text().expect("expected replay text"),
387            " › prompt\n\nanswer\n\n"
388        );
389    }
390
391    #[test]
392    fn test_session_transcript_content_hash_tracks_exact_message_content() {
393        // Arrange
394        let original = SessionTranscript::new(vec![SessionMessage::conversation(
395            0,
396            SessionMessageKind::AssistantAnswer,
397            "alpha",
398        )]);
399        let replacement = SessionTranscript::new(vec![SessionMessage::conversation(
400            0,
401            SessionMessageKind::AssistantAnswer,
402            "bravo",
403        )]);
404
405        // Act
406        let original_hash = original.content_hash();
407        let replacement_hash = replacement.content_hash();
408
409        // Assert
410        assert_eq!(
411            original.total_content_len(),
412            replacement.total_content_len()
413        );
414        assert_ne!(original_hash, replacement_hash);
415    }
416
417    #[test]
418    fn test_session_transcript_formats_multiline_user_prompt() {
419        // Arrange
420        let messages = vec![SessionMessage::conversation(
421            1,
422            SessionMessageKind::UserPrompt,
423            "first\nsecond",
424        )];
425
426        // Act
427        let transcript = SessionTranscript::new(messages);
428
429        // Assert
430        assert_eq!(
431            transcript.replay_text().expect("expected replay text"),
432            " › first\n   second\n\n"
433        );
434    }
435
436    #[test]
437    fn test_session_transcript_formats_clarification_prompt_with_question_spacing() {
438        // Arrange
439        let messages = vec![SessionMessage::conversation(
440            1,
441            SessionMessageKind::UserPrompt,
442            "Clarifications:\n1. Q: Need target branch?\n   A: main\n2. Q: Need tests?\n   A: yes",
443        )];
444
445        // Act
446        let transcript = SessionTranscript::new(messages);
447
448        // Assert
449        assert_eq!(
450            transcript.replay_text().expect("expected replay text"),
451            " › Clarifications:\n   \n   1. Q: Need target branch?\n      A: main\n   \n   2. Q: \
452             Need tests?\n      A: yes\n\n"
453        );
454    }
455
456    #[test]
457    fn test_session_transcript_formats_prompt_spacing_after_assistant_answer() {
458        // Arrange
459        let messages = vec![
460            SessionMessage::conversation(0, SessionMessageKind::AssistantAnswer, "answer"),
461            SessionMessage::conversation(1, SessionMessageKind::UserPrompt, "next prompt"),
462        ];
463
464        // Act
465        let transcript = SessionTranscript::new(messages);
466
467        // Assert
468        assert_eq!(
469            transcript.replay_text().expect("expected replay text"),
470            "answer\n\n\n › next prompt\n\n"
471        );
472    }
473
474    #[test]
475    fn test_session_transcript_conversation_replay_text_excludes_workflow_notices() {
476        // Arrange
477        let transcript = SessionTranscript::new(vec![
478            SessionMessage::conversation(0, SessionMessageKind::UserPrompt, "review changes"),
479            SessionMessage::new(
480                1,
481                SessionMessageKind::WorkflowNotice,
482                "[Commit] No changes to commit.\n",
483            ),
484            SessionMessage::conversation(2, SessionMessageKind::AssistantAnswer, "done"),
485        ]);
486
487        // Act
488        let conversation_text = transcript
489            .conversation_replay_text()
490            .expect("conversation text should render");
491
492        // Assert
493        assert_eq!(conversation_text, " › review changes\n\ndone\n\n");
494        assert!(!conversation_text.contains("[Commit]"));
495    }
496
497    #[test]
498    fn test_session_transcript_conversation_replay_text_ignores_notice_only_transcript() {
499        // Arrange
500        let transcript = SessionTranscript::new(vec![SessionMessage::new(
501            0,
502            SessionMessageKind::WorkflowNotice,
503            "[Sync] Complete.\n",
504        )]);
505
506        // Act
507        let conversation_text = transcript.conversation_replay_text();
508
509        // Assert
510        assert_eq!(conversation_text, None);
511    }
512
513    #[test]
514    fn test_session_transcript_append_message_preserves_constructor_ordering() {
515        // Arrange
516        let mut transcript = SessionTranscript::new(vec![
517            SessionMessage::conversation(4, SessionMessageKind::AssistantAnswer, "first answer"),
518            SessionMessage::conversation(1, SessionMessageKind::UserPrompt, "prompt"),
519        ]);
520
521        // Act
522        transcript.append_message(SessionMessageKind::WorkflowNotice, "[Sync] Complete.\n");
523        let reconstructed = SessionTranscript::new(transcript.messages().to_vec());
524
525        // Assert
526        assert_eq!(
527            transcript.messages(),
528            &[
529                SessionMessage::conversation(1, SessionMessageKind::UserPrompt, "prompt"),
530                SessionMessage::conversation(
531                    4,
532                    SessionMessageKind::AssistantAnswer,
533                    "first answer",
534                ),
535                SessionMessage::new(5, SessionMessageKind::WorkflowNotice, "[Sync] Complete.\n",),
536            ]
537        );
538        assert_eq!(transcript.content_hash(), reconstructed.content_hash());
539    }
540
541    #[test]
542    fn test_session_transcript_ignores_empty_messages() {
543        // Arrange
544        let mut transcript = SessionTranscript::default();
545        let empty_messages = [
546            SessionMessage::new(0, SessionMessageKind::UserPrompt, "\n"),
547            SessionMessage::new(1, SessionMessageKind::AssistantAnswer, "  "),
548        ];
549
550        // Act
551        transcript.append_message(SessionMessageKind::UserPrompt, "\n");
552        let replay_text = SessionTranscript::display_text_for_messages(&empty_messages);
553
554        // Assert
555        assert!(transcript.is_empty());
556        assert!(replay_text.is_empty());
557    }
558
559    #[test]
560    fn test_session_transcript_total_content_len_updates_on_append() {
561        // Arrange
562        let mut transcript = SessionTranscript::new(vec![SessionMessage::conversation(
563            4,
564            SessionMessageKind::UserPrompt,
565            "prompt",
566        )]);
567
568        // Act
569        transcript.append_message(SessionMessageKind::AssistantAnswer, " answer\n");
570
571        // Assert
572        assert_eq!(
573            transcript.total_content_len(),
574            "prompt".len() + "answer".len()
575        );
576    }
577
578    #[test]
579    fn test_normalized_message_content_removes_outer_whitespace_only() {
580        // Arrange, Act, Assert
581        assert_eq!(
582            normalized_message_content("\n  keep\ninner spacing  \n"),
583            "keep\ninner spacing"
584        );
585    }
586
587    #[test]
588    fn test_stored_message_content_preserves_compatibility_spacing() {
589        // Arrange
590        let workflow_notice = "\n[Sync Error] failed\n";
591
592        // Act
593        let stored = stored_message_content(SessionMessageKind::WorkflowNotice, workflow_notice);
594
595        // Assert
596        assert_eq!(stored, workflow_notice);
597    }
598
599    #[test]
600    fn test_stored_message_content_preserves_user_prompt_indentation() {
601        // Arrange, Act, Assert
602        assert_eq!(
603            stored_message_content(
604                SessionMessageKind::UserPrompt,
605                "\n    first\n        second  \n"
606            ),
607            "    first\n        second"
608        );
609    }
610
611    #[test]
612    fn test_stored_message_content_normalizes_assistant_spacing() {
613        // Arrange, Act, Assert
614        assert_eq!(
615            stored_message_content(SessionMessageKind::AssistantAnswer, "\n  hello  \n"),
616            "hello"
617        );
618    }
619}