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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum SessionMessageKind {
14 UserPrompt,
16 AgentPrompt,
18 AssistantAnswer,
20 WorkflowNotice,
22}
23
24impl SessionMessageKind {
25 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 pub fn is_conversation_message(self) -> bool {
38 matches!(
39 self,
40 Self::UserPrompt | Self::AgentPrompt | Self::AssistantAnswer
41 )
42 }
43
44 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#[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#[derive(Clone, Debug, Eq, PartialEq)]
88pub struct SessionMessage {
89 pub content: String,
91 pub kind: SessionMessageKind,
93 pub position: i64,
95}
96
97impl SessionMessage {
98 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 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#[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 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 pub fn is_empty(&self) -> bool {
143 self.messages.is_empty()
144 }
145
146 pub fn messages(&self) -> &[SessionMessage] {
148 &self.messages
149 }
150
151 pub fn content_hash(&self) -> u64 {
153 self.content_hash
154 }
155
156 pub fn total_content_len(&self) -> usize {
158 self.total_content_len
159 }
160
161 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 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 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 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
233fn 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
250pub 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 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
281pub fn normalized_message_content(content: &str) -> String {
283 content.trim().to_string()
284}
285
286fn 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
320fn normalized_user_prompt_content(content: &str) -> String {
323 content
324 .trim_end()
325 .trim_start_matches(['\r', '\n'])
326 .to_string()
327}
328
329fn 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
345fn 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 let kinds = [
364 SessionMessageKind::UserPrompt,
365 SessionMessageKind::AgentPrompt,
366 SessionMessageKind::AssistantAnswer,
367 SessionMessageKind::WorkflowNotice,
368 ];
369
370 let parsed = kinds.map(|kind| {
372 kind.as_str()
373 .parse::<SessionMessageKind>()
374 .expect("kind should parse")
375 });
376
377 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 let error = "unknown"
389 .parse::<SessionMessageKind>()
390 .expect_err("unknown kind should fail");
391
392 assert_eq!(error.to_string(), "unknown session message kind `unknown`");
394 }
395
396 #[test]
397 fn test_session_transcript_formats_messages_by_position() {
398 let messages = vec![
400 SessionMessage::conversation(2, SessionMessageKind::AssistantAnswer, " answer\n"),
401 SessionMessage::conversation(1, SessionMessageKind::UserPrompt, "\nprompt "),
402 ];
403
404 let transcript = SessionTranscript::new(messages);
406
407 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 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 let original_hash = original.content_hash();
430 let replacement_hash = replacement.content_hash();
431
432 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 let messages = vec![SessionMessage::conversation(
444 1,
445 SessionMessageKind::UserPrompt,
446 "first\nsecond",
447 )];
448
449 let transcript = SessionTranscript::new(messages);
451
452 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 let messages = vec![SessionMessage::conversation(
463 1,
464 SessionMessageKind::AgentPrompt,
465 "resolve review comments",
466 )];
467
468 let transcript = SessionTranscript::new(messages);
470
471 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 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 let transcript = SessionTranscript::new(messages);
493
494 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 let messages = vec![
506 SessionMessage::conversation(0, SessionMessageKind::AssistantAnswer, "answer"),
507 SessionMessage::conversation(1, SessionMessageKind::UserPrompt, "next prompt"),
508 ];
509
510 let transcript = SessionTranscript::new(messages);
512
513 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 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 let conversation_text = transcript
535 .conversation_replay_text()
536 .expect("conversation text should render");
537
538 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 let transcript = SessionTranscript::new(vec![SessionMessage::new(
547 0,
548 SessionMessageKind::WorkflowNotice,
549 "[Sync] Complete.\n",
550 )]);
551
552 let conversation_text = transcript.conversation_replay_text();
554
555 assert_eq!(conversation_text, None);
557 }
558
559 #[test]
560 fn test_session_transcript_append_message_preserves_constructor_ordering() {
561 let mut transcript = SessionTranscript::new(vec![
563 SessionMessage::conversation(4, SessionMessageKind::AssistantAnswer, "first answer"),
564 SessionMessage::conversation(1, SessionMessageKind::UserPrompt, "prompt"),
565 ]);
566
567 transcript.append_message(SessionMessageKind::WorkflowNotice, "[Sync] Complete.\n");
569 let reconstructed = SessionTranscript::new(transcript.messages().to_vec());
570
571 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 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 transcript.append_message(SessionMessageKind::UserPrompt, "\n");
598 let replay_text = SessionTranscript::display_text_for_messages(&empty_messages);
599
600 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 let mut transcript = SessionTranscript::new(vec![SessionMessage::conversation(
609 4,
610 SessionMessageKind::UserPrompt,
611 "prompt",
612 )]);
613
614 transcript.append_message(SessionMessageKind::AssistantAnswer, " answer\n");
616
617 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 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 let workflow_notice = "\n[Sync Error] failed\n";
637
638 let stored = stored_message_content(SessionMessageKind::WorkflowNotice, workflow_notice);
640
641 assert_eq!(stored, workflow_notice);
643 }
644
645 #[test]
646 fn test_stored_message_content_preserves_user_prompt_indentation() {
647 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 assert_eq!(
661 stored_message_content(SessionMessageKind::AssistantAnswer, "\n hello \n"),
662 "hello"
663 );
664 }
665}