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