Skip to main content

pi/core/sessions/
context.rs

1//! Session path, compaction-aware context, and entry→message projection.
2//!
3//! Ports `buildSessionPath`, `buildContextEntries`, `buildSessionContext`, and
4//! `sessionEntryToContextMessages` from
5//! `.references/pi/packages/coding-agent/src/core/session-manager.ts`.
6
7use std::collections::HashMap;
8
9use pi_agent::{AgentMessage, CustomAgentMessage};
10use pi_ai::Message;
11use serde::de::Error as _;
12use serde_json::Value;
13
14use super::super::messages::{
15    MessageConversionError, create_branch_summary_message, create_compaction_summary_message,
16    create_custom_message,
17};
18use super::entries::{
19    CompactionEntry, ModelChangeEntry, SessionEntry, SessionMessageEntry, ThinkingLevelChangeEntry,
20};
21
22/// Default thinking level when no `thinking_level_change` is on the path.
23pub const DEFAULT_THINKING_LEVEL: &str = "off";
24
25/// Leaf-selection semantics for path/context construction.
26///
27/// Mirrors the TypeScript `leafId?: string | null` argument:
28/// - [`LeafRef::Last`] — argument omitted (`undefined`): start from the last entry.
29/// - [`LeafRef::Null`] — explicit `null`: empty path.
30/// - [`LeafRef::Id`] — start from that id (missing id falls back to last entry).
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub enum LeafRef<'a> {
33    /// Use the last entry in file order (TS `undefined`).
34    Last,
35    /// Empty path (TS `null`).
36    Null,
37    /// Walk from this entry id.
38    Id(&'a str),
39}
40
41/// Provider/model pair resolved from the active path.
42#[derive(Clone, Debug, Eq, PartialEq)]
43pub struct SessionModel {
44    /// Provider id.
45    pub provider: String,
46    /// Model id within the provider.
47    pub model_id: String,
48}
49
50/// Resolved session context for the LLM turn.
51#[derive(Clone, Debug, PartialEq)]
52pub struct SessionContext {
53    /// Messages projected from the compaction-aware path.
54    pub messages: Vec<AgentMessage>,
55    /// Last thinking level on the full path (default `"off"`).
56    pub thinking_level: String,
57    /// Last model from a `model_change` or assistant message on the full path.
58    pub model: Option<SessionModel>,
59}
60
61/// Walk from leaf to root, returning entries in root→leaf order.
62///
63/// When `leaf` is [`LeafRef::Id`] and the id is not in the index, falls back to
64/// the last entry (TypeScript `buildSessionPath` behavior).
65#[must_use]
66pub fn build_session_path<'a>(
67    entries: &[&'a SessionEntry],
68    leaf: LeafRef<'a>,
69) -> Vec<&'a SessionEntry> {
70    let index = build_entry_index(entries);
71
72    let start: Option<&'a SessionEntry> = match leaf {
73        LeafRef::Null => return Vec::new(),
74        LeafRef::Last => entries.last().copied(),
75        LeafRef::Id(id) => {
76            if id.is_empty() {
77                entries.last().copied()
78            } else {
79                index.get(id).copied().or_else(|| entries.last().copied())
80            }
81        }
82    };
83
84    let Some(mut current) = start else {
85        return Vec::new();
86    };
87
88    let mut path = Vec::new();
89    loop {
90        path.push(current);
91        match current.parent_id() {
92            Some(pid) => match index.get(pid) {
93                Some(next) => current = next,
94                None => break,
95            },
96            None => break,
97        }
98    }
99    path.reverse();
100    path
101}
102
103/// Build the compaction-aware active entry list for context/rendering.
104///
105/// Order: `[latest compaction on path]` + path entries from `firstKeptEntryId`
106/// up to (but not including) the compaction + all entries after the compaction.
107/// Older summarized entries are omitted. Settings must still be read from the
108/// full path via [`build_session_context`].
109#[must_use]
110pub fn build_context_entries<'a>(
111    entries: &[&'a SessionEntry],
112    leaf: LeafRef<'a>,
113) -> Vec<&'a SessionEntry> {
114    let path = build_session_path(entries, leaf);
115
116    let mut compaction: Option<&'a SessionEntry> = None;
117    for entry in &path {
118        if entry.discriminant() == "compaction" {
119            compaction = Some(*entry);
120        }
121    }
122
123    let Some(compaction) = compaction else {
124        return path;
125    };
126
127    let compaction_id = compaction.id();
128    let compaction_idx = path.iter().position(|e| e.id() == compaction_id);
129    let Some(compaction_idx) = compaction_idx else {
130        return path;
131    };
132
133    let first_kept = match compaction {
134        SessionEntry::Compaction(c) => Some(c.first_kept_entry_id.as_str()),
135        SessionEntry::Unknown(raw) => raw.get("firstKeptEntryId").and_then(Value::as_str),
136        _ => None,
137    };
138
139    let mut context: Vec<&'a SessionEntry> = vec![compaction];
140    let mut found_first_kept = false;
141    for entry in path.iter().take(compaction_idx) {
142        if first_kept.is_some() && entry.id() == first_kept {
143            found_first_kept = true;
144        }
145        if found_first_kept {
146            context.push(*entry);
147        }
148    }
149    context.extend(path.iter().skip(compaction_idx + 1).copied());
150    context
151}
152
153/// Build the session context (messages + thinking level + model) for the LLM.
154///
155/// # Errors
156///
157/// Returns a [`MessageConversionError`] if any entry on the active path fails
158/// to project into agent messages (e.g. a malformed custom/compaction payload).
159pub fn build_session_context(
160    entries: &[&SessionEntry],
161    leaf: LeafRef<'_>,
162) -> Result<SessionContext, MessageConversionError> {
163    let path = build_session_path(entries, leaf);
164    let (thinking_level, model) = get_session_context_settings(&path);
165    let context_entries = build_context_entries(entries, leaf);
166    let mut messages = Vec::new();
167    for entry in context_entries {
168        messages.extend(session_entry_to_context_messages(entry)?);
169    }
170    Ok(SessionContext {
171        messages,
172        thinking_level,
173        model,
174    })
175}
176
177/// Project one selected session entry into runtime/LLM messages.
178///
179/// Plain custom / label / model / thinking entries return an empty list.
180///
181/// # Errors
182///
183/// Returns a [`MessageConversionError`] if the entry is a custom, branch
184/// summary, or compaction entry whose payload cannot be serialized or wrapped
185/// into an agent message.
186pub fn session_entry_to_context_messages(
187    entry: &SessionEntry,
188) -> Result<Vec<AgentMessage>, MessageConversionError> {
189    match entry {
190        SessionEntry::Message(m) => Ok(vec![m.message.clone()]),
191        SessionEntry::CustomMessage(c) => {
192            let custom = create_custom_message(
193                &c.custom_type,
194                c.content.clone(),
195                c.display,
196                c.details.clone(),
197                &c.timestamp,
198            )?;
199            Ok(vec![product_to_agent_message(&custom)?])
200        }
201        SessionEntry::BranchSummary(b) if !b.summary.is_empty() => {
202            let msg = create_branch_summary_message(&b.summary, &b.from_id, &b.timestamp)?;
203            Ok(vec![product_to_agent_message(&msg)?])
204        }
205        SessionEntry::Compaction(c) => {
206            let msg = create_compaction_summary_message(&c.summary, c.tokens_before, &c.timestamp)?;
207            Ok(vec![product_to_agent_message(&msg)?])
208        }
209        _ => Ok(Vec::new()),
210    }
211}
212
213/// Latest compaction entry on a list (reverse scan), if any.
214#[must_use]
215pub fn get_latest_compaction_entry<'a>(
216    entries: &[&'a SessionEntry],
217) -> Option<&'a CompactionEntry> {
218    for entry in entries.iter().rev() {
219        if let SessionEntry::Compaction(c) = entry {
220            return Some(c);
221        }
222    }
223    None
224}
225
226// ---------------------------------------------------------------------------
227// Internals
228// ---------------------------------------------------------------------------
229
230fn build_entry_index<'a>(entries: &[&'a SessionEntry]) -> HashMap<&'a str, &'a SessionEntry> {
231    let mut index = HashMap::with_capacity(entries.len());
232    for entry in entries {
233        if let Some(id) = entry.id() {
234            index.insert(id, *entry);
235        }
236    }
237    index
238}
239
240fn get_session_context_settings(path: &[&SessionEntry]) -> (String, Option<SessionModel>) {
241    let mut thinking_level = DEFAULT_THINKING_LEVEL.to_owned();
242    let mut model: Option<SessionModel> = None;
243
244    for entry in path {
245        match entry {
246            SessionEntry::ThinkingLevelChange(ThinkingLevelChangeEntry {
247                thinking_level: level,
248                ..
249            }) => {
250                thinking_level.clone_from(level);
251            }
252            SessionEntry::ModelChange(ModelChangeEntry {
253                provider, model_id, ..
254            }) => {
255                model = Some(SessionModel {
256                    provider: provider.clone(),
257                    model_id: model_id.clone(),
258                });
259            }
260            SessionEntry::Message(SessionMessageEntry { message, .. })
261                if message.role() == "assistant" =>
262            {
263                if let Some(Message::Assistant(a)) = message.as_llm() {
264                    model = Some(SessionModel {
265                        provider: a.provider.clone(),
266                        model_id: a.model.clone(),
267                    });
268                }
269            }
270            _ => {}
271        }
272    }
273
274    (thinking_level, model)
275}
276
277fn product_to_agent_message<T: serde::Serialize>(
278    msg: &T,
279) -> Result<AgentMessage, MessageConversionError> {
280    let value =
281        serde_json::to_value(msg).map_err(|source| MessageConversionError::InvalidPayload {
282            role: "product",
283            source,
284        })?;
285    let Value::Object(mut map) = value else {
286        return Err(MessageConversionError::InvalidPayload {
287            role: "product",
288            source: serde_json::Error::custom("product message must serialize to an object"),
289        });
290    };
291    let Some(Value::String(role)) = map.remove("role") else {
292        return Err(MessageConversionError::InvalidPayload {
293            role: "product",
294            source: serde_json::Error::custom("product message missing string role"),
295        });
296    };
297    Ok(AgentMessage::Custom(CustomAgentMessage::new(role, map)))
298}
299
300// ---------------------------------------------------------------------------
301// Tests
302// ---------------------------------------------------------------------------
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use serde_json::json;
308
309    type TestResult = Result<(), Box<dyn std::error::Error>>;
310    type FixtureResult = Result<SessionEntry, Box<dyn std::error::Error>>;
311
312    fn msg(id: &str, parent: Option<&str>, role: &str, text: &str) -> FixtureResult {
313        let value = if role == "user" {
314            json!({
315                "type": "message",
316                "id": id,
317                "parentId": parent,
318                "timestamp": "2025-01-01T00:00:00Z",
319                "message": { "role": "user", "content": text, "timestamp": 1 }
320            })
321        } else {
322            json!({
323                "type": "message",
324                "id": id,
325                "parentId": parent,
326                "timestamp": "2025-01-01T00:00:00Z",
327                "message": {
328                    "role": "assistant",
329                    "content": [{ "type": "text", "text": text }],
330                    "api": "anthropic-messages",
331                    "provider": "anthropic",
332                    "model": "claude-test",
333                    "usage": {
334                        "input": 1, "output": 1, "cacheRead": 0, "cacheWrite": 0,
335                        "totalTokens": 2,
336                        "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "total": 0 }
337                    },
338                    "stopReason": "stop",
339                    "timestamp": 1
340                }
341            })
342        };
343        Ok(serde_json::from_value(value)?)
344    }
345
346    fn compaction(
347        id: &str,
348        parent: Option<&str>,
349        summary: &str,
350        first_kept: &str,
351    ) -> FixtureResult {
352        Ok(serde_json::from_value(json!({
353            "type": "compaction",
354            "id": id,
355            "parentId": parent,
356            "timestamp": "2025-01-01T00:00:00Z",
357            "summary": summary,
358            "firstKeptEntryId": first_kept,
359            "tokensBefore": 1000
360        }))?)
361    }
362
363    fn branch_summary(
364        id: &str,
365        parent: Option<&str>,
366        summary: &str,
367        from_id: &str,
368    ) -> FixtureResult {
369        Ok(serde_json::from_value(json!({
370            "type": "branch_summary",
371            "id": id,
372            "parentId": parent,
373            "timestamp": "2025-01-01T00:00:00Z",
374            "summary": summary,
375            "fromId": from_id
376        }))?)
377    }
378
379    fn custom(id: &str, parent: Option<&str>, custom_type: &str) -> FixtureResult {
380        Ok(serde_json::from_value(json!({
381            "type": "custom",
382            "id": id,
383            "parentId": parent,
384            "timestamp": "2025-01-01T00:00:00Z",
385            "customType": custom_type,
386            "data": { "x": 1 }
387        }))?)
388    }
389
390    fn thinking(id: &str, parent: Option<&str>, level: &str) -> FixtureResult {
391        Ok(serde_json::from_value(json!({
392            "type": "thinking_level_change",
393            "id": id,
394            "parentId": parent,
395            "timestamp": "2025-01-01T00:00:00Z",
396            "thinkingLevel": level
397        }))?)
398    }
399
400    fn model_change(
401        id: &str,
402        parent: Option<&str>,
403        provider: &str,
404        model_id: &str,
405    ) -> FixtureResult {
406        Ok(serde_json::from_value(json!({
407            "type": "model_change",
408            "id": id,
409            "parentId": parent,
410            "timestamp": "2025-01-01T00:00:00Z",
411            "provider": provider,
412            "modelId": model_id
413        }))?)
414    }
415
416    fn refs(entries: &[SessionEntry]) -> Vec<&SessionEntry> {
417        entries.iter().collect()
418    }
419
420    #[test]
421    fn empty_entries_empty_context() -> TestResult {
422        let ctx = build_session_context(&[], LeafRef::Last)?;
423        assert!(ctx.messages.is_empty());
424        assert_eq!(ctx.thinking_level, "off");
425        assert!(ctx.model.is_none());
426        Ok(())
427    }
428
429    #[test]
430    fn simple_conversation() -> TestResult {
431        let entries = [
432            msg("1", None, "user", "hello")?,
433            msg("2", Some("1"), "assistant", "hi there")?,
434            msg("3", Some("2"), "user", "how are you")?,
435            msg("4", Some("3"), "assistant", "great")?,
436        ];
437        let r = refs(&entries);
438        let ctx = build_session_context(&r, LeafRef::Last)?;
439        assert_eq!(ctx.messages.len(), 4);
440        assert_eq!(
441            ctx.messages
442                .iter()
443                .map(AgentMessage::role)
444                .collect::<Vec<_>>(),
445            vec!["user", "assistant", "user", "assistant"]
446        );
447        Ok(())
448    }
449
450    #[test]
451    fn tracks_thinking_level() -> TestResult {
452        let entries = [
453            msg("1", None, "user", "hello")?,
454            thinking("2", Some("1"), "high")?,
455            msg("3", Some("2"), "assistant", "thinking hard")?,
456        ];
457        let r = refs(&entries);
458        let ctx = build_session_context(&r, LeafRef::Last)?;
459        assert_eq!(ctx.thinking_level, "high");
460        assert_eq!(ctx.messages.len(), 2);
461        Ok(())
462    }
463
464    #[test]
465    fn tracks_model_from_assistant() -> TestResult {
466        let entries = [
467            msg("1", None, "user", "hello")?,
468            msg("2", Some("1"), "assistant", "hi")?,
469        ];
470        let r = refs(&entries);
471        let ctx = build_session_context(&r, LeafRef::Last)?;
472        let model = ctx.model.ok_or("model")?;
473        assert_eq!(model.provider, "anthropic");
474        assert_eq!(model.model_id, "claude-test");
475        Ok(())
476    }
477
478    #[test]
479    fn assistant_overwrites_model_change() -> TestResult {
480        let entries = [
481            msg("1", None, "user", "hello")?,
482            model_change("2", Some("1"), "openai", "gpt-4")?,
483            msg("3", Some("2"), "assistant", "hi")?,
484        ];
485        let r = refs(&entries);
486        let ctx = build_session_context(&r, LeafRef::Last)?;
487        let model = ctx.model.ok_or("model")?;
488        assert_eq!(model.provider, "anthropic");
489        assert_eq!(model.model_id, "claude-test");
490        Ok(())
491    }
492
493    #[test]
494    fn compaction_includes_summary_before_kept() -> TestResult {
495        let entries = [
496            msg("1", None, "user", "first")?,
497            msg("2", Some("1"), "assistant", "response1")?,
498            msg("3", Some("2"), "user", "second")?,
499            msg("4", Some("3"), "assistant", "response2")?,
500            compaction("5", Some("4"), "Summary of first two turns", "3")?,
501            msg("6", Some("5"), "user", "third")?,
502            msg("7", Some("6"), "assistant", "response3")?,
503        ];
504        let r = refs(&entries);
505        let ctx = build_session_context(&r, LeafRef::Last)?;
506        assert_eq!(ctx.messages.len(), 5);
507        assert_eq!(ctx.messages[0].role(), "compactionSummary");
508        let AgentMessage::Custom(c) = &ctx.messages[0] else {
509            return Err("expected custom".into());
510        };
511        let summary = c
512            .payload
513            .get("summary")
514            .and_then(Value::as_str)
515            .ok_or("summary")?;
516        assert!(summary.contains("Summary of first two turns"));
517        Ok(())
518    }
519
520    #[test]
521    fn multiple_compactions_uses_latest() -> TestResult {
522        let entries = [
523            msg("1", None, "user", "a")?,
524            msg("2", Some("1"), "assistant", "b")?,
525            compaction("3", Some("2"), "First summary", "1")?,
526            msg("4", Some("3"), "user", "c")?,
527            msg("5", Some("4"), "assistant", "d")?,
528            compaction("6", Some("5"), "Second summary", "4")?,
529            msg("7", Some("6"), "user", "e")?,
530        ];
531        let r = refs(&entries);
532        let ctx = build_session_context(&r, LeafRef::Last)?;
533        assert_eq!(ctx.messages.len(), 4);
534        let AgentMessage::Custom(c) = &ctx.messages[0] else {
535            return Err("expected custom".into());
536        };
537        let summary = c
538            .payload
539            .get("summary")
540            .and_then(Value::as_str)
541            .ok_or("summary")?;
542        assert!(summary.contains("Second summary"));
543        Ok(())
544    }
545
546    #[test]
547    fn build_context_entries_includes_custom_on_path() -> TestResult {
548        let entries = [
549            msg("1", None, "user", "first")?,
550            custom("2", Some("1"), "old-state")?,
551            msg("3", Some("2"), "assistant", "response1")?,
552            custom("4", Some("3"), "kept-card")?,
553            msg("5", Some("4"), "user", "second")?,
554            compaction("6", Some("5"), "Summary", "4")?,
555            custom("7", Some("6"), "after-card")?,
556            msg("8", Some("7"), "assistant", "response2")?,
557        ];
558        let r = refs(&entries);
559        let ids: Vec<&str> = build_context_entries(&r, LeafRef::Last)
560            .iter()
561            .filter_map(|e| e.id())
562            .collect();
563        assert_eq!(ids, vec!["6", "4", "5", "7", "8"]);
564        Ok(())
565    }
566
567    #[test]
568    fn settings_from_full_path_after_compaction() -> TestResult {
569        let entries = [
570            msg("1", None, "user", "first")?,
571            thinking("2", Some("1"), "high")?,
572            msg("3", Some("2"), "assistant", "response1")?,
573            msg("4", Some("3"), "user", "second")?,
574            compaction("5", Some("4"), "Summary", "4")?,
575        ];
576        let r = refs(&entries);
577        let ctx = build_session_context(&r, LeafRef::Last)?;
578        assert_eq!(ctx.thinking_level, "high");
579        assert_eq!(
580            ctx.messages
581                .iter()
582                .map(AgentMessage::role)
583                .collect::<Vec<_>>(),
584            vec!["compactionSummary", "user"]
585        );
586        Ok(())
587    }
588
589    #[test]
590    fn follows_path_to_specified_leaf() -> TestResult {
591        let entries = [
592            msg("1", None, "user", "start")?,
593            msg("2", Some("1"), "assistant", "response")?,
594            msg("3", Some("2"), "user", "branch A")?,
595            msg("4", Some("2"), "user", "branch B")?,
596        ];
597        let r = refs(&entries);
598        let ctx_a = build_session_context(&r, LeafRef::Id("3"))?;
599        assert_eq!(ctx_a.messages.len(), 3);
600        let ctx_b = build_session_context(&r, LeafRef::Id("4"))?;
601        assert_eq!(ctx_b.messages.len(), 3);
602        Ok(())
603    }
604
605    #[test]
606    fn includes_branch_summary_in_path() -> TestResult {
607        let entries = [
608            msg("1", None, "user", "start")?,
609            msg("2", Some("1"), "assistant", "response")?,
610            msg("3", Some("2"), "user", "abandoned path")?,
611            branch_summary("4", Some("2"), "Summary of abandoned work", "3")?,
612            msg("5", Some("4"), "user", "new direction")?,
613        ];
614        let r = refs(&entries);
615        let ctx = build_session_context(&r, LeafRef::Id("5"))?;
616        assert_eq!(ctx.messages.len(), 4);
617        assert_eq!(ctx.messages[2].role(), "branchSummary");
618        Ok(())
619    }
620
621    #[test]
622    fn missing_leaf_id_falls_back_to_last() -> TestResult {
623        let entries = [
624            msg("1", None, "user", "hello")?,
625            msg("2", Some("1"), "assistant", "hi")?,
626        ];
627        let r = refs(&entries);
628        let ctx = build_session_context(&r, LeafRef::Id("nonexistent"))?;
629        assert_eq!(ctx.messages.len(), 2);
630        Ok(())
631    }
632
633    #[test]
634    fn orphaned_entry_path() -> TestResult {
635        let entries = [
636            msg("1", None, "user", "hello")?,
637            msg("2", Some("missing"), "assistant", "orphan")?,
638        ];
639        let r = refs(&entries);
640        let ctx = build_session_context(&r, LeafRef::Id("2"))?;
641        assert_eq!(ctx.messages.len(), 1);
642        Ok(())
643    }
644
645    #[test]
646    fn custom_entries_not_in_messages() -> TestResult {
647        let entries = [
648            msg("1", None, "user", "hello")?,
649            custom("2", Some("1"), "my_data")?,
650            msg("3", Some("2"), "assistant", "hi")?,
651        ];
652        let r = refs(&entries);
653        let ctx = build_session_context(&r, LeafRef::Last)?;
654        assert_eq!(ctx.messages.len(), 2);
655        Ok(())
656    }
657}