Skip to main content

lash_sansio/session_model/
message.rs

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// ─── Structured message types for context-aware pruning ───
9
10/// A structured message with typed parts for context management.
11///
12/// `parts` is `Arc`-shared so cloning a `Message` is one Arc bump per
13/// message field rather than a deep-clone of every `Part`. Construct with
14/// `parts: shared_parts(vec![...])` or `parts: Arc::new(...)`. Mutate via
15/// `Arc::make_mut(&mut message.parts)` when truly needed; most plugin
16/// pipelines should produce a fresh `Vec<Part>` and assign it.
17#[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/// Wrap a `Vec<Part>` for the `Message::parts` field. Use this in struct
27/// literals and tests (`parts: shared_parts(vec![Part { ... }])`) so the
28/// call sites stay short and uniform.
29#[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    /// e.g. "m3.p0"
64    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    /// Opaque provider replay state attached to a `ToolCall` part.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub tool_replay: Option<ProviderReplayMeta>,
76    pub prune_state: PruneState,
77    /// Populated only for `PartKind::Reasoning` parts. Carries opaque
78    /// provider replay metadata so the adapter can re-emit the exact same
79    /// reasoning item on subsequent turns.
80    /// `#[serde(default, skip_serializing_if)]` so older snapshots that
81    /// predate this field round-trip unchanged.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub reasoning_meta: Option<ProviderReasoningReplay>,
84    /// Provider message metadata for assistant text parts. Legacy snapshots
85    /// omit it; adapters synthesize deterministic ids when replaying older
86    /// assistant text.
87    #[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    /// Chain-of-thought / reasoning item captured from providers that expose
102    /// a reasoning channel. `content` holds the human-readable summary for
103    /// display (fix 1.3a). The encrypted blob and raw `summary`/`id` needed
104    /// to re-feed the model on the next turn (fix 1.3b) live in
105    /// `reasoning_meta`. Reasoning parts are preserved across snapshots so
106    /// next-turn re-feeding survives session resume; they are never rendered
107    /// into the flat chat prompt. Provider adapters decide whether and how
108    /// to re-emit them through their native channel.
109    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        // Reasoning parts are not user-visible text and aren't sent to the
134        // model as flat prompt content. Provider adapters may re-emit them
135        // via structured replay metadata instead. Excluding them from the
136        // accounting keeps the rolling-history plugin's prune decisions
137        // driven by real conversation content.
138        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    /// Total character count of all parts (rendered).
177    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        // Reasoning items are display-only from the transcript's point of
241        // view — they are never replayed as flat text. Provider adapters use
242        // structured replay metadata when they can re-emit reasoning.
243        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
265/// Memoized render of a `MessageSequence`'s `base`. Shared across the
266/// per-iteration `MessageSequence` instances that wrap the same base
267/// (typically the `SessionGraphCache`'s projected messages) so the
268/// chat projector's `render_prompt` walk happens once per turn instead
269/// of once per LLM iteration.
270pub 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
305// A `MessageSequence` is a memoized base/delta rope with caches; its meaningful
306// value is the flat, materialized message list. Serialize as exactly that list
307// (and reconstruct an owned sequence on the way back) so that types embedding a
308// `MessageSequence` can derive serde with the same wire form as a plain
309// `Vec<Message>`. This is what lets `Effect` be serialized directly in a turn
310// checkpoint instead of round-tripping through a parallel `Vec<Message>` twin.
311impl 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    /// Attach a shared render cache for the `base` portion. Subsequent
364    /// `render_prompt` calls will reuse the memoized `RenderedPrompt` for
365    /// the base instead of rewalking it. The delta is always re-rendered
366    /// because it changes per LLM iteration. Returns `self` for chaining.
367    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            // Reasoning parts don't participate in tool pairing and are
543            // always safe to resume through.
544            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        // User message has text + image blocks bundled together.
949        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        // JSON round-trip preserves the reasoning part — the snapshot
1197        // layer must not silently drop it, otherwise replays would lose
1198        // the trace.
1199        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        // But the rendered LLM prompt must NOT include the reasoning
1210        // content in any assistant TEXT block — reasoning travels as its
1211        // own block kind so adapters that don't understand it can drop
1212        // without corrupting the visible transcript.
1213        let rendered = render_structured_prompt(&msgs);
1214        assert_eq!(rendered.messages.len(), 1);
1215        assert_eq!(rendered.messages[0].role, LlmRole::Assistant);
1216        // Without `reasoning_meta`, the reasoning part is dropped entirely,
1217        // so the assistant turn contains only the prose block.
1218        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        // When the assistant message consists solely of a display-only
1225        // reasoning part (no encrypted payload), no message is sent at
1226        // all.
1227        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    // ─── Reasoning-part roundtrip (fix 1.3b) ──────────────────────────
1262    //
1263    // Provider reasoning items can carry replay metadata that the adapter
1264    // re-emits on the next turn. The session-model layer stores these parts
1265    // so they survive resume/snapshot and flows them through as
1266    // `kind == "reasoning"` LlmMessages.
1267
1268    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        // The custom `MessageSequence` serde must produce exactly the same wire
1311        // form as a plain `Vec<Message>`. This is the invariant that lets
1312        // `Effect` be serialized directly in a turn checkpoint instead of
1313        // round-tripping through a parallel `Vec<Message>` twin — so existing
1314        // persisted checkpoints stay byte-compatible.
1315        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        // Build via base+delta so the materialization path is exercised, not
1330        // just the trivial owned case.
1331        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        // Older snapshots written before fix 1.3b have no
1352        // `reasoning_meta` column. The field must default to `None`
1353        // and the deserializer must accept the legacy shape.
1354        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        // Whether or not the reasoning item carries an encrypted blob,
1368        // it must NEVER be flattened into assistant text content.
1369        // Without an encrypted blob the adapter also drops it entirely
1370        // (no point re-feeding a display-only summary).
1371        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        // With encrypted content, a single Reasoning block is emitted
1384        // that adapters can re-emit via their native reasoning channel.
1385        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        // Sanity: transcript rendering never includes reasoning text.
1403        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        // The rolling-history plugin's prune logic is driven by
1412        // `prompt_char_count`. Reasoning parts are not user-visible,
1413        // so they must not count against the prompt budget.
1414        let part = reasoning_part_fixture(Some("X=="));
1415        assert_eq!(part.prompt_char_count(), 0);
1416    }
1417}