1use 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
22pub const DEFAULT_THINKING_LEVEL: &str = "off";
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub enum LeafRef<'a> {
33 Last,
35 Null,
37 Id(&'a str),
39}
40
41#[derive(Clone, Debug, Eq, PartialEq)]
43pub struct SessionModel {
44 pub provider: String,
46 pub model_id: String,
48}
49
50#[derive(Clone, Debug, PartialEq)]
52pub struct SessionContext {
53 pub messages: Vec<AgentMessage>,
55 pub thinking_level: String,
57 pub model: Option<SessionModel>,
59}
60
61#[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#[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
153pub 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
177pub 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#[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
226fn 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#[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}