1use std::collections::{HashMap, HashSet};
9
10use oino_ai::transcript::SystemState;
11use oino_ai::{
12 AgentMessage, AssistantContent, SystemMessage, TextContent, UserContent, UserContentField,
13};
14use serde::{Deserialize, Serialize};
15
16use crate::entry::{ContextReplacement, EditableContent, EntryId, EntryKind, SessionEntry};
17use crate::messages::{branch_summary_message, compaction_summary_message, custom_message};
18
19#[derive(Clone, Debug, Default, PartialEq)]
21pub struct EntryIndex {
22 entries: Vec<SessionEntry>,
23 by_id: HashMap<EntryId, usize>,
24}
25
26impl EntryIndex {
27 #[must_use]
29 pub fn new(entries: Vec<SessionEntry>) -> Self {
30 let by_id = entries
31 .iter()
32 .enumerate()
33 .map(|(index, entry)| (entry.id.clone(), index))
34 .collect();
35 Self { entries, by_id }
36 }
37
38 pub fn push(&mut self, entry: SessionEntry) {
40 self.by_id.insert(entry.id.clone(), self.entries.len());
41 self.entries.push(entry);
42 }
43
44 #[must_use]
46 pub fn get(&self, id: &EntryId) -> Option<&SessionEntry> {
47 self.by_id.get(id).map(|&index| &self.entries[index])
48 }
49
50 #[must_use]
52 pub fn contains(&self, id: &EntryId) -> bool {
53 self.by_id.contains_key(id)
54 }
55
56 #[must_use]
58 pub fn contains_str(&self, id: &str) -> bool {
59 self.by_id.contains_key(&EntryId::new(id))
60 }
61
62 #[must_use]
64 pub fn entries(&self) -> &[SessionEntry] {
65 &self.entries
66 }
67
68 #[must_use]
70 pub fn into_entries(self) -> Vec<SessionEntry> {
71 self.entries
72 }
73
74 #[must_use]
76 pub fn id_count(&self) -> usize {
77 self.by_id.len()
78 }
79
80 #[must_use]
82 pub fn len(&self) -> usize {
83 self.entries.len()
84 }
85
86 #[must_use]
88 pub fn is_empty(&self) -> bool {
89 self.entries.is_empty()
90 }
91
92 #[must_use]
94 pub fn last_id(&self) -> Option<&EntryId> {
95 self.entries.last().map(|entry| &entry.id)
96 }
97}
98
99impl FromIterator<SessionEntry> for EntryIndex {
100 fn from_iter<I: IntoIterator<Item = SessionEntry>>(iter: I) -> Self {
101 Self::new(iter.into_iter().collect())
102 }
103}
104
105#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "camelCase")]
108pub struct ModelRef {
109 pub provider: String,
111 pub model_id: String,
113}
114
115#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase")]
118pub struct ProjectedEntry {
119 pub source_entry: SessionEntry,
121 pub messages: Vec<AgentMessage>,
123}
124
125#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct SessionProjection {
129 pub entries: Vec<ProjectedEntry>,
131 pub messages: Vec<AgentMessage>,
133 pub thinking_level: String,
135 pub model: Option<ModelRef>,
137}
138
139#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct SessionContext {
143 pub messages: Vec<AgentMessage>,
145 pub thinking_level: String,
147 pub model: Option<ModelRef>,
149}
150
151#[derive(Clone, Debug, Default, PartialEq, Eq)]
153pub struct BranchSelection {
154 pub model: Option<ModelRef>,
156 pub model_entry_id: Option<EntryId>,
158 pub thinking_level: Option<String>,
160}
161
162#[must_use]
165pub fn path_to<'a>(entries: &'a EntryIndex, leaf: Option<&EntryId>) -> Vec<&'a SessionEntry> {
166 let Some(leaf) = leaf else {
167 return Vec::new();
168 };
169 let Some(start) = entries.get(leaf).or_else(|| entries.entries.last()) else {
170 return Vec::new();
171 };
172 walk_up(entries, start)
173}
174
175pub(crate) fn walk_up<'a>(
176 entries: &'a EntryIndex,
177 start: &'a SessionEntry,
178) -> Vec<&'a SessionEntry> {
179 let mut path = Vec::new();
180 let mut seen = HashSet::new();
181 let mut current = Some(start);
182 while let Some(entry) = current {
183 if !seen.insert(&entry.id) {
184 break;
185 }
186 path.push(entry);
187 current = entry
188 .parent_id
189 .as_ref()
190 .and_then(|parent| entries.get(parent));
191 }
192 path.reverse();
193 path
194}
195
196#[must_use]
198pub fn latest_compaction<'a>(path: &[&'a SessionEntry]) -> Option<&'a SessionEntry> {
199 path.iter()
200 .rev()
201 .find(|entry| matches!(entry.kind, EntryKind::Compaction(_)))
202 .copied()
203}
204
205#[must_use]
207pub fn branch_selection(path: &[&SessionEntry]) -> BranchSelection {
208 let mut selection = BranchSelection::default();
209 for entry in path {
210 match &entry.kind {
211 EntryKind::ModelChange { provider, model_id } => {
212 selection.model = Some(ModelRef {
213 provider: provider.clone(),
214 model_id: model_id.clone(),
215 });
216 selection.model_entry_id = Some(entry.id.clone());
217 }
218 EntryKind::ThinkingLevelChange { thinking_level } => {
219 selection.thinking_level = Some(thinking_level.clone());
220 }
221 _ => {}
222 }
223 }
224 selection
225}
226
227fn context_settings(path: &[&SessionEntry]) -> (String, Option<ModelRef>) {
230 let mut thinking_level = "off".to_owned();
231 let mut model = None;
232 for entry in path {
233 match &entry.kind {
234 EntryKind::ThinkingLevelChange {
235 thinking_level: level,
236 } => {
237 thinking_level.clone_from(level);
238 }
239 EntryKind::ModelChange { provider, model_id } => {
240 model = Some(ModelRef {
241 provider: provider.clone(),
242 model_id: model_id.clone(),
243 });
244 }
245 EntryKind::Message {
246 message: AgentMessage::Assistant(assistant),
247 } => {
248 model = Some(ModelRef {
249 provider: assistant.provider.clone(),
250 model_id: assistant.model.clone(),
251 });
252 }
253 _ => {}
254 }
255 }
256 (thinking_level, model)
257}
258
259#[must_use]
261pub fn build_context_entries(entries: &EntryIndex, leaf: Option<&EntryId>) -> Vec<SessionEntry> {
262 select_context(&path_to(entries, leaf))
263 .into_iter()
264 .cloned()
265 .collect()
266}
267
268fn select_context<'a>(path: &[&'a SessionEntry]) -> Vec<&'a SessionEntry> {
269 let Some(compaction_index) = path
270 .iter()
271 .rposition(|entry| matches!(entry.kind, EntryKind::Compaction(_)))
272 else {
273 return path.to_vec();
274 };
275 let compaction = path[compaction_index];
276 let EntryKind::Compaction(data) = &compaction.kind else {
277 return path.to_vec();
278 };
279 let mut selected = vec![compaction];
280 let mut found_first_kept = false;
281 for entry in &path[..compaction_index] {
282 if entry.id == data.first_kept_entry_id {
283 found_first_kept = true;
284 }
285 if found_first_kept && !entry.is_system_message() {
286 selected.push(entry);
287 }
288 }
289 selected.extend_from_slice(&path[compaction_index + 1..]);
290 selected
291}
292
293#[must_use]
296pub fn entry_to_context_messages(
297 entry: &SessionEntry,
298 compaction_system: Option<&SystemMessage>,
299) -> Vec<AgentMessage> {
300 let timestamp = entry.timestamp.to_ms().unwrap_or(0);
301 match &entry.kind {
302 EntryKind::Message { message } => vec![message.clone()],
303 EntryKind::CustomMessage {
304 custom_type,
305 content,
306 display,
307 details,
308 } => vec![custom_message(
309 custom_type,
310 content.clone(),
311 *display,
312 details.clone(),
313 timestamp,
314 )],
315 EntryKind::BranchSummary(summary) if !summary.summary.is_empty() => {
316 vec![branch_summary_message(
317 &summary.summary,
318 Some(summary.from_id.as_str()),
319 timestamp,
320 )]
321 }
322 EntryKind::Compaction(compaction) => {
323 let system = compaction.system_message.as_ref().or(compaction_system);
324 system
325 .cloned()
326 .map(AgentMessage::System)
327 .into_iter()
328 .chain(std::iter::once(compaction_summary_message(
329 &compaction.summary,
330 compaction.tokens_before,
331 timestamp,
332 )))
333 .collect()
334 }
335 _ => Vec::new(),
336 }
337}
338
339fn user_blocks(content: &EditableContent) -> UserContentField {
340 match content {
341 EditableContent::Text(text) => UserContentField::Text(text.clone()),
342 EditableContent::User(blocks) => UserContentField::Blocks(blocks.clone()),
343 EditableContent::Assistant(blocks) => UserContentField::Blocks(
344 blocks
345 .iter()
346 .filter_map(|block| match block {
347 AssistantContent::Text(text) => Some(UserContent::Text(text.clone())),
348 _ => None,
349 })
350 .collect(),
351 ),
352 }
353}
354
355fn result_blocks(content: &EditableContent) -> Vec<UserContent> {
356 match user_blocks(content) {
357 UserContentField::Text(text) => vec![UserContent::Text(TextContent::new(text))],
358 UserContentField::Blocks(blocks) => blocks,
359 }
360}
361
362fn assistant_blocks(content: &EditableContent) -> Vec<AssistantContent> {
363 match content {
364 EditableContent::Text(text) => vec![AssistantContent::Text(TextContent::new(text.clone()))],
365 EditableContent::Assistant(blocks) => blocks.clone(),
366 EditableContent::User(blocks) => blocks
367 .iter()
368 .filter_map(|block| match block {
369 UserContent::Text(text) => Some(AssistantContent::Text(text.clone())),
370 UserContent::Image(_) => None,
371 })
372 .collect(),
373 }
374}
375
376fn replace_content(message: AgentMessage, replacement: &ContextReplacement) -> AgentMessage {
380 let content = &replacement.content;
381 match message {
382 AgentMessage::User(mut user) => {
383 user.content = user_blocks(content);
384 AgentMessage::User(user)
385 }
386 AgentMessage::Custom(mut custom) => {
387 custom.content = user_blocks(content);
388 AgentMessage::Custom(custom)
389 }
390 AgentMessage::Assistant(mut assistant) => {
391 assistant.content = assistant_blocks(content);
392 AgentMessage::Assistant(assistant)
393 }
394 AgentMessage::ToolResult(mut result) => {
395 result.content = result_blocks(content);
396 AgentMessage::ToolResult(result)
397 }
398 other => other,
399 }
400}
401
402fn project_entry(
403 entry: &SessionEntry,
404 edit: Option<&Option<ContextReplacement>>,
405) -> Vec<AgentMessage> {
406 let messages = entry_to_context_messages(entry, None);
407 match edit {
408 None => messages,
409 Some(None) => Vec::new(),
410 Some(Some(replacement)) => messages
411 .into_iter()
412 .map(|message| replace_content(message, replacement))
413 .collect(),
414 }
415}
416
417#[must_use]
420pub fn build_session_projection(entries: &EntryIndex, leaf: Option<&EntryId>) -> SessionProjection {
421 let path = path_to(entries, leaf);
422 let (thinking_level, model) = context_settings(&path);
423 let selected = select_context(&path);
424 let mut edits: HashMap<&EntryId, &Option<ContextReplacement>> = HashMap::new();
425 for entry in &selected {
426 if let EntryKind::ContextEdit {
427 target_id,
428 replacement,
429 } = &entry.kind
430 {
431 edits.insert(target_id, replacement);
432 }
433 }
434 let projected: Vec<ProjectedEntry> = selected
435 .iter()
436 .enumerate()
437 .map(|(index, entry)| ProjectedEntry {
438 source_entry: (*entry).clone(),
439 messages: if index > 0 && matches!(entry.kind, EntryKind::Compaction(_)) {
440 Vec::new()
441 } else {
442 project_entry(entry, edits.get(&entry.id).copied())
443 },
444 })
445 .collect();
446 let messages = projected
447 .iter()
448 .flat_map(|entry| entry.messages.iter().cloned())
449 .collect();
450 SessionProjection {
451 entries: projected,
452 messages,
453 thinking_level,
454 model,
455 }
456}
457
458#[must_use]
460pub fn build_session_context(entries: &EntryIndex, leaf: Option<&EntryId>) -> SessionContext {
461 let SessionProjection {
462 messages,
463 thinking_level,
464 model,
465 ..
466 } = build_session_projection(entries, leaf);
467 SessionContext {
468 messages,
469 thinking_level,
470 model,
471 }
472}
473
474#[must_use]
476pub fn current_system_message(messages: &[AgentMessage]) -> Option<SystemMessage> {
477 SystemState::fold(messages.iter().filter_map(AgentMessage::as_system)).into_message()
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use serde_json::json;
484
485 fn index(lines: &serde_json::Value) -> EntryIndex {
486 serde_json::from_value::<Vec<SessionEntry>>(lines.clone())
487 .unwrap()
488 .into_iter()
489 .collect()
490 }
491
492 fn user(id: &str, parent: Option<&str>, text: &str) -> serde_json::Value {
493 json!({"type":"message","id":id,"parentId":parent,"timestamp":"2024-01-01T00:00:00.000Z",
494 "message":{"role":"user","content":text,"timestamp":1}})
495 }
496
497 fn texts(context: &SessionContext) -> Vec<String> {
498 context
499 .messages
500 .iter()
501 .map(|m| match m {
502 AgentMessage::User(u) => u.content.text(""),
503 other => other.role().to_owned(),
504 })
505 .collect()
506 }
507
508 #[test]
509 fn compaction_keeps_range_and_drops_system() {
510 let entries = index(&json!([
511 {"type":"message","id":"s","parentId":null,"timestamp":"2024-01-01T00:00:00.000Z",
512 "message":{"role":"system","content":"sys","timestamp":0}},
513 user("a", Some("s"), "a"),
514 user("b", Some("a"), "b"),
515 {"type":"message","id":"s2","parentId":"b","timestamp":"2024-01-01T00:00:00.000Z",
516 "message":{"role":"system","content":"patch","timestamp":0}},
517 {"type":"compaction","id":"c","parentId":"s2","timestamp":"2024-01-01T00:00:01.000Z",
518 "summary":"sum","firstKeptEntryId":"b","tokensBefore":5,
519 "systemMessage":{"role":"system","content":"sys\n\npatch","timestamp":1000}},
520 user("d", Some("c"), "d"),
521 ]));
522 let context = build_session_context(&entries, entries.last_id());
523 assert_eq!(texts(&context), ["system", "compactionSummary", "b", "d"]);
524 let selected: Vec<_> = build_context_entries(&entries, Some(&"d".into()))
525 .into_iter()
526 .map(|e| e.id.to_string())
527 .collect();
528 assert_eq!(selected, ["c", "b", "d"]);
529 assert_eq!(build_session_context(&entries, None).messages, []);
530 }
531
532 #[test]
533 fn context_edits_are_branch_relative() {
534 let entries = index(&json!([
535 user("a", None, "a"),
536 {"type":"context_edit","id":"e","parentId":"a","timestamp":"2024-01-01T00:00:00.000Z","targetId":"a",
537 "replacement":{"content":"edited"}},
538 {"type":"context_edit","id":"f","parentId":"e","timestamp":"2024-01-01T00:00:00.000Z","targetId":"a","replacement":null},
539 ]));
540 assert_eq!(
541 texts(&build_session_context(&entries, Some(&"e".into()))),
542 ["edited"]
543 );
544 assert_eq!(
545 build_session_context(&entries, Some(&"f".into())).messages,
546 []
547 );
548 assert_eq!(
549 texts(&build_session_context(&entries, Some(&"a".into()))),
550 ["a"]
551 );
552 }
553
554 #[test]
555 fn settings_come_from_full_path() {
556 let entries = index(&json!([
557 {"type":"model_change","id":"m","parentId":null,"timestamp":"t","provider":"p","modelId":"x"},
558 {"type":"thinking_level_change","id":"t","parentId":"m","timestamp":"t","thinkingLevel":"high"},
559 ]));
560 let context = build_session_context(&entries, entries.last_id());
561 assert_eq!(context.thinking_level, "high");
562 assert_eq!(context.model.map(|m| m.model_id), Some("x".to_owned()));
563 let path = path_to(&entries, entries.last_id());
564 assert_eq!(branch_selection(&path).model_entry_id, Some("m".into()));
565 }
566
567 #[test]
568 fn cycles_terminate() {
569 let entries = index(&json!([
570 user("a", Some("b"), "a"),
571 user("b", Some("a"), "b")
572 ]));
573 assert_eq!(path_to(&entries, Some(&"b".into())).len(), 2);
574 }
575}