Skip to main content

kcode_session_history_catalog/
lib.rs

1//! Append-only completion receipts and Session History read projections.
2
3use 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/// Input used to persist one completed session receipt.
20#[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/// The durable, camel-case completion receipt stored in the catalog.
34#[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/// The effective first receipt and whether this call appended it.
57#[derive(Clone, Debug, PartialEq)]
58pub struct RecordedCompletion {
59    pub receipt: CompletionReceipt,
60    pub appended: bool,
61}
62
63/// Catalog identity conflicts and local storage failures.
64#[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/// A cheaply cloneable handle to one completion catalog.
95#[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/// Optional compatibility callbacks for legacy provider-cost projection.
259#[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/// Pure projections over caller-supplied control and transcript values.
274#[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}