1use crate::llm::types::{
2 AttachmentSource, LlmContentBlock, LlmMessage, LlmRole, ProviderReasoningReplay,
3 ProviderReplayMeta, ResponseTextMeta,
4};
5use std::collections::HashSet;
6use std::sync::{Arc, OnceLock};
7
8#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
18pub struct Message {
19 pub id: String,
20 pub role: MessageRole,
21 pub parts: Arc<Vec<Part>>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub origin: Option<MessageOrigin>,
24}
25
26#[inline]
30pub fn shared_parts(parts: Vec<Part>) -> Arc<Vec<Part>> {
31 Arc::new(parts)
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
35pub enum MessageRole {
36 User,
37 Assistant,
38 System,
39 Event,
40}
41
42#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
43#[serde(tag = "kind", rename_all = "snake_case")]
44pub enum MessageOrigin {
45 Plugin {
46 plugin_id: String,
47 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
48 transient: bool,
49 },
50 Process {
51 process_id: String,
52 event_type: String,
53 sequence: u64,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 wake_id: Option<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 caused_by: Option<crate::CausalRef>,
58 },
59}
60
61#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
62pub struct Part {
63 pub id: String,
65 pub kind: PartKind,
66 pub content: String,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub attachment: Option<PartAttachment>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub tool_call_id: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub tool_name: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub tool_replay: Option<ProviderReplayMeta>,
76 pub prune_state: PruneState,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub reasoning_meta: Option<ProviderReasoningReplay>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub response_meta: Option<ResponseTextMeta>,
89}
90
91#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
92pub enum PartKind {
93 Text,
94 Attachment,
95 Code,
96 Output,
97 Error,
98 Prose,
99 ToolCall,
100 ToolResult,
101 Reasoning,
110}
111
112#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
113pub struct PartAttachment {
114 pub source: AttachmentSource,
115}
116
117#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
118pub enum PruneState {
119 Intact,
120 Cleared,
121 Deleted {
122 breadcrumb: String,
123 archive_hash: String,
124 },
125 Summarized {
126 summary: String,
127 archive_hash: String,
128 },
129}
130
131impl Part {
132 pub fn prompt_char_count(&self) -> usize {
133 if matches!(self.kind, PartKind::Reasoning) {
139 return 0;
140 }
141 if matches!(self.kind, PartKind::Attachment) {
142 return self
143 .attachment
144 .as_ref()
145 .and_then(|attachment| attachment.source.stored_ref())
146 .map(|attachment_ref| attachment_ref.id.as_str().len())
147 .unwrap_or_else(|| self.render().len());
148 }
149 self.render().len()
150 }
151
152 pub(crate) fn render(&self) -> String {
153 if matches!(self.kind, PartKind::Attachment) {
154 return if self.attachment.is_some() || self.content.trim().is_empty() {
155 "[Attachment]".to_string()
156 } else {
157 self.content.clone()
158 };
159 }
160 match &self.prune_state {
161 PruneState::Intact => self.content.clone(),
162 PruneState::Cleared => "[Old tool result content cleared]".to_string(),
163 PruneState::Deleted {
164 breadcrumb,
165 archive_hash,
166 } => format!("[pruned:{} — {}]", archive_hash, breadcrumb),
167 PruneState::Summarized {
168 summary,
169 archive_hash,
170 } => format!("[SUMMARY of original {}]\n{}", archive_hash, summary),
171 }
172 }
173}
174
175impl Message {
176 pub fn char_count(&self) -> usize {
178 self.parts.iter().map(Part::prompt_char_count).sum()
179 }
180
181 pub fn is_transient(&self) -> bool {
182 matches!(
183 self.origin,
184 Some(MessageOrigin::Plugin {
185 transient: true,
186 ..
187 })
188 )
189 }
190}
191
192fn render_part_for_chat(role: MessageRole, part: &Part) -> String {
193 let rendered = part.render();
194 match role {
195 MessageRole::System => match part.kind {
196 PartKind::Code => rendered,
197 PartKind::Output => format!("<output>\n{}\n</output>", rendered),
198 PartKind::Error => format!("<error>\n{}\n</error>", rendered),
199 PartKind::Text
200 | PartKind::Attachment
201 | PartKind::Prose
202 | PartKind::ToolCall
203 | PartKind::ToolResult
204 | PartKind::Reasoning => rendered,
205 },
206 MessageRole::Assistant => match part.kind {
207 PartKind::Code => rendered,
208 PartKind::ToolCall => render_assistant_tool_call(part, &rendered),
209 PartKind::Prose | PartKind::Text | PartKind::Attachment | PartKind::ToolResult => {
210 rendered
211 }
212 PartKind::Reasoning => rendered,
213 _ => rendered,
214 },
215 MessageRole::User | MessageRole::Event => rendered,
216 }
217}
218
219fn render_assistant_tool_call(part: &Part, rendered: &str) -> String {
220 let tool_name = part.tool_name.as_deref().unwrap_or("tool");
221 let trimmed = rendered.trim();
222 if trimmed.is_empty() || trimmed == "{}" {
223 format!("{tool_name}()")
224 } else {
225 format!("{tool_name}({trimmed})")
226 }
227}
228
229fn attachment_from_part(part: &Part) -> Option<AttachmentSource> {
230 if !matches!(part.kind, PartKind::Attachment) {
231 return None;
232 }
233 let attachment = part.attachment.as_ref()?;
234 Some(attachment.source.clone())
235}
236
237fn render_message_for_transcript(msg: &Message, attachments: &mut Vec<AttachmentSource>) -> String {
238 let mut out = Vec::new();
239 for part in msg.parts.iter() {
240 if matches!(part.kind, PartKind::Reasoning) {
244 continue;
245 }
246 if let Some(attachment) = attachment_from_part(part) {
247 attachments.push(attachment);
248 out.push("[Attachment]".to_string());
249 continue;
250 }
251 let rendered = render_part_for_chat(msg.role, part);
252 if !rendered.trim().is_empty() {
253 out.push(rendered);
254 }
255 }
256 out.join("\n\n")
257}
258
259#[derive(Clone, Debug, Default, PartialEq, Eq)]
260pub struct RenderedPrompt {
261 pub messages: Vec<LlmMessage>,
262 pub attachments: Vec<AttachmentSource>,
263}
264
265pub type BaseRenderCache = OnceLock<RenderedPrompt>;
271
272#[derive(Debug)]
273pub struct MessageSequence {
274 base: Arc<Vec<Message>>,
275 delta: Vec<Message>,
276 owned: Option<Vec<Message>>,
277 materialized: OnceLock<Arc<Vec<Message>>>,
278 base_rendered: Option<Arc<BaseRenderCache>>,
279}
280
281impl Clone for MessageSequence {
282 fn clone(&self) -> Self {
283 Self {
284 base: Arc::clone(&self.base),
285 delta: self.delta.clone(),
286 owned: self.owned.clone(),
287 materialized: OnceLock::new(),
288 base_rendered: self.base_rendered.as_ref().map(Arc::clone),
289 }
290 }
291}
292
293impl Default for MessageSequence {
294 fn default() -> Self {
295 Self::from_owned(Vec::new())
296 }
297}
298
299impl From<Vec<Message>> for MessageSequence {
300 fn from(messages: Vec<Message>) -> Self {
301 Self::from_owned(messages)
302 }
303}
304
305impl serde::Serialize for MessageSequence {
312 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
313 self.as_slice().serialize(serializer)
314 }
315}
316
317impl<'de> serde::Deserialize<'de> for MessageSequence {
318 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
319 let messages = Vec::<Message>::deserialize(deserializer)?;
320 Ok(Self::from_owned(messages))
321 }
322}
323
324impl std::ops::Deref for MessageSequence {
325 type Target = [Message];
326
327 fn deref(&self) -> &Self::Target {
328 self.as_slice()
329 }
330}
331
332impl MessageSequence {
333 pub fn from_owned(messages: Vec<Message>) -> Self {
334 Self {
335 base: Arc::new(Vec::new()),
336 delta: Vec::new(),
337 owned: Some(messages),
338 materialized: OnceLock::new(),
339 base_rendered: None,
340 }
341 }
342
343 pub fn from_base(base: Arc<Vec<Message>>) -> Self {
344 Self {
345 base,
346 delta: Vec::new(),
347 owned: None,
348 materialized: OnceLock::new(),
349 base_rendered: None,
350 }
351 }
352
353 pub fn from_base_and_delta(base: Arc<Vec<Message>>, delta: Vec<Message>) -> Self {
354 Self {
355 base,
356 delta,
357 owned: None,
358 materialized: OnceLock::new(),
359 base_rendered: None,
360 }
361 }
362
363 pub fn with_base_render_cache(mut self, cache: Arc<BaseRenderCache>) -> Self {
368 self.base_rendered = Some(cache);
369 self
370 }
371
372 pub fn len(&self) -> usize {
373 match &self.owned {
374 Some(owned) => owned.len(),
375 None => self.base.len() + self.delta.len(),
376 }
377 }
378
379 pub fn is_empty(&self) -> bool {
380 self.len() == 0
381 }
382
383 pub fn iter(&self) -> MessageSequenceIter<'_> {
384 match self.owned.as_ref() {
385 Some(owned) => MessageSequenceIter::Owned(owned.iter()),
386 None => MessageSequenceIter::Split(self.base.iter().chain(self.delta.iter())),
387 }
388 }
389
390 pub fn as_slice(&self) -> &[Message] {
391 if let Some(owned) = &self.owned {
392 return owned.as_slice();
393 }
394 if self.delta.is_empty() {
395 return self.base.as_slice();
396 }
397 self.materialized
398 .get_or_init(|| {
399 let mut combined = Vec::with_capacity(self.base.len() + self.delta.len());
400 combined.extend(self.base.iter().cloned());
401 combined.extend(self.delta.iter().cloned());
402 Arc::new(combined)
403 })
404 .as_slice()
405 }
406
407 pub fn shared(&self) -> Arc<Vec<Message>> {
408 if let Some(owned) = &self.owned {
409 return Arc::clone(self.materialized.get_or_init(|| Arc::new(owned.clone())));
410 }
411 if self.delta.is_empty() {
412 return Arc::clone(&self.base);
413 }
414 Arc::clone(self.materialized.get_or_init(|| {
415 let mut combined = Vec::with_capacity(self.base.len() + self.delta.len());
416 combined.extend(self.base.iter().cloned());
417 combined.extend(self.delta.iter().cloned());
418 Arc::new(combined)
419 }))
420 }
421
422 pub fn make_mut(&mut self) -> &mut Vec<Message> {
423 if self.owned.is_none() {
424 let owned = if self.delta.is_empty() {
425 Arc::unwrap_or_clone(Arc::clone(&self.base))
426 } else if let Some(materialized) = self.materialized.get() {
427 Arc::unwrap_or_clone(Arc::clone(materialized))
428 } else {
429 let mut combined = Vec::with_capacity(self.base.len() + self.delta.len());
430 combined.extend(self.base.iter().cloned());
431 combined.extend(self.delta.iter().cloned());
432 combined
433 };
434 self.owned = Some(owned);
435 self.base = Arc::new(Vec::new());
436 self.delta.clear();
437 }
438 self.materialized = OnceLock::new();
439 self.owned.as_mut().expect("message sequence owned state")
440 }
441
442 pub fn push(&mut self, message: Message) {
443 if let Some(owned) = self.owned.as_mut() {
444 owned.push(message);
445 } else {
446 self.delta.push(message);
447 }
448 self.materialized = OnceLock::new();
449 }
450
451 pub fn extend(&mut self, messages: Vec<Message>) {
452 if messages.is_empty() {
453 return;
454 }
455 if let Some(owned) = self.owned.as_mut() {
456 owned.extend(messages);
457 } else {
458 self.delta.extend(messages);
459 }
460 self.materialized = OnceLock::new();
461 }
462
463 pub fn replace(&mut self, messages: Vec<Message>) {
464 self.base = Arc::new(Vec::new());
465 self.delta.clear();
466 self.owned = Some(messages);
467 self.materialized = OnceLock::new();
468 }
469
470 pub fn into_vec(self) -> Vec<Message> {
471 if let Some(owned) = self.owned {
472 return owned;
473 }
474 if self.delta.is_empty() {
475 return Arc::unwrap_or_clone(self.base);
476 }
477 if let Some(materialized) = self.materialized.into_inner() {
478 return Arc::unwrap_or_clone(materialized);
479 }
480 let mut combined = Vec::with_capacity(self.base.len() + self.delta.len());
481 combined.extend(self.base.iter().cloned());
482 combined.extend(self.delta);
483 combined
484 }
485
486 pub fn render_prompt(&self) -> RenderedPrompt {
487 if let Some(owned) = &self.owned {
488 return render_prompt(owned.as_slice());
489 }
490 if self.base.is_empty() {
491 return render_prompt(self.delta.as_slice());
492 }
493 let mut rendered = match &self.base_rendered {
494 Some(cache) => cache
495 .get_or_init(|| render_prompt(self.base.as_slice()))
496 .clone(),
497 None => render_prompt(self.base.as_slice()),
498 };
499 if !self.delta.is_empty() {
500 append_rendered_prompt(&mut rendered, self.delta.as_slice());
501 }
502 rendered
503 }
504}
505
506pub enum MessageSequenceIter<'a> {
507 Owned(std::slice::Iter<'a, Message>),
508 Split(std::iter::Chain<std::slice::Iter<'a, Message>, std::slice::Iter<'a, Message>>),
509}
510
511impl<'a> Iterator for MessageSequenceIter<'a> {
512 type Item = &'a Message;
513
514 fn next(&mut self) -> Option<Self::Item> {
515 match self {
516 Self::Owned(iter) => iter.next(),
517 Self::Split(iter) => iter.next(),
518 }
519 }
520}
521
522#[derive(Clone, Debug, Default)]
523struct TranscriptTurn {
524 user: Vec<String>,
525 assistant: Vec<String>,
526}
527
528pub fn render_prompt(msgs: &[Message]) -> RenderedPrompt {
529 let mut rendered = RenderedPrompt::default();
530 append_rendered_prompt(&mut rendered, msgs);
531 rendered
532}
533
534pub fn messages_are_prompt_resume_safe<'a>(
535 messages: impl IntoIterator<Item = &'a Message>,
536) -> bool {
537 let mut seen_tool_calls = HashSet::new();
538 let mut completed_tool_calls = HashSet::new();
539
540 for message in messages {
541 for part in message.parts.iter() {
542 if matches!(part.kind, PartKind::Reasoning) {
545 continue;
546 }
547 match part.kind {
548 PartKind::ToolCall => {
549 if !matches!(message.role, MessageRole::Assistant) {
550 return false;
551 }
552 let Some(call_id) = part
553 .tool_call_id
554 .as_deref()
555 .map(str::trim)
556 .filter(|call_id| !call_id.is_empty())
557 else {
558 return false;
559 };
560 if !seen_tool_calls.insert(call_id) {
561 return false;
562 }
563 }
564 PartKind::ToolResult => {
565 if !matches!(message.role, MessageRole::User) {
566 return false;
567 }
568 let Some(call_id) = part
569 .tool_call_id
570 .as_deref()
571 .map(str::trim)
572 .filter(|call_id| !call_id.is_empty())
573 else {
574 return false;
575 };
576 if !seen_tool_calls.contains(call_id) {
577 return false;
578 }
579 if !completed_tool_calls.insert(call_id) {
580 return false;
581 }
582 }
583 _ => {}
584 }
585 }
586 }
587
588 seen_tool_calls.len() == completed_tool_calls.len()
589}
590
591pub fn render_transcript_prompt(msgs: &[Message]) -> RenderedPrompt {
592 let mut attachments = Vec::new();
593 let mut turns = Vec::new();
594 let mut current = TranscriptTurn::default();
595 let mut has_current = false;
596
597 for msg in msgs {
598 let text = render_message_for_transcript(msg, &mut attachments);
599 let has_text = !text.trim().is_empty();
600 match msg.role {
601 MessageRole::User | MessageRole::Event => {
602 if has_current && (!current.user.is_empty() || !current.assistant.is_empty()) {
603 turns.push(current);
604 current = TranscriptTurn::default();
605 }
606 if has_text {
607 current
608 .user
609 .push(if matches!(msg.role, MessageRole::Event) {
610 format!("Event:\n{text}")
611 } else {
612 text
613 });
614 }
615 has_current = true;
616 }
617 MessageRole::Assistant | MessageRole::System => {
618 if !has_current {
619 has_current = true;
620 }
621 if has_text {
622 current.assistant.push(text);
623 }
624 }
625 }
626 }
627
628 if has_current && (!current.user.is_empty() || !current.assistant.is_empty()) {
629 turns.push(current);
630 }
631
632 let mut text = String::new();
633 text.push_str(
634 "History:\nThis is a chronological transcript. `Assistant` refers to Lash, and you are continuing the same session.\n\n",
635 );
636 for (idx, turn) in turns.iter().enumerate() {
637 text.push_str(&format!("=== Turn {} ===\n", idx + 1));
638 text.push_str("User:\n");
639 if turn.user.is_empty() {
640 text.push_str("[No user content recorded]\n");
641 } else {
642 text.push_str(&turn.user.join("\n\n"));
643 text.push('\n');
644 }
645 text.push('\n');
646 text.push_str("Assistant (Lash, continuing this transcript):\n");
647 let is_current_pending_turn = idx + 1 == turns.len() && turn.assistant.is_empty();
648 if turn.assistant.is_empty() && !is_current_pending_turn {
649 text.push_str("[No assistant content recorded]\n");
650 } else if !turn.assistant.is_empty() {
651 text.push_str(&turn.assistant.join("\n\n"));
652 text.push('\n');
653 }
654 text.push('\n');
655 }
656 text.push_str(
657 "Continue from the latest turn as Lash.\nIf the task is complete, provide the final answer.\nOtherwise produce the next valid step for this runtime.",
658 );
659
660 RenderedPrompt {
661 messages: vec![LlmMessage::text(LlmRole::User, text)],
662 attachments,
663 }
664}
665
666pub fn append_rendered_prompt(rendered: &mut RenderedPrompt, msgs: &[Message]) {
667 append_structured_prompt(rendered, msgs)
668}
669
670#[cfg(test)]
671fn render_structured_prompt(msgs: &[Message]) -> RenderedPrompt {
672 let mut rendered = RenderedPrompt::default();
673 append_structured_prompt(&mut rendered, msgs);
674 rendered
675}
676
677fn append_structured_prompt(rendered: &mut RenderedPrompt, msgs: &[Message]) {
678 for msg in msgs {
679 let mut blocks: Vec<LlmContentBlock> = Vec::new();
680 for part in msg.parts.iter() {
681 match part.kind {
682 PartKind::Reasoning => {
683 let Some(meta) = part.reasoning_meta.as_ref() else {
684 continue;
685 };
686 if meta.is_empty() {
687 continue;
688 }
689 blocks.push(LlmContentBlock::Reasoning {
690 text: part.content.clone(),
691 replay: Some(meta.clone()),
692 });
693 }
694 PartKind::ToolCall => {
695 let call_id = part.tool_call_id.clone().unwrap_or_default();
696 let tool_name = part.tool_name.clone().unwrap_or_default();
697 blocks.push(LlmContentBlock::ToolCall {
698 call_id,
699 tool_name,
700 input_json: part.content.clone(),
701 replay: part.tool_replay.clone(),
702 });
703 }
704 PartKind::ToolResult => {
705 let text = part.render();
706 let call_id = part.tool_call_id.clone().unwrap_or_default();
707 blocks.push(LlmContentBlock::ToolResult {
708 call_id,
709 content: text,
710 tool_name: part.tool_name.clone(),
711 });
712 }
713 _ => {
714 if let Some(attachment) = attachment_from_part(part)
715 && matches!(msg.role, MessageRole::User)
716 {
717 let attachment_idx = rendered.attachments.len();
718 rendered.attachments.push(attachment);
719 blocks.push(LlmContentBlock::Attachment { attachment_idx });
720 continue;
721 }
722
723 let mut text = render_part_for_chat(msg.role, part);
724 if text.trim().is_empty() {
725 continue;
726 }
727
728 if matches!(msg.role, MessageRole::System | MessageRole::Event) {
729 text = if matches!(msg.role, MessageRole::Event) {
730 format!("Runtime event:\n{text}")
731 } else {
732 format!("Runtime note:\n{text}")
733 };
734 }
735
736 blocks.push(LlmContentBlock::Text {
737 text: text.into(),
738 response_meta: if matches!(part.kind, PartKind::Text | PartKind::Prose) {
739 part.response_meta.clone()
740 } else {
741 None
742 },
743 cache_breakpoint: false,
744 });
745 }
746 }
747 }
748 if blocks.is_empty() {
749 continue;
750 }
751 rendered
752 .messages
753 .push(LlmMessage::new(llm_role_for_message(msg.role), blocks));
754 }
755}
756
757fn llm_role_for_message(role: MessageRole) -> LlmRole {
758 match role {
759 MessageRole::User => LlmRole::User,
760 MessageRole::Assistant => LlmRole::Assistant,
761 MessageRole::System => LlmRole::System,
762 MessageRole::Event => LlmRole::User,
763 }
764}
765
766#[cfg(test)]
767mod tests {
768 use super::*;
769 use crate::AttachmentRef;
770
771 fn part(kind: PartKind, content: &str) -> Part {
772 Part {
773 id: "p0".to_string(),
774 kind,
775 content: content.to_string(),
776 attachment: None,
777 tool_call_id: None,
778 tool_name: None,
779 tool_replay: None,
780 prune_state: PruneState::Intact,
781 reasoning_meta: None,
782 response_meta: None,
783 }
784 }
785
786 fn test_attachment_ref(byte_len: u64) -> AttachmentRef {
787 AttachmentRef {
788 id: crate::AttachmentId::new("att-test"),
789 media_type: crate::MediaType::parse("image/png").unwrap(),
790 byte_len,
791 type_metadata: None,
792 label: None,
793 }
794 }
795
796 fn attachment_part(bytes: &[u8]) -> Part {
797 Part {
798 id: "p0".to_string(),
799 kind: PartKind::Attachment,
800 content: String::new(),
801 attachment: Some(PartAttachment {
802 source: AttachmentSource::stored(test_attachment_ref(bytes.len() as u64)),
803 }),
804 tool_call_id: None,
805 tool_name: None,
806 tool_replay: None,
807 prune_state: PruneState::Intact,
808 reasoning_meta: None,
809 response_meta: None,
810 }
811 }
812
813 #[test]
814 fn render_transcript_prompt_orders_turns_oldest_first() {
815 let msgs = vec![
816 Message {
817 id: "m0".to_string(),
818 role: MessageRole::User,
819 parts: vec![part(PartKind::Text, "first")].into(),
820 origin: None,
821 },
822 Message {
823 id: "m1".to_string(),
824 role: MessageRole::Assistant,
825 parts: vec![part(PartKind::Prose, "reply one")].into(),
826 origin: None,
827 },
828 Message {
829 id: "m2".to_string(),
830 role: MessageRole::User,
831 parts: vec![part(PartKind::Text, "second")].into(),
832 origin: None,
833 },
834 ];
835
836 let rendered = render_transcript_prompt(&msgs);
837 let text = block_text(&rendered.messages[0], 0);
838
839 assert!(text.contains("=== Turn 1 ===\nUser:\nfirst"));
840 assert!(text.contains("Assistant (Lash, continuing this transcript):\nreply one"));
841 assert!(text.contains("=== Turn 2 ===\nUser:\nsecond"));
842 }
843
844 fn block_text(msg: &LlmMessage, idx: usize) -> &str {
845 match msg.blocks.get(idx) {
846 Some(LlmContentBlock::Text { text, .. }) => text.as_ref(),
847 Some(other) => panic!("expected Text block, got {other:?}"),
848 None => panic!("missing block at index {idx}"),
849 }
850 }
851
852 #[test]
853 fn render_prompt_repl_preserves_message_boundaries() {
854 let msgs = vec![
855 Message {
856 id: "m1".to_string(),
857 role: MessageRole::User,
858 parts: vec![part(PartKind::Text, "first")].into(),
859 origin: None,
860 },
861 Message {
862 id: "m2".to_string(),
863 role: MessageRole::Assistant,
864 parts: vec![
865 part(PartKind::Prose, "reply one"),
866 part(PartKind::Code, "x = 1"),
867 ]
868 .into(),
869 origin: None,
870 },
871 Message {
872 id: "m3".to_string(),
873 role: MessageRole::User,
874 parts: vec![part(PartKind::Text, "second")].into(),
875 origin: None,
876 },
877 ];
878
879 let rendered = render_prompt(&msgs);
880 assert_eq!(rendered.messages.len(), 3);
881 assert_eq!(block_text(&rendered.messages[0], 0), "first");
882 assert!(block_text(&rendered.messages[1], 0).contains("reply one"));
883 assert_eq!(block_text(&rendered.messages[1], 1), "x = 1");
884 assert_eq!(block_text(&rendered.messages[2], 0), "second");
885 }
886
887 #[test]
888 fn render_structured_prompt_preserves_tool_protocol_and_user_images() {
889 let msgs = vec![
890 Message {
891 id: "m0".to_string(),
892 role: MessageRole::System,
893 parts: vec![part(PartKind::Text, "note")].into(),
894 origin: None,
895 },
896 Message {
897 id: "m1".to_string(),
898 role: MessageRole::User,
899 parts: vec![
900 part(PartKind::Text, "show this"),
901 attachment_part(&[1, 2, 3]),
902 ]
903 .into(),
904 origin: None,
905 },
906 Message {
907 id: "m2".to_string(),
908 role: MessageRole::Assistant,
909 parts: vec![Part {
910 id: "m2.p0".to_string(),
911 kind: PartKind::ToolCall,
912 content: r#"{"path":"README.md"}"#.to_string(),
913 attachment: None,
914 tool_call_id: Some("tc1".to_string()),
915 tool_name: Some("read_file".to_string()),
916 tool_replay: None,
917 prune_state: PruneState::Intact,
918 reasoning_meta: None,
919 response_meta: None,
920 }]
921 .into(),
922 origin: None,
923 },
924 Message {
925 id: "m3".to_string(),
926 role: MessageRole::User,
927 parts: vec![Part {
928 id: "m3.p0".to_string(),
929 kind: PartKind::ToolResult,
930 content: "ok".to_string(),
931 attachment: None,
932 tool_call_id: Some("tc1".to_string()),
933 tool_name: Some("read_file".to_string()),
934 tool_replay: None,
935 prune_state: PruneState::Intact,
936 reasoning_meta: None,
937 response_meta: None,
938 }]
939 .into(),
940 origin: None,
941 },
942 ];
943
944 let rendered = render_structured_prompt(&msgs);
945 assert_eq!(rendered.messages.len(), 4);
946 assert_eq!(rendered.messages[0].role, LlmRole::System);
947 assert_eq!(block_text(&rendered.messages[0], 0), "Runtime note:\nnote");
948 assert_eq!(rendered.messages[1].role, LlmRole::User);
950 assert!(matches!(
951 rendered.messages[1].blocks[0],
952 LlmContentBlock::Text { .. }
953 ));
954 assert!(matches!(
955 rendered.messages[1].blocks[1],
956 LlmContentBlock::Attachment { attachment_idx: 0 }
957 ));
958 assert_eq!(rendered.attachments.len(), 1);
959 assert!(matches!(
960 rendered.messages[2].blocks[0],
961 LlmContentBlock::ToolCall { .. }
962 ));
963 assert!(matches!(
964 rendered.messages[3].blocks[0],
965 LlmContentBlock::ToolResult { .. }
966 ));
967 }
968
969 #[test]
970 fn render_structured_prompt_preserves_empty_tool_results() {
971 let msgs = vec![
972 Message {
973 id: "m0".to_string(),
974 role: MessageRole::Assistant,
975 parts: vec![Part {
976 id: "m0.p0".to_string(),
977 kind: PartKind::ToolCall,
978 content: r#"{"question":"Pick one"}"#.to_string(),
979 attachment: None,
980 tool_call_id: Some("ask_1".to_string()),
981 tool_name: Some("ask".to_string()),
982 tool_replay: None,
983 prune_state: PruneState::Intact,
984 reasoning_meta: None,
985 response_meta: None,
986 }]
987 .into(),
988 origin: None,
989 },
990 Message {
991 id: "m1".to_string(),
992 role: MessageRole::User,
993 parts: vec![Part {
994 id: "m1.p0".to_string(),
995 kind: PartKind::ToolResult,
996 content: String::new(),
997 attachment: None,
998 tool_call_id: Some("ask_1".to_string()),
999 tool_name: Some("ask".to_string()),
1000 tool_replay: None,
1001 prune_state: PruneState::Intact,
1002 reasoning_meta: None,
1003 response_meta: None,
1004 }]
1005 .into(),
1006 origin: None,
1007 },
1008 ];
1009
1010 let rendered = render_structured_prompt(&msgs);
1011 assert_eq!(rendered.messages.len(), 2);
1012 match &rendered.messages[0].blocks[0] {
1013 LlmContentBlock::ToolCall {
1014 call_id, tool_name, ..
1015 } => {
1016 assert_eq!(call_id, "ask_1");
1017 assert_eq!(tool_name, "ask");
1018 }
1019 other => panic!("expected ToolCall, got {other:?}"),
1020 }
1021 match &rendered.messages[1].blocks[0] {
1022 LlmContentBlock::ToolResult {
1023 call_id, content, ..
1024 } => {
1025 assert_eq!(call_id, "ask_1");
1026 assert!(content.is_empty());
1027 }
1028 other => panic!("expected ToolResult, got {other:?}"),
1029 }
1030 }
1031
1032 #[test]
1033 fn render_transcript_prompt_collects_attachments() {
1034 let msgs = vec![Message {
1035 id: "m0".to_string(),
1036 role: MessageRole::User,
1037 parts: vec![attachment_part(&[9, 8, 7])].into(),
1038 origin: None,
1039 }];
1040
1041 let rendered = render_transcript_prompt(&msgs);
1042 let text = block_text(&rendered.messages[0], 0);
1043 assert!(text.contains("[Attachment]"));
1044 assert_eq!(rendered.attachments.len(), 1);
1045 }
1046
1047 #[test]
1048 fn render_transcript_prompt_omits_missing_assistant_placeholder_for_current_turn() {
1049 let msgs = vec![
1050 Message {
1051 id: "m0".to_string(),
1052 role: MessageRole::User,
1053 parts: vec![part(PartKind::Text, "first")].into(),
1054 origin: None,
1055 },
1056 Message {
1057 id: "m1".to_string(),
1058 role: MessageRole::Assistant,
1059 parts: vec![part(PartKind::Prose, "reply one")].into(),
1060 origin: None,
1061 },
1062 Message {
1063 id: "m2".to_string(),
1064 role: MessageRole::User,
1065 parts: vec![part(PartKind::Text, "second")].into(),
1066 origin: None,
1067 },
1068 ];
1069
1070 let rendered = render_transcript_prompt(&msgs);
1071 let text = block_text(&rendered.messages[0], 0);
1072
1073 assert!(text.contains("=== Turn 2 ===\nUser:\nsecond"));
1074 assert!(!text.contains("=== Turn 2 ===\nUser:\nsecond\n\nAssistant (Lash, continuing this transcript):\n[No assistant content recorded]"));
1075 }
1076
1077 #[test]
1078 fn render_transcript_prompt_preserves_tool_name_for_assistant_tool_calls() {
1079 let msgs = vec![
1080 Message {
1081 id: "m0".to_string(),
1082 role: MessageRole::User,
1083 parts: vec![part(PartKind::Text, "what time is it")].into(),
1084 origin: None,
1085 },
1086 Message {
1087 id: "m1".to_string(),
1088 role: MessageRole::Assistant,
1089 parts: vec![Part {
1090 id: "m1.p0".to_string(),
1091 kind: PartKind::ToolCall,
1092 content: r#"{"cmd":"date"}"#.to_string(),
1093 attachment: None,
1094 tool_call_id: Some("tc1".to_string()),
1095 tool_name: Some("exec_command".to_string()),
1096 tool_replay: None,
1097 prune_state: PruneState::Intact,
1098 reasoning_meta: None,
1099 response_meta: None,
1100 }]
1101 .into(),
1102 origin: None,
1103 },
1104 ];
1105
1106 let rendered = render_transcript_prompt(&msgs);
1107 let text = block_text(&rendered.messages[0], 0);
1108
1109 assert!(text.contains(r#"exec_command({"cmd":"date"})"#));
1110 }
1111
1112 #[test]
1113 fn render_transcript_prompt_omits_runtime_notes_section() {
1114 let msgs = vec![Message {
1115 id: "m0".to_string(),
1116 role: MessageRole::User,
1117 parts: vec![part(PartKind::Text, "hi")].into(),
1118 origin: None,
1119 }];
1120
1121 let rendered = render_transcript_prompt(&msgs);
1122 let text = block_text(&rendered.messages[0], 0);
1123 assert!(!text.contains("Runtime Notes:"));
1124 }
1125
1126 #[test]
1127 fn prompt_resume_safety_accepts_completed_tool_history() {
1128 let msgs = vec![
1129 Message {
1130 id: "m0".to_string(),
1131 role: MessageRole::Assistant,
1132 parts: vec![Part {
1133 id: "m0.p0".to_string(),
1134 kind: PartKind::ToolCall,
1135 content: r#"{"path":"README.md"}"#.to_string(),
1136 attachment: None,
1137 tool_call_id: Some("tc1".to_string()),
1138 tool_name: Some("read_file".to_string()),
1139 tool_replay: None,
1140 prune_state: PruneState::Intact,
1141 reasoning_meta: None,
1142 response_meta: None,
1143 }]
1144 .into(),
1145 origin: None,
1146 },
1147 Message {
1148 id: "m1".to_string(),
1149 role: MessageRole::User,
1150 parts: vec![Part {
1151 id: "m1.p0".to_string(),
1152 kind: PartKind::ToolResult,
1153 content: "ok".to_string(),
1154 attachment: None,
1155 tool_call_id: Some("tc1".to_string()),
1156 tool_name: Some("read_file".to_string()),
1157 tool_replay: None,
1158 prune_state: PruneState::Intact,
1159 reasoning_meta: None,
1160 response_meta: None,
1161 }]
1162 .into(),
1163 origin: None,
1164 },
1165 ];
1166
1167 assert!(messages_are_prompt_resume_safe(&msgs));
1168 }
1169
1170 #[test]
1171 fn reasoning_parts_survive_snapshot_but_never_reach_the_model() {
1172 let reasoning_part = Part {
1173 id: "m1.p0".to_string(),
1174 kind: PartKind::Reasoning,
1175 content: "Thinking about how to answer.".to_string(),
1176 attachment: None,
1177 tool_call_id: None,
1178 tool_name: None,
1179 tool_replay: None,
1180 prune_state: PruneState::Intact,
1181 reasoning_meta: None,
1182 response_meta: None,
1183 };
1184
1185 let msgs = vec![Message {
1186 id: "m1".to_string(),
1187 role: MessageRole::Assistant,
1188 parts: vec![
1189 reasoning_part.clone(),
1190 part(PartKind::Prose, "Here is the answer."),
1191 ]
1192 .into(),
1193 origin: None,
1194 }];
1195
1196 let serialized = serde_json::to_string(&msgs).expect("serialize messages");
1200 let deserialized: Vec<Message> =
1201 serde_json::from_str(&serialized).expect("deserialize messages");
1202 assert_eq!(deserialized[0].parts.len(), 2);
1203 assert!(matches!(deserialized[0].parts[0].kind, PartKind::Reasoning));
1204 assert_eq!(
1205 deserialized[0].parts[0].content,
1206 "Thinking about how to answer."
1207 );
1208
1209 let rendered = render_structured_prompt(&msgs);
1214 assert_eq!(rendered.messages.len(), 1);
1215 assert_eq!(rendered.messages[0].role, LlmRole::Assistant);
1216 assert_eq!(rendered.messages[0].blocks.len(), 1);
1219 assert!(matches!(
1220 &rendered.messages[0].blocks[0],
1221 LlmContentBlock::Text { text, .. } if text.as_ref() == "Here is the answer."
1222 ));
1223
1224 let reasoning_only = vec![Message {
1228 id: "m2".to_string(),
1229 role: MessageRole::Assistant,
1230 parts: vec![reasoning_part].into(),
1231 origin: None,
1232 }];
1233 let rendered_only = render_structured_prompt(&reasoning_only);
1234 assert!(rendered_only.messages.is_empty());
1235 }
1236
1237 #[test]
1238 fn prompt_resume_safety_rejects_unmatched_tool_calls() {
1239 let msgs = vec![Message {
1240 id: "m0".to_string(),
1241 role: MessageRole::Assistant,
1242 parts: vec![Part {
1243 id: "m0.p0".to_string(),
1244 kind: PartKind::ToolCall,
1245 content: r#"{"path":"README.md"}"#.to_string(),
1246 attachment: None,
1247 tool_call_id: Some("tc1".to_string()),
1248 tool_name: Some("read_file".to_string()),
1249 tool_replay: None,
1250 prune_state: PruneState::Intact,
1251 reasoning_meta: None,
1252 response_meta: None,
1253 }]
1254 .into(),
1255 origin: None,
1256 }];
1257
1258 assert!(!messages_are_prompt_resume_safe(&msgs));
1259 }
1260
1261 fn reasoning_part_fixture(encrypted: Option<&str>) -> Part {
1269 Part {
1270 id: "m0.p0".to_string(),
1271 kind: PartKind::Reasoning,
1272 content: "Thinking.".to_string(),
1273 attachment: None,
1274 tool_call_id: None,
1275 tool_name: None,
1276 tool_replay: None,
1277 prune_state: PruneState::Intact,
1278 reasoning_meta: encrypted.map(|encrypted| ProviderReasoningReplay {
1279 item_id: Some("rs_xyz".to_string()),
1280 summary: vec!["Thinking.".to_string()],
1281 encrypted_content: Some(encrypted.to_string()),
1282 signature: None,
1283 redacted: false,
1284 }),
1285 response_meta: None,
1286 }
1287 }
1288
1289 #[test]
1290 fn reasoning_part_roundtrips_through_snapshot_serde() {
1291 let msgs = vec![Message {
1292 id: "m0".to_string(),
1293 role: MessageRole::Assistant,
1294 parts: vec![reasoning_part_fixture(Some("CIPHER=="))].into(),
1295 origin: None,
1296 }];
1297 let serialized = serde_json::to_string(&msgs).expect("serialize");
1298 let deserialized: Vec<Message> = serde_json::from_str(&serialized).expect("deserialize");
1299 assert_eq!(deserialized[0].parts.len(), 1);
1300 let part = &deserialized[0].parts[0];
1301 assert!(matches!(part.kind, PartKind::Reasoning));
1302 let meta = part.reasoning_meta.as_ref().expect("meta survives");
1303 assert_eq!(meta.item_id.as_deref(), Some("rs_xyz"));
1304 assert_eq!(meta.summary, vec!["Thinking.".to_string()]);
1305 assert_eq!(meta.encrypted_content.as_deref(), Some("CIPHER=="));
1306 }
1307
1308 #[test]
1309 fn message_sequence_serializes_as_flat_message_array() {
1310 let msgs = vec![
1316 Message {
1317 id: "m0".to_string(),
1318 role: MessageRole::Assistant,
1319 parts: vec![reasoning_part_fixture(None)].into(),
1320 origin: None,
1321 },
1322 Message {
1323 id: "m1".to_string(),
1324 role: MessageRole::Assistant,
1325 parts: vec![reasoning_part_fixture(Some("CIPHER=="))].into(),
1326 origin: None,
1327 },
1328 ];
1329 let sequence = MessageSequence::from_base_and_delta(
1332 Arc::new(vec![msgs[0].clone()]),
1333 vec![msgs[1].clone()],
1334 );
1335
1336 assert_eq!(
1337 serde_json::to_value(&sequence).expect("serialize sequence"),
1338 serde_json::to_value(&msgs).expect("serialize vec"),
1339 "MessageSequence must serialize identically to Vec<Message>"
1340 );
1341
1342 let decoded: MessageSequence =
1343 serde_json::from_value(serde_json::to_value(&sequence).unwrap())
1344 .expect("deserialize sequence");
1345 assert_eq!(decoded.len(), 2);
1346 assert_eq!(decoded.as_slice()[1].id, "m1");
1347 }
1348
1349 #[test]
1350 fn reasoning_part_roundtrips_when_snapshot_predates_field() {
1351 let legacy = r#"[{
1355 "id":"m0","role":"Assistant",
1356 "parts":[{
1357 "id":"m0.p0","kind":"Prose","content":"Hi",
1358 "prune_state":"Intact"
1359 }]
1360 }]"#;
1361 let msgs: Vec<Message> = serde_json::from_str(legacy).expect("legacy snapshot");
1362 assert!(msgs[0].parts[0].reasoning_meta.is_none());
1363 }
1364
1365 #[test]
1366 fn reasoning_parts_never_flow_to_rendered_prompt_as_text() {
1367 let display_only = vec![Message {
1372 id: "m0".to_string(),
1373 role: MessageRole::Assistant,
1374 parts: vec![reasoning_part_fixture(None)].into(),
1375 origin: None,
1376 }];
1377 let rendered = render_structured_prompt(&display_only);
1378 assert!(
1379 rendered.messages.is_empty(),
1380 "display-only reasoning must not reach the prompt"
1381 );
1382
1383 let replayable = vec![Message {
1386 id: "m0".to_string(),
1387 role: MessageRole::Assistant,
1388 parts: vec![reasoning_part_fixture(Some("CIPHER=="))].into(),
1389 origin: None,
1390 }];
1391 let rendered = render_structured_prompt(&replayable);
1392 assert_eq!(rendered.messages.len(), 1);
1393 match &rendered.messages[0].blocks[0] {
1394 LlmContentBlock::Reasoning { replay, .. } => {
1395 let replay = replay.as_ref().expect("reasoning replay");
1396 assert_eq!(replay.encrypted_content.as_deref(), Some("CIPHER=="));
1397 assert_eq!(replay.item_id.as_deref(), Some("rs_xyz"));
1398 assert_eq!(replay.summary, vec!["Thinking.".to_string()]);
1399 }
1400 other => panic!("expected Reasoning block, got {other:?}"),
1401 }
1402 let transcript = render_transcript_prompt(&replayable);
1404 let transcript_text = block_text(&transcript.messages[0], 0);
1405 assert!(!transcript_text.contains("Thinking."));
1406 assert!(!transcript_text.contains("CIPHER=="));
1407 }
1408
1409 #[test]
1410 fn reasoning_parts_are_zero_for_prune_accounting() {
1411 let part = reasoning_part_fixture(Some("X=="));
1415 assert_eq!(part.prompt_char_count(), 0);
1416 }
1417}