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 AssistantAnswer,
18 WorkflowNotice,
20}
21
22impl SessionMessageKind {
23 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 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#[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#[derive(Clone, Debug, Eq, PartialEq)]
76pub struct SessionMessage {
77 pub content: String,
79 pub kind: SessionMessageKind,
81 pub position: i64,
83}
84
85impl SessionMessage {
86 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 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#[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 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 pub fn is_empty(&self) -> bool {
131 self.messages.is_empty()
132 }
133
134 pub fn messages(&self) -> &[SessionMessage] {
136 &self.messages
137 }
138
139 pub fn content_hash(&self) -> u64 {
141 self.content_hash
142 }
143
144 pub fn total_content_len(&self) -> usize {
146 self.total_content_len
147 }
148
149 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 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 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 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
221fn 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
238pub 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 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
267pub fn normalized_message_content(content: &str) -> String {
269 content.trim().to_string()
270}
271
272fn 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
306fn normalized_user_prompt_content(content: &str) -> String {
309 content
310 .trim_end()
311 .trim_start_matches(['\r', '\n'])
312 .to_string()
313}
314
315fn 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
331fn 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 let kind = SessionMessageKind::AssistantAnswer;
350
351 let parsed = kind
353 .as_str()
354 .parse::<SessionMessageKind>()
355 .expect("kind should parse");
356
357 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 let error = "unknown"
366 .parse::<SessionMessageKind>()
367 .expect_err("unknown kind should fail");
368
369 assert_eq!(error.to_string(), "unknown session message kind `unknown`");
371 }
372
373 #[test]
374 fn test_session_transcript_formats_messages_by_position() {
375 let messages = vec![
377 SessionMessage::conversation(2, SessionMessageKind::AssistantAnswer, " answer\n"),
378 SessionMessage::conversation(1, SessionMessageKind::UserPrompt, "\nprompt "),
379 ];
380
381 let transcript = SessionTranscript::new(messages);
383
384 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 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 let original_hash = original.content_hash();
407 let replacement_hash = replacement.content_hash();
408
409 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 let messages = vec![SessionMessage::conversation(
421 1,
422 SessionMessageKind::UserPrompt,
423 "first\nsecond",
424 )];
425
426 let transcript = SessionTranscript::new(messages);
428
429 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 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 let transcript = SessionTranscript::new(messages);
447
448 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 let messages = vec![
460 SessionMessage::conversation(0, SessionMessageKind::AssistantAnswer, "answer"),
461 SessionMessage::conversation(1, SessionMessageKind::UserPrompt, "next prompt"),
462 ];
463
464 let transcript = SessionTranscript::new(messages);
466
467 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 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 let conversation_text = transcript
489 .conversation_replay_text()
490 .expect("conversation text should render");
491
492 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 let transcript = SessionTranscript::new(vec![SessionMessage::new(
501 0,
502 SessionMessageKind::WorkflowNotice,
503 "[Sync] Complete.\n",
504 )]);
505
506 let conversation_text = transcript.conversation_replay_text();
508
509 assert_eq!(conversation_text, None);
511 }
512
513 #[test]
514 fn test_session_transcript_append_message_preserves_constructor_ordering() {
515 let mut transcript = SessionTranscript::new(vec![
517 SessionMessage::conversation(4, SessionMessageKind::AssistantAnswer, "first answer"),
518 SessionMessage::conversation(1, SessionMessageKind::UserPrompt, "prompt"),
519 ]);
520
521 transcript.append_message(SessionMessageKind::WorkflowNotice, "[Sync] Complete.\n");
523 let reconstructed = SessionTranscript::new(transcript.messages().to_vec());
524
525 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 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 transcript.append_message(SessionMessageKind::UserPrompt, "\n");
552 let replay_text = SessionTranscript::display_text_for_messages(&empty_messages);
553
554 assert!(transcript.is_empty());
556 assert_eq!(replay_text, "");
557 }
558
559 #[test]
560 fn test_session_transcript_total_content_len_updates_on_append() {
561 let mut transcript = SessionTranscript::new(vec![SessionMessage::conversation(
563 4,
564 SessionMessageKind::UserPrompt,
565 "prompt",
566 )]);
567
568 transcript.append_message(SessionMessageKind::AssistantAnswer, " answer\n");
570
571 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 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 let workflow_notice = "\n[Sync Error] failed\n";
591
592 let stored = stored_message_content(SessionMessageKind::WorkflowNotice, workflow_notice);
594
595 assert_eq!(stored, workflow_notice);
597 }
598
599 #[test]
600 fn test_stored_message_content_preserves_user_prompt_indentation() {
601 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 assert_eq!(
615 stored_message_content(SessionMessageKind::AssistantAnswer, "\n hello \n"),
616 "hello"
617 );
618 }
619}