Skip to main content

lash_sansio/session_model/
message.rs

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// ─── Structured message types for context-aware pruning ───
10
11/// A structured message with typed parts for context management.
12///
13/// `parts` is `Arc`-shared so cloning a `Message` is one Arc bump per
14/// message field rather than a deep-clone of every `Part`. Construct with
15/// `parts: shared_parts(vec![...])` or `parts: Arc::new(...)`. Mutate via
16/// `Arc::make_mut(&mut message.parts)` when truly needed; most plugin
17/// pipelines should produce a fresh `Vec<Part>` and assign it.
18#[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/// Wrap a `Vec<Part>` for the `Message::parts` field. Use this in struct
28/// literals and tests (`parts: shared_parts(vec![Part { ... }])`) so the
29/// call sites stay short and uniform.
30#[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    /// e.g. "m3.p0"
65    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    /// Opaque provider replay state attached to a `ToolCall` part.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub tool_replay: Option<ProviderReplayMeta>,
77    pub prune_state: PruneState,
78    /// Populated only for `PartKind::Reasoning` parts. Carries opaque
79    /// provider replay metadata so the adapter can re-emit the exact same
80    /// reasoning item on subsequent turns.
81    /// `#[serde(default, skip_serializing_if)]` so older snapshots that
82    /// predate this field round-trip unchanged.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub reasoning_meta: Option<ProviderReasoningReplay>,
85    /// Provider message metadata for assistant text parts. Legacy snapshots
86    /// omit it; adapters synthesize deterministic ids when replaying older
87    /// assistant text.
88    #[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    /// Chain-of-thought / reasoning item captured from providers that expose
103    /// a reasoning channel. `content` holds the human-readable summary for
104    /// display (fix 1.3a). The encrypted blob and raw `summary`/`id` needed
105    /// to re-feed the model on the next turn (fix 1.3b) live in
106    /// `reasoning_meta`. Reasoning parts are preserved across snapshots so
107    /// next-turn re-feeding survives session resume; they are never rendered
108    /// into the flat chat prompt. Provider adapters decide whether and how
109    /// to re-emit them through their native channel.
110    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        // Reasoning parts are not user-visible text and aren't sent to the
135        // model as flat prompt content. Provider adapters may re-emit them
136        // via structured replay metadata instead. Excluding them from the
137        // accounting keeps the rolling-history plugin's prune decisions
138        // driven by real conversation content.
139        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    /// 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::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        // Reasoning items are display-only from the transcript's point of
239        // view — they are never replayed as flat text. Provider adapters use
240        // structured replay metadata when they can re-emit reasoning.
241        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
263/// Memoized render of a `MessageSequence`'s `base`. Shared across the
264/// per-iteration `MessageSequence` instances that wrap the same base
265/// (typically the `SessionGraphCache`'s projected messages) so the
266/// chat projector's `render_prompt` walk happens once per turn instead
267/// of once per LLM iteration.
268pub 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
303// A `MessageSequence` is a memoized base/delta rope with caches; its meaningful
304// value is the flat, materialized message list. Serialize as exactly that list
305// (and reconstruct an owned sequence on the way back) so that types embedding a
306// `MessageSequence` can derive serde with the same wire form as a plain
307// `Vec<Message>`. This is what lets `Effect` be serialized directly in a turn
308// checkpoint instead of round-tripping through a parallel `Vec<Message>` twin.
309impl 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    /// Attach a shared render cache for the `base` portion. Subsequent
362    /// `render_prompt` calls will reuse the memoized `RenderedPrompt` for
363    /// the base instead of rewalking it. The delta is always re-rendered
364    /// because it changes per LLM iteration. Returns `self` for chaining.
365    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            // Reasoning parts don't participate in tool pairing and are
541            // always safe to resume through.
542            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        // User message has text + image blocks bundled together.
943        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        // JSON round-trip preserves the reasoning part — the snapshot
1191        // layer must not silently drop it, otherwise replays would lose
1192        // the trace.
1193        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        // But the rendered LLM prompt must NOT include the reasoning
1204        // content in any assistant TEXT block — reasoning travels as its
1205        // own block kind so adapters that don't understand it can drop
1206        // without corrupting the visible transcript.
1207        let rendered = render_structured_prompt(&msgs);
1208        assert_eq!(rendered.messages.len(), 1);
1209        assert_eq!(rendered.messages[0].role, LlmRole::Assistant);
1210        // Without `reasoning_meta`, the reasoning part is dropped entirely,
1211        // so the assistant turn contains only the prose block.
1212        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        // When the assistant message consists solely of a display-only
1219        // reasoning part (no encrypted payload), no message is sent at
1220        // all.
1221        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    // ─── Reasoning-part roundtrip (fix 1.3b) ──────────────────────────
1256    //
1257    // Provider reasoning items can carry replay metadata that the adapter
1258    // re-emits on the next turn. The session-model layer stores these parts
1259    // so they survive resume/snapshot and flows them through as
1260    // `kind == "reasoning"` LlmMessages.
1261
1262    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        // The custom `MessageSequence` serde must produce exactly the same wire
1305        // form as a plain `Vec<Message>`. This is the invariant that lets
1306        // `Effect` be serialized directly in a turn checkpoint instead of
1307        // round-tripping through a parallel `Vec<Message>` twin — so existing
1308        // persisted checkpoints stay byte-compatible.
1309        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        // Build via base+delta so the materialization path is exercised, not
1324        // just the trivial owned case.
1325        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        // Older snapshots written before fix 1.3b have no
1346        // `reasoning_meta` column. The field must default to `None`
1347        // and the deserializer must accept the legacy shape.
1348        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        // Whether or not the reasoning item carries an encrypted blob,
1362        // it must NEVER be flattened into assistant text content.
1363        // Without an encrypted blob the adapter also drops it entirely
1364        // (no point re-feeding a display-only summary).
1365        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        // With encrypted content, a single Reasoning block is emitted
1378        // that adapters can re-emit via their native reasoning channel.
1379        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        // Sanity: transcript rendering never includes reasoning text.
1397        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        // The rolling-history plugin's prune logic is driven by
1406        // `prompt_char_count`. Reasoning parts are not user-visible,
1407        // so they must not count against the prompt budget.
1408        let part = reasoning_part_fixture(Some("X=="));
1409        assert_eq!(part.prompt_char_count(), 0);
1410    }
1411}