1use std::{
4 collections::{BTreeMap, HashSet},
5 fs::{File, OpenOptions},
6 io::{BufRead, BufReader, Write},
7 path::{Path, PathBuf},
8 sync::{Arc, Mutex, MutexGuard},
9};
10
11use anyhow::Context as _;
12use kcode_chatend::SessionHistoryIntegration;
13pub use kcode_session_control_state::SessionRecord;
14use kcode_session_log::Role;
15pub use kcode_session_log::SessionLog;
16use serde::{Deserialize, Serialize};
17use serde_json::{Value, json};
18
19#[derive(Clone, Debug, Deserialize, Serialize)]
21pub struct RecordCompletion {
22 pub session_object_id: String,
23 #[serde(default)]
24 pub commit_receipt: Option<CompletionReceipt>,
25 #[serde(default)]
26 pub session_id: Option<String>,
27 #[serde(default)]
28 pub session_type: Option<String>,
29 #[serde(default)]
30 pub created_at: Option<String>,
31}
32
33#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
35#[serde(rename_all = "camelCase")]
36pub struct CompletionReceipt {
37 #[serde(default)]
38 pub transaction_id: Option<String>,
39 pub session_object_id: String,
40 #[serde(default)]
41 pub session_id: Option<String>,
42 #[serde(default)]
43 pub session_type: Option<String>,
44 #[serde(default)]
45 pub created_at: Option<String>,
46 #[serde(default)]
47 pub committed_at: Option<String>,
48 #[serde(default)]
49 pub ingress_source: Option<Value>,
50 #[serde(default)]
51 pub node_ids: BTreeMap<String, String>,
52 #[serde(default)]
53 pub object_ids: BTreeMap<String, String>,
54}
55
56#[derive(Clone, Debug, PartialEq)]
58pub struct RecordedCompletion {
59 pub receipt: CompletionReceipt,
60 pub appended: bool,
61}
62
63#[derive(Debug)]
65pub enum Error {
66 Conflict(String),
67 Storage(anyhow::Error),
68}
69
70impl std::fmt::Display for Error {
71 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 Self::Conflict(message) => formatter.write_str(message),
74 Self::Storage(error) => write!(formatter, "{error:#}"),
75 }
76 }
77}
78
79impl std::error::Error for Error {
80 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
81 match self {
82 Self::Conflict(_) => None,
83 Self::Storage(error) => Some(error.as_ref()),
84 }
85 }
86}
87
88impl From<anyhow::Error> for Error {
89 fn from(error: anyhow::Error) -> Self {
90 Self::Storage(error)
91 }
92}
93
94#[derive(Clone, Debug)]
96pub struct Catalog {
97 path: PathBuf,
98 mutation: Arc<Mutex<()>>,
99}
100
101impl Catalog {
102 pub fn open(path: PathBuf) -> Result<Self, Error> {
103 let parent = path
104 .parent()
105 .filter(|parent| !parent.as_os_str().is_empty())
106 .unwrap_or_else(|| Path::new("."));
107 create_private_directory(parent)?;
108 if !path.exists() {
109 let file = OpenOptions::new()
110 .create_new(true)
111 .write(true)
112 .open(&path)
113 .with_context(|| format!("creating {}", path.display()))?;
114 file.sync_all()
115 .with_context(|| format!("synchronizing {}", path.display()))?;
116 sync_directory(parent)?;
117 }
118 Ok(Self {
119 path,
120 mutation: Arc::new(Mutex::new(())),
121 })
122 }
123
124 pub fn receipts(&self) -> Result<Vec<CompletionReceipt>, Error> {
125 let _guard = self.lock()?;
126 read_completion_receipts(&self.path).map_err(Into::into)
127 }
128
129 pub fn record(
130 &self,
131 input: RecordCompletion,
132 committed_at: String,
133 ) -> Result<RecordedCompletion, Error> {
134 let _guard = self.lock()?;
135 let mut receipt = input.commit_receipt.unwrap_or(CompletionReceipt {
136 transaction_id: None,
137 session_object_id: input.session_object_id.clone(),
138 session_id: None,
139 session_type: None,
140 created_at: None,
141 committed_at: None,
142 ingress_source: None,
143 node_ids: BTreeMap::new(),
144 object_ids: BTreeMap::new(),
145 });
146 if receipt.session_object_id != input.session_object_id {
147 return Err(Error::Conflict(
148 "completion receipt and requested session object differ".into(),
149 ));
150 }
151 receipt.session_id = receipt.session_id.or(input.session_id);
152 receipt.session_type = receipt.session_type.or(input.session_type);
153 receipt.created_at = receipt.created_at.or(input.created_at);
154 receipt.committed_at.get_or_insert(committed_at);
155
156 if let Some(existing) = read_completion_receipts(&self.path)?
157 .into_iter()
158 .find(|existing| existing.session_object_id == receipt.session_object_id)
159 {
160 return Ok(RecordedCompletion {
161 receipt: existing,
162 appended: false,
163 });
164 }
165
166 let mut file = OpenOptions::new()
167 .append(true)
168 .open(&self.path)
169 .with_context(|| format!("opening {} for append", self.path.display()))?;
170 let encoded = serde_json::to_string(&receipt)
171 .context("encoding Session History completion receipt")?;
172 writeln!(file, "{encoded}")
173 .with_context(|| format!("appending completion receipt to {}", self.path.display()))?;
174 file.flush()
175 .with_context(|| format!("flushing {}", self.path.display()))?;
176 file.sync_data()
177 .with_context(|| format!("synchronizing {}", self.path.display()))?;
178 Ok(RecordedCompletion {
179 receipt,
180 appended: true,
181 })
182 }
183
184 fn lock(&self) -> Result<MutexGuard<'_, ()>, Error> {
185 self.mutation
186 .lock()
187 .map_err(|error| anyhow::anyhow!("completion catalog lock is poisoned: {error}"))
188 .map_err(Into::into)
189 }
190}
191
192fn read_completion_receipts(path: &Path) -> anyhow::Result<Vec<CompletionReceipt>> {
193 let file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
194 let mut receipts = Vec::new();
195 let mut seen = HashSet::new();
196 for (line_index, line) in BufReader::new(file).lines().enumerate() {
197 let line =
198 line.with_context(|| format!("reading completion receipt line {}", line_index + 1))?;
199 let line = line.trim();
200 if line.is_empty() {
201 continue;
202 }
203 let receipt = if line.starts_with('{') {
204 serde_json::from_str::<CompletionReceipt>(line).with_context(|| {
205 format!(
206 "decoding Session History completion receipt line {}",
207 line_index + 1
208 )
209 })?
210 } else {
211 CompletionReceipt {
212 transaction_id: None,
213 session_object_id: line.to_owned(),
214 session_id: None,
215 session_type: None,
216 created_at: None,
217 committed_at: None,
218 ingress_source: None,
219 node_ids: BTreeMap::new(),
220 object_ids: BTreeMap::new(),
221 }
222 };
223 if seen.insert(receipt.session_object_id.clone()) {
224 receipts.push(receipt);
225 }
226 }
227 Ok(receipts)
228}
229
230fn create_private_directory(path: &Path) -> anyhow::Result<()> {
231 if path.is_dir() {
232 return Ok(());
233 }
234 let mut builder = std::fs::DirBuilder::new();
235 builder.recursive(true);
236 #[cfg(unix)]
237 {
238 use std::os::unix::fs::DirBuilderExt as _;
239 builder.mode(0o700);
240 }
241 builder
242 .create(path)
243 .with_context(|| format!("creating {}", path.display()))?;
244 let parent = path
245 .parent()
246 .filter(|parent| !parent.as_os_str().is_empty())
247 .unwrap_or_else(|| Path::new("."));
248 sync_directory(parent)
249}
250
251fn sync_directory(path: &Path) -> anyhow::Result<()> {
252 File::open(path)
253 .with_context(|| format!("opening directory {} for sync", path.display()))?
254 .sync_all()
255 .with_context(|| format!("syncing directory {}", path.display()))
256}
257
258#[derive(Clone, Copy)]
260pub struct ProviderCostCompatibility {
261 pub session_model: fn(&Value) -> Option<String>,
262 pub estimator: kcode_chatend::ProviderCostEstimator,
263}
264
265impl std::fmt::Debug for ProviderCostCompatibility {
266 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267 formatter
268 .debug_struct("ProviderCostCompatibility")
269 .finish_non_exhaustive()
270 }
271}
272
273#[derive(Clone, Debug)]
275pub struct ReadModel {
276 pub provider_cost_compatibility: Option<ProviderCostCompatibility>,
277}
278
279impl ReadModel {
280 pub fn prepare_control_state(&self, state: &mut Value) {
281 retain_summary_fields(state);
282 }
283
284 pub fn active_summary(&self, mut record: SessionRecord) -> SessionRecord {
285 record.summary = true;
286 record.state = summary_state(&record.state);
287 record
288 }
289
290 pub fn active(&self, mut record: SessionRecord, log: &SessionLog) -> SessionRecord {
291 record.state["sessionId"] = json!(log.header.session_id);
292 if !record.state.get("transcript").is_some_and(Value::is_array) {
293 record.state["transcript"] = Value::Array(
294 log.events
295 .iter()
296 .enumerate()
297 .filter_map(|(position, event)| transcript_entry(position, event))
298 .collect(),
299 );
300 }
301 if !record.state.get("events").is_some_and(Value::is_array) {
302 record.state["events"] = serde_json::to_value(&log.events).unwrap_or(Value::Null);
303 }
304 clear_chatend_projection(&mut record.state);
305
306 let context_state = record
307 .state
308 .get("historyIngress")
309 .filter(|state| state.get("chatendMetadata").is_some())
310 .unwrap_or(&record.state);
311 let default_provider_model = self
312 .provider_cost_compatibility
313 .and_then(|compatibility| (compatibility.session_model)(context_state))
314 .or_else(|| {
315 self.provider_cost_compatibility
316 .and_then(|compatibility| (compatibility.session_model)(&record.state))
317 });
318 let exact_chatend = context_state
319 .get("chatendMetadata")
320 .cloned()
321 .and_then(|value| serde_json::from_value::<kcode_chatend::SessionMetadata>(value).ok())
322 .and_then(|metadata| match self.provider_cost_compatibility {
323 Some(compatibility) => SessionHistoryIntegration::replay(
324 metadata,
325 log,
326 default_provider_model.as_deref(),
327 Some(compatibility.estimator),
328 )
329 .ok(),
330 None => SessionHistoryIntegration::replay(metadata, log, None, None).ok(),
331 });
332 if let Some(chatend) = exact_chatend {
333 let boxes = serde_json::to_value(&chatend.boxes).unwrap_or(Value::Null);
334 let projection = chatend.projection();
335 let submitted = chatend.events.iter().rev().find_map(|event| {
336 let kcode_chatend::EventKind::ProviderInputSubmitted { round, context, .. } =
337 &event.kind
338 else {
339 return None;
340 };
341 Some((event.recorded_at.as_str(), *round, context))
342 });
343 let (chatend_text, chatend_text_source, structured_material) = match submitted {
344 Some((submitted_at, round, submitted)) => (
345 Value::String(submitted.input.clone()),
346 Value::String("submitted".into()),
347 json!({
348 "provider":submitted.provider,
349 "model":submitted.model,
350 "reasoningEffort":submitted.reasoning_effort,
351 "baseInstructions":submitted.base_instructions,
352 "developerInstructions":submitted.developer_instructions,
353 "tools":submitted.tools,
354 "round":round,
355 "submittedAt":submitted_at,
356 }),
357 ),
358 None => (
359 Value::String(projection.render()),
360 Value::String("reconstructed".into()),
361 Value::Null,
362 ),
363 };
364 let context = serde_json::to_value(projection).unwrap_or(Value::Null);
365 record.state["boxes"] = boxes.clone();
366 record.state["context"] = context.clone();
367 record.state["chatendText"] = chatend_text.clone();
368 record.state["chatendTextSource"] = chatend_text_source.clone();
369 record.state["structuredMaterial"] = structured_material.clone();
370 if let Some(ingress) = record
371 .state
372 .get_mut("historyIngress")
373 .and_then(Value::as_object_mut)
374 {
375 ingress.insert("boxes".into(), boxes);
376 ingress.insert("context".into(), context);
377 ingress.insert("chatendText".into(), chatend_text);
378 ingress.insert("chatendTextSource".into(), chatend_text_source);
379 ingress.insert("structuredMaterial".into(), structured_material);
380 }
381 }
382 record
383 }
384
385 pub fn completed(&self, receipt: CompletionReceipt, summary: bool) -> SessionRecord {
386 let object_id = receipt.session_object_id.clone();
387 let started_at = receipt.created_at.clone().unwrap_or_default();
388 let updated_at = receipt.committed_at.clone().unwrap_or_default();
389 SessionRecord {
390 id: object_id.clone(),
391 phase: "complete".into(),
392 started_at,
393 updated_at,
394 state: json!({
395 "sessionObjectId":object_id,
396 "sessionId":receipt.session_id.clone(),
397 "sessionType":receipt.session_type.clone(),
398 "ingressSource":receipt.ingress_source.clone(),
399 "commitReceipt":receipt,
400 }),
401 provenance_id: None,
402 version: 1,
403 last_user_message_at: None,
404 ended_at: None,
405 ingress_failure_count: 0,
406 ingress_failures: json!([]),
407 ingress_next_attempt_at: None,
408 summary,
409 }
410 }
411
412 pub fn legacy_provider_cost_summary_for_archive(
413 &self,
414 archive: &Value,
415 session_state: Option<&Value>,
416 ) -> anyhow::Result<Option<kcode_chatend::ProviderCostSummary>> {
417 let Some(compatibility) = self.provider_cost_compatibility else {
418 return Ok(None);
419 };
420 if is_metadata_free_session_log_archive(archive) {
421 return Ok(None);
422 }
423 let default_provider_model =
424 session_state.and_then(|state| (compatibility.session_model)(state));
425 SessionHistoryIntegration::legacy_provider_cost_summary_for_archive(
426 archive,
427 default_provider_model.as_deref(),
428 compatibility.estimator,
429 )
430 .map(Some)
431 }
432}
433
434fn clear_chatend_projection(state: &mut Value) {
435 const FIELDS: [&str; 5] = [
436 "boxes",
437 "context",
438 "chatendText",
439 "chatendTextSource",
440 "structuredMaterial",
441 ];
442 let Some(state) = state.as_object_mut() else {
443 return;
444 };
445 for field in FIELDS {
446 state.remove(field);
447 }
448 if let Some(ingress) = state
449 .get_mut("historyIngress")
450 .and_then(Value::as_object_mut)
451 {
452 for field in FIELDS {
453 ingress.remove(field);
454 }
455 }
456}
457
458fn retain_summary_fields(state: &mut Value) {
459 if state
460 .get("firstUserMessage")
461 .and_then(Value::as_str)
462 .is_some()
463 {
464 return;
465 }
466 let Some(first_user) = state
467 .get("transcript")
468 .and_then(Value::as_array)
469 .and_then(|transcript| {
470 transcript
471 .iter()
472 .find(|entry| entry.get("role").and_then(Value::as_str) == Some("user"))
473 })
474 .and_then(|entry| entry.get("content"))
475 .and_then(Value::as_str)
476 else {
477 return;
478 };
479 state["firstUserMessage"] = Value::String(first_user.chars().take(512).collect());
480}
481
482fn summary_state(control: &Value) -> Value {
483 json!({
484 "sessionType":control.get("sessionType"),
485 "channel":control.get("channel"),
486 "freeTime":control.get("freeTime"),
487 "orchestration":control.get("orchestration"),
488 "ingressSource":control.get("ingressSource"),
489 "firstUserMessage":control.get("firstUserMessage"),
490 "boxCount":control.get("boxCount"),
491 "eventCount":control.get("eventCount"),
492 "pendingTurn":control.get("pendingTurn").cloned().unwrap_or(Value::Bool(false)),
493 })
494}
495
496fn persisted_context_kind(event: &kcode_session_log::SessionEvent) -> Option<Value> {
497 serde_json::from_str::<Value>(&event.text)
498 .ok()?
499 .get("kind")
500 .cloned()
501}
502
503fn display_text(event: &kcode_session_log::SessionEvent) -> String {
504 persisted_context_kind(event)
505 .and_then(|kind| {
506 (kind.get("type").and_then(Value::as_str) == Some("box_created"))
507 .then(|| {
508 kind.get("content")
509 .and_then(|content| content.get("text"))
510 .and_then(Value::as_str)
511 .map(str::to_owned)
512 })
513 .flatten()
514 })
515 .unwrap_or_else(|| event.text.clone())
516}
517
518fn transcript_entry(position: usize, event: &kcode_session_log::SessionEvent) -> Option<Value> {
519 let kind = persisted_context_kind(event);
520 let box_content = kind
521 .as_ref()
522 .filter(|kind| kind.get("type").and_then(Value::as_str) == Some("box_created"))
523 .and_then(|kind| kind.get("content"));
524 let metadata = box_content
525 .and_then(|content| content.get("metadata"))
526 .filter(|value| value.is_object());
527 let role = match event.role {
528 Role::UserMessage => "user",
529 Role::KennedyMessage => "kennedy",
530 Role::SystemError => "system",
531 Role::SystemMessage => (box_content?
532 .get("metadata")
533 .and_then(|metadata| metadata.get("transcriptRole"))
534 .and_then(Value::as_str)
535 == Some("system"))
536 .then_some("system")?,
537 _ => return None,
538 };
539 let mut item = json!({
540 "role":role,
541 "content":display_text(event),
542 "boxId":position + 1,
543 });
544 if let Some(objects) = box_content
545 .and_then(|content| content.get("objects"))
546 .filter(|value| value.is_array())
547 {
548 item["objects"] = objects.clone();
549 }
550 if let Some(metadata) = metadata {
551 for key in ["inputKind", "externalEventId"] {
552 if let Some(value) = metadata.get(key) {
553 item[key] = value.clone();
554 }
555 }
556 if let Some(attachments) = metadata.get("attachments").filter(|value| value.is_array()) {
557 item["attachments"] = attachments.clone();
558 } else if let Some(media) = metadata.get("media").filter(|value| value.is_object()) {
559 item["attachments"] = json!([media]);
560 }
561 }
562 Some(item)
563}
564
565fn is_metadata_free_session_log_archive(archive: &Value) -> bool {
566 if archive.get("metadata").is_some() {
567 return false;
568 }
569 let Some(header) = archive.get("header") else {
570 return false;
571 };
572 if header.get("formatVersion").and_then(Value::as_str)
573 != Some(kcode_session_log::FORMAT_VERSION)
574 || !header
575 .get("sessionId")
576 .and_then(Value::as_str)
577 .is_some_and(|value| !value.trim().is_empty())
578 || !header
579 .get("createdAt")
580 .and_then(Value::as_str)
581 .is_some_and(|value| !value.trim().is_empty())
582 {
583 return false;
584 }
585 archive
586 .get("events")
587 .and_then(Value::as_array)
588 .is_some_and(|events| {
589 events.iter().all(|event| {
590 event.get("text").and_then(Value::as_str).is_some()
591 && event
592 .get("role")
593 .and_then(Value::as_str)
594 .is_some_and(|role| {
595 matches!(
596 role,
597 "system-message"
598 | "system-error"
599 | "user-message"
600 | "kennedy-message"
601 | "kennedy-tool-call"
602 | "tool-result"
603 | "tool-error"
604 | "object"
605 | "pending-object"
606 )
607 })
608 })
609 })
610}
611
612#[cfg(test)]
613mod tests {
614 use std::time::{SystemTime, UNIX_EPOCH};
615
616 use super::*;
617 use kcode_chatend::{
618 BoxContent, BoxOwner, CacheExpectation, EventKind, ProviderContext, SessionKind,
619 SessionMetadata,
620 };
621 use kcode_session_log::SessionStore;
622
623 fn root(label: &str) -> PathBuf {
624 std::env::temp_dir().join(format!(
625 "kennedy-session-history-catalog-{label}-{}-{}",
626 std::process::id(),
627 SystemTime::now()
628 .duration_since(UNIX_EPOCH)
629 .unwrap()
630 .as_nanos()
631 ))
632 }
633
634 fn receipt(id: &str, transaction: &str) -> CompletionReceipt {
635 CompletionReceipt {
636 transaction_id: Some(transaction.into()),
637 session_object_id: id.into(),
638 session_id: Some("session-42".into()),
639 session_type: Some("conversation".into()),
640 created_at: Some("2026-08-14T00:00:00Z".into()),
641 committed_at: None,
642 ingress_source: Some(json!({"idempotencyId":"ingress-1"})),
643 node_ids: BTreeMap::new(),
644 object_ids: BTreeMap::new(),
645 }
646 }
647
648 fn input(id: &str, receipt: Option<CompletionReceipt>) -> RecordCompletion {
649 RecordCompletion {
650 session_object_id: id.into(),
651 commit_receipt: receipt,
652 session_id: Some("fallback-session".into()),
653 session_type: Some("fallback-type".into()),
654 created_at: Some("fallback-created".into()),
655 }
656 }
657
658 fn session_record(state: Value) -> SessionRecord {
659 SessionRecord {
660 id: "session-42".into(),
661 phase: "active".into(),
662 started_at: "2026-08-14T00:00:00Z".into(),
663 updated_at: "2026-08-14T00:00:00Z".into(),
664 state,
665 provenance_id: None,
666 version: 1,
667 last_user_message_at: None,
668 ended_at: None,
669 ingress_failure_count: 0,
670 ingress_failures: json!([]),
671 ingress_next_attempt_at: None,
672 summary: false,
673 }
674 }
675
676 #[test]
677 fn catalog_preserves_legacy_first_writer_and_conflict_behavior() {
678 let root = root("compatibility");
679 std::fs::create_dir_all(&root).unwrap();
680 let path = root.join("completed.txt");
681 std::fs::write(&path, "legacy-object\n\n").unwrap();
682 let catalog = Catalog::open(path.clone()).unwrap();
683
684 let first = catalog
685 .record(
686 input("archive-1", Some(receipt("archive-1", "transaction-1"))),
687 "2026-08-14T01:00:00Z".into(),
688 )
689 .unwrap();
690 assert!(first.appended);
691 assert_eq!(
692 first.receipt.committed_at.as_deref(),
693 Some("2026-08-14T01:00:00Z")
694 );
695 let duplicate = catalog
696 .record(
697 input("archive-1", Some(receipt("archive-1", "replacement"))),
698 "later".into(),
699 )
700 .unwrap();
701 assert!(!duplicate.appended);
702 assert_eq!(
703 duplicate.receipt.transaction_id.as_deref(),
704 Some("transaction-1")
705 );
706
707 let receipts = catalog.receipts().unwrap();
708 assert_eq!(receipts.len(), 2);
709 assert_eq!(receipts[0].session_object_id, "legacy-object");
710 assert_eq!(receipts[1].session_object_id, "archive-1");
711 let persisted = std::fs::read_to_string(&path).unwrap();
712 assert!(persisted.contains("\"sessionObjectId\":\"archive-1\""));
713 assert!(!persisted.contains("replacement"));
714
715 let conflict = catalog
716 .record(
717 input("archive-2", Some(receipt("other-archive", "transaction-2"))),
718 "now".into(),
719 )
720 .unwrap_err();
721 assert!(matches!(conflict, Error::Conflict(_)));
722
723 std::fs::write(&path, "legacy-object\n{broken}\n").unwrap();
724 let malformed = catalog.receipts().unwrap_err().to_string();
725 assert!(malformed.contains("line 2"));
726 let _ = std::fs::remove_dir_all(root);
727 }
728
729 #[test]
730 fn cloned_catalog_serializes_duplicate_append() {
731 let root = root("synchronized");
732 let catalog = Catalog::open(root.join("nested/completed.txt")).unwrap();
733 let handles = (0..8)
734 .map(|index| {
735 let catalog = catalog.clone();
736 std::thread::spawn(move || {
737 catalog
738 .record(input("archive-1", None), format!("committed-{index}"))
739 .unwrap()
740 .appended
741 })
742 })
743 .collect::<Vec<_>>();
744 assert_eq!(
745 handles
746 .into_iter()
747 .map(|handle| handle.join().unwrap())
748 .filter(|appended| *appended)
749 .count(),
750 1
751 );
752 assert_eq!(catalog.receipts().unwrap().len(), 1);
753 let _ = std::fs::remove_dir_all(root);
754 }
755
756 #[test]
757 fn summaries_preserve_the_existing_unicode_bound() {
758 let model = ReadModel {
759 provider_cost_compatibility: None,
760 };
761 let mut state = json!({
762 "sessionType":"conversation",
763 "transcript":[{"role":"user","content":"é".repeat(600)}],
764 "boxCount":2,
765 "eventCount":3,
766 });
767 model.prepare_control_state(&mut state);
768 assert_eq!(
769 state["firstUserMessage"].as_str().unwrap().chars().count(),
770 512
771 );
772 let summary = model.active_summary(session_record(state));
773 assert!(summary.summary);
774 assert!(summary.state.get("transcript").is_none());
775 assert_eq!(summary.state["boxCount"], 2);
776 assert_eq!(summary.state["pendingTurn"], false);
777 }
778
779 #[test]
780 fn active_projection_reconstructs_chatend_and_discards_failed_replay_fields() {
781 let root = root("active");
782 std::fs::create_dir_all(&root).unwrap();
783 let metadata = SessionMetadata {
784 session_id: "session-42".into(),
785 kind: SessionKind::Conversation,
786 created_at: "2026-08-14T00:00:00Z".into(),
787 effective_context_tokens: 100_000,
788 channel: Value::Null,
789 };
790 let mut source = SessionHistoryIntegration::create_session(
791 root.join("session-42.session-log"),
792 metadata.clone(),
793 )
794 .unwrap();
795 source
796 .create_box(
797 "2026-08-14T00:00:01Z",
798 "User message",
799 BoxOwner::User,
800 BoxContent::text("hello from the log"),
801 )
802 .unwrap();
803 source
804 .record(
805 "2026-08-14T00:00:02Z",
806 EventKind::ProviderInputSubmitted {
807 round: 1,
808 context: ProviderContext {
809 input: "exact provider input".into(),
810 provider: "provider".into(),
811 model: "model".into(),
812 reasoning_effort: "medium".into(),
813 base_instructions: Some("base".into()),
814 developer_instructions: None,
815 tools: Vec::new(),
816 },
817 transport_input_hash: None,
818 transport_input_bytes: None,
819 thread_action: None,
820 thread_reset_reason: None,
821 cacheable_prefix_bytes: 0,
822 material_fingerprint: "fixture".into(),
823 cache_expectation: CacheExpectation::ColdStart.label().into(),
824 planned_invalidation_reason: None,
825 },
826 )
827 .unwrap();
828 drop(source);
829 let log = SessionStore::new(&root)
830 .open_session("session-42")
831 .unwrap()
832 .list();
833 let model = ReadModel {
834 provider_cost_compatibility: None,
835 };
836 let active = model.active(session_record(json!({"chatendMetadata":metadata})), &log);
837 assert_eq!(
838 active.state["transcript"][0]["content"],
839 "hello from the log"
840 );
841 assert!(active.state["events"].is_array());
842 assert!(active.state["boxes"].is_object());
843 assert_eq!(active.state["chatendText"], "exact provider input");
844 assert_eq!(active.state["chatendTextSource"], "submitted");
845 assert_eq!(active.state["structuredMaterial"]["round"], 1);
846
847 let mut bad_metadata = metadata;
848 bad_metadata.session_id = "different-session".into();
849 let failed = model.active(
850 session_record(json!({
851 "chatendMetadata":bad_metadata,
852 "boxes":{"stale":true},
853 "context":{"stale":true},
854 "chatendText":"stale",
855 "chatendTextSource":"stale",
856 "structuredMaterial":{"stale":true},
857 })),
858 &log,
859 );
860 for field in [
861 "boxes",
862 "context",
863 "chatendText",
864 "chatendTextSource",
865 "structuredMaterial",
866 ] {
867 assert!(failed.state.get(field).is_none());
868 }
869 let _ = std::fs::remove_dir_all(root);
870 }
871
872 fn no_session_model(_: &Value) -> Option<String> {
873 None
874 }
875
876 fn no_cost(
877 _: &str,
878 _: &kcode_chatend::ProviderMetering,
879 ) -> Option<kcode_chatend::ProviderCostEstimate> {
880 None
881 }
882
883 #[test]
884 fn completed_and_metadata_free_cost_compatibility_match_legacy_behavior() {
885 let model = ReadModel {
886 provider_cost_compatibility: Some(ProviderCostCompatibility {
887 session_model: no_session_model,
888 estimator: no_cost,
889 }),
890 };
891 let completed = model.completed(receipt("archive-1", "transaction-1"), false);
892 assert_eq!(completed.id, "archive-1");
893 assert_eq!(completed.phase, "complete");
894 assert_eq!(completed.state["sessionObjectId"], "archive-1");
895 assert_eq!(
896 completed.state["commitReceipt"]["transactionId"],
897 "transaction-1"
898 );
899
900 let archive = json!({
901 "header":{
902 "formatVersion":kcode_session_log::FORMAT_VERSION,
903 "sessionId":"session-42",
904 "createdAt":"2026-08-14T00:00:00Z"
905 },
906 "events":[{"role":"user-message","text":"hello"}]
907 });
908 assert!(
909 model
910 .legacy_provider_cost_summary_for_archive(&archive, None)
911 .unwrap()
912 .is_none()
913 );
914 }
915}