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