Skip to main content

kcode_chatend/
lib.rs

1//! Kennedy's reconstructed, provider-independent durable session context.
2//!
3//! This module owns Kennedy's box model, context projection, context
4//! representations, token policy, and replay rules. Durable session history
5//! is owned by the separate `kcode-session-log` package.
6
7use std::{
8    collections::{BTreeMap, HashMap, HashSet},
9    path::Path,
10};
11
12use anyhow::{Context as _, ensure};
13pub use kcode_context_cache_policy::CacheExpectation;
14use kcode_context_cache_policy::{
15    MarkerObservation, MarkerState, PreviousProjection, StaleMarker, classify, decide_markers,
16};
17use kcode_session_log::{
18    EventPosition, Role, Session as DurableSession, SessionStore as DurableSessionStore,
19};
20use serde::{Deserialize, Serialize};
21use serde_json::{Value, json};
22
23pub const FORMAT_VERSION: u32 = 1;
24pub const MAX_OBJECT_BYTES: u64 = 32 * 1024 * 1024 * 1024;
25pub const ESTIMATED_BYTES_PER_TOKEN: u64 = 4;
26
27const HISTORY_INGRESS_ATTEMPT_RESET_NOTE: &str = "history_ingress_attempt_reset";
28const INGRESS_TIME_MARKER_NOTE: &str = "ingress_time_marker";
29
30/// Provider token dimensions retained by historical Chatend receipts.
31#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
32pub struct ProviderTokenUsage {
33    pub input_tokens: u64,
34    pub cached_input_tokens: u64,
35    pub thinking_tokens: u64,
36    pub output_tokens: u64,
37}
38
39/// Provider metering that can be reconstructed without changing durable history.
40#[derive(Clone, Copy, Debug, PartialEq)]
41pub enum ProviderMetering {
42    Tokens(ProviderTokenUsage),
43    DurationSeconds { seconds: f64 },
44    Unavailable,
45}
46
47/// One compatibility price returned by the application-owned provider catalog.
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct ProviderCostEstimate {
50    pub usd_nanos: u64,
51    pub accuracy: Value,
52    pub pricing_version: String,
53}
54
55/// Application callback used only for legacy receipts that did not store a cost.
56pub type ProviderCostEstimator = fn(&str, &ProviderMetering) -> Option<ProviderCostEstimate>;
57
58/// Read-time cost fields reconstructed from one immutable session archive.
59#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
60pub struct ProviderCostSummary {
61    pub estimated_cost_usd_nanos: u64,
62    pub unpriced_provider_calls: u64,
63}
64
65/// Replays an immutable session archive and returns only its compatible cost fields.
66///
67/// The input value is never modified. Callers may overlay the returned fields on a
68/// response projection without changing the archive's checksummed event stream.
69pub(crate) fn legacy_provider_cost_summary_for_archive(
70    archive: &Value,
71    default_provider_model: Option<&str>,
72    estimator: ProviderCostEstimator,
73) -> anyhow::Result<ProviderCostSummary> {
74    let metadata = archive
75        .get("metadata")
76        .cloned()
77        .context("session archive is missing Chatend metadata")?;
78    let metadata = serde_json::from_value(metadata).context("decoding session archive metadata")?;
79    let log =
80        serde_json::from_value(archive.clone()).context("decoding immutable session archive")?;
81    let chatend = Chatend::replay_with_provider_costs(
82        metadata,
83        &log,
84        default_provider_model,
85        Some(estimator),
86    )?;
87    let status = chatend.projection().status;
88    Ok(ProviderCostSummary {
89        estimated_cost_usd_nanos: status.estimated_cost_usd_nanos,
90        unpriced_provider_calls: status.unpriced_provider_calls,
91    })
92}
93
94#[derive(
95    Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize,
96)]
97#[serde(transparent)]
98pub struct EventId(pub u64);
99
100impl std::fmt::Display for EventId {
101    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        self.0.fmt(formatter)
103    }
104}
105
106#[derive(
107    Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize,
108)]
109#[serde(transparent)]
110pub struct BoxId(pub u64);
111
112impl std::fmt::Display for BoxId {
113    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        self.0.fmt(formatter)
115    }
116}
117
118#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
119#[serde(transparent)]
120pub struct PendingId(String);
121
122impl PendingId {
123    pub fn from_event(id: EventId) -> Self {
124        Self(format!("pending:{}", id.0))
125    }
126
127    pub fn parse(value: impl Into<String>) -> anyhow::Result<Self> {
128        let value = value.into();
129        let number = value
130            .strip_prefix("pending:")
131            .context("pending identity must begin with `pending:`")?
132            .parse::<u64>()
133            .context("pending identity must end in an unsigned integer")?;
134        ensure!(number > 0, "pending identity zero is reserved");
135        Ok(Self(value))
136    }
137
138    pub fn number(&self) -> u64 {
139        self.0["pending:".len()..]
140            .parse()
141            .expect("validated PendingId")
142    }
143}
144
145impl std::fmt::Display for PendingId {
146    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        formatter.write_str(&self.0)
148    }
149}
150
151#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153pub enum SessionKind {
154    Conversation,
155    Telegram,
156    TelegramGroup,
157    SelfTime,
158    AudioIngress,
159    HistoryIngress,
160    Other(String),
161}
162
163#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
164#[serde(rename_all = "camelCase")]
165pub struct SessionMetadata {
166    pub session_id: String,
167    pub kind: SessionKind,
168    pub created_at: String,
169    pub effective_context_tokens: u64,
170    #[serde(default)]
171    pub channel: Value,
172}
173
174#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
175#[serde(tag = "kind", rename_all = "snake_case")]
176pub enum BoxOwner {
177    User,
178    Kennedy,
179    Controller,
180    System,
181    Tool { tool_instance: String, slot: String },
182}
183
184#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase")]
186pub struct BoxContent {
187    #[serde(default)]
188    pub text: String,
189    #[serde(default)]
190    pub objects: Vec<String>,
191    #[serde(default)]
192    pub metadata: Value,
193}
194
195impl BoxContent {
196    pub fn text(value: impl Into<String>) -> Self {
197        Self {
198            text: value.into(),
199            ..Self::default()
200        }
201    }
202
203    /// Retained for source compatibility. All Chatend box headers now omit
204    /// the internal owner label.
205    pub fn use_concise_header(&mut self) {
206        if !self.metadata.is_object() {
207            self.metadata = json!({});
208        }
209        self.metadata["chatendConciseHeader"] = json!(true);
210    }
211
212    fn render(&self) -> String {
213        let mut rendered = self.text.clone();
214        for object in &self.objects {
215            if !rendered.is_empty() && !rendered.ends_with('\n') {
216                rendered.push('\n');
217            }
218            rendered.push_str("Object provided: ");
219            rendered.push_str(object);
220        }
221        rendered
222    }
223}
224
225#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
226#[serde(tag = "kind", rename_all = "snake_case")]
227pub enum Representation {
228    Hydrated { canonical_event: EventId },
229    Dehydrated { based_on: EventId },
230    Summarized { based_on: EventId, text: String },
231}
232
233#[derive(Clone, Debug, Eq, PartialEq)]
234pub enum BoxRepresentation {
235    Hydrated,
236    Dehydrated,
237    Summarized(String),
238}
239
240impl Representation {
241    fn based_on(&self) -> EventId {
242        match self {
243            Self::Hydrated { canonical_event } => *canonical_event,
244            Self::Dehydrated { based_on } | Self::Summarized { based_on, .. } => *based_on,
245        }
246    }
247}
248
249#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
250#[serde(rename_all = "camelCase")]
251pub struct CanonicalRevision {
252    pub event_id: EventId,
253    pub content: BoxContent,
254}
255
256#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
257#[serde(rename_all = "camelCase")]
258pub struct BoxState {
259    pub id: BoxId,
260    pub name: String,
261    pub owner: BoxOwner,
262    pub created_at: EventId,
263    pub canonical: CanonicalRevision,
264    pub representation: Representation,
265    pub occurrence_events: Vec<EventId>,
266    pub active: bool,
267}
268
269impl BoxState {
270    pub fn stale(&self) -> bool {
271        !matches!(self.representation, Representation::Hydrated { .. })
272            && self.representation.based_on() != self.canonical.event_id
273    }
274}
275
276#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
277#[serde(rename_all = "snake_case")]
278pub enum PendingKind {
279    Node,
280    Object,
281}
282
283#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
284#[serde(tag = "type", rename_all = "snake_case")]
285pub enum EventKind {
286    SessionConfigured {
287        effective_context_tokens: u64,
288        kind: SessionKind,
289    },
290    BoxCreated {
291        box_id: BoxId,
292        name: String,
293        owner: BoxOwner,
294        content: BoxContent,
295    },
296    CanonicalUpdated {
297        box_id: BoxId,
298        content: BoxContent,
299    },
300    BoxRenamed {
301        box_id: BoxId,
302        name: String,
303    },
304    BoxDehydrated {
305        box_id: BoxId,
306    },
307    BoxSummarized {
308        box_id: BoxId,
309        text: String,
310    },
311    BoxRehydrated {
312        box_id: BoxId,
313    },
314    BoxRetired {
315        box_id: BoxId,
316    },
317    PendingAllocated {
318        pending_id: PendingId,
319        resource: PendingKind,
320    },
321    ToolInvoked {
322        tool_instance: String,
323        tool_name: String,
324        arguments: Value,
325        #[serde(default, skip_serializing_if = "Option::is_none")]
326        invocation_id: Option<String>,
327    },
328    ToolCompleted {
329        tool_instance: String,
330        tool_name: String,
331        outcome: Value,
332        #[serde(default, skip_serializing_if = "Option::is_none")]
333        invocation_id: Option<String>,
334    },
335    ToolLayoutChanged {
336        tool_instance: String,
337        box_ids: Vec<BoxId>,
338    },
339    InferenceSubmitted {
340        manifest_hash: String,
341        estimated_input_tokens: u64,
342        #[serde(default)]
343        raw_estimated_input_tokens: Option<u64>,
344    },
345    ProviderInputSubmitted {
346        round: u64,
347        context: ProviderContext,
348        #[serde(default, skip_serializing_if = "Option::is_none")]
349        transport_input_hash: Option<String>,
350        #[serde(default, skip_serializing_if = "Option::is_none")]
351        transport_input_bytes: Option<u64>,
352        #[serde(default, skip_serializing_if = "Option::is_none")]
353        thread_action: Option<String>,
354        #[serde(default, skip_serializing_if = "Option::is_none")]
355        thread_reset_reason: Option<String>,
356        cacheable_prefix_bytes: u64,
357        material_fingerprint: String,
358        cache_expectation: String,
359        #[serde(default, skip_serializing_if = "Option::is_none")]
360        planned_invalidation_reason: Option<String>,
361    },
362    ProjectionMarkersReset,
363    StaleBoxesMarked {
364        box_ids: Vec<BoxId>,
365        consolidated: bool,
366    },
367    ContextSizeMarked {
368        estimated_tokens: u64,
369    },
370    ProviderReceipt {
371        manifest_hash: String,
372        input_tokens: Option<u64>,
373        output_tokens: Option<u64>,
374        /// Exact UTF-8 byte length of Chatend's rendered context when this
375        /// provider input-token measurement arrived.
376        #[serde(default)]
377        context_bytes: Option<u64>,
378        /// Legacy uncalibrated token anchor retained for archive replay.
379        #[serde(default)]
380        raw_context_tokens: Option<u64>,
381        provider_data: Value,
382    },
383    CapacityError {
384        attempted_operation: String,
385        projected_tokens: u64,
386        limit_tokens: u64,
387    },
388    SourceTerminated {
389        reason: String,
390    },
391    HistoryIngressStarted,
392    HistoryEventInspected {
393        source_event: EventId,
394    },
395    HistoryEventReleased {
396        source_event: EventId,
397    },
398    KwebPlanChanged {
399        operation: Value,
400    },
401    KwebCommitted {
402        transaction_id: String,
403        session_object_id: String,
404        mappings: Value,
405    },
406    SessionCompleted {
407        session_object_id: String,
408    },
409    Note {
410        label: String,
411        value: Value,
412    },
413}
414
415#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
416#[serde(rename_all = "camelCase")]
417pub struct Event {
418    pub id: EventId,
419    pub recorded_at: String,
420    pub kind: EventKind,
421}
422
423#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
424#[serde(rename_all = "camelCase")]
425pub struct Transition {
426    pub recorded_at: String,
427    pub events: Vec<Event>,
428}
429
430#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
431#[serde(rename_all = "camelCase")]
432struct PersistedContextEvent {
433    context_event_version: u32,
434    recorded_at: String,
435    kind: EventKind,
436}
437
438#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
439#[serde(rename_all = "camelCase")]
440struct PersistedContextEventWire {
441    context_event_version: u32,
442    recorded_at: String,
443    kind: Value,
444}
445
446#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
447#[serde(rename_all = "camelCase")]
448pub struct ObjectMetadata {
449    pub pending_id: PendingId,
450    pub event_id: EventId,
451    pub recorded_at: String,
452    pub media_type: String,
453    pub file_name: Option<String>,
454    #[serde(default)]
455    pub transport: Value,
456}
457
458#[derive(Clone, Debug, Eq, PartialEq)]
459pub struct ObjectLocation {
460    pub metadata: ObjectMetadata,
461    pub payload_offset: u64,
462    pub payload_len: u64,
463}
464
465#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
466#[serde(rename_all = "camelCase")]
467pub struct ToolSlot {
468    pub slot: String,
469    pub box_id: BoxId,
470    pub retired: bool,
471}
472
473#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
474#[serde(rename_all = "camelCase")]
475pub struct ToolState {
476    pub slots: Vec<ToolSlot>,
477}
478
479/// One provider-visible dynamic tool contract retained with submitted context.
480#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
481#[serde(rename_all = "camelCase")]
482pub struct ProviderToolDefinition {
483    pub name: String,
484    pub description: String,
485    pub input_schema: Value,
486}
487
488/// Exact caller-controlled material submitted to one model inference.
489#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
490#[serde(rename_all = "camelCase")]
491pub struct ProviderContext {
492    pub input: String,
493    pub provider: String,
494    pub model: String,
495    pub reasoning_effort: String,
496    pub base_instructions: Option<String>,
497    pub developer_instructions: Option<String>,
498    pub tools: Vec<ProviderToolDefinition>,
499}
500
501/// Provider projection prepared after sparse marker reconciliation.
502#[derive(Clone, Debug, Eq, PartialEq)]
503pub struct PreparedProviderProjection {
504    pub projection: ContextProjection,
505    pub provider_input: String,
506    pub thread_reset_reason: Option<String>,
507    pub cacheable_prefix_bytes: u64,
508    pub expectation: CacheExpectation,
509}
510
511#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
512struct IngressTimeMarkerState {
513    any: bool,
514    at_or_below_thirty_minutes: bool,
515    at_or_below_ten_minutes: bool,
516}
517
518fn should_append_ingress_time_marker(
519    state: IngressTimeMarkerState,
520    remaining_seconds: u64,
521    context_size_marked: bool,
522) -> bool {
523    !state.any
524        || context_size_marked
525        || (remaining_seconds <= 10 * 60 && !state.at_or_below_ten_minutes)
526        || (remaining_seconds <= 20 * 60 && !state.at_or_below_thirty_minutes)
527}
528
529#[derive(Clone, Debug, Eq, PartialEq)]
530pub struct ToolSlotInput {
531    pub slot: String,
532    pub name: String,
533    pub content: BoxContent,
534    pub retired: bool,
535}
536
537#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
538#[serde(rename_all = "camelCase")]
539pub struct Chatend {
540    pub metadata: SessionMetadata,
541    pub next_id: u64,
542    pub events: Vec<Event>,
543    pub boxes: BTreeMap<BoxId, BoxState>,
544    pub pending: BTreeMap<PendingId, PendingKind>,
545    pub tools: BTreeMap<String, ToolState>,
546    #[serde(default)]
547    pub tool_layouts: BTreeMap<String, Vec<BoxId>>,
548    pub source_terminated: bool,
549    pub history_ingress_started: bool,
550    pub completed_session_object: Option<String>,
551}
552
553impl Chatend {
554    fn opened(metadata: SessionMetadata) -> Self {
555        Self {
556            metadata,
557            next_id: 1,
558            events: Vec::new(),
559            boxes: BTreeMap::new(),
560            pending: BTreeMap::new(),
561            tools: BTreeMap::new(),
562            tool_layouts: BTreeMap::new(),
563            source_terminated: false,
564            history_ingress_started: false,
565            completed_session_object: None,
566        }
567    }
568
569    fn restore_history_ingress_baseline(&mut self) -> anyhow::Result<()> {
570        let baseline_end = self
571            .events
572            .iter()
573            .position(|event| matches!(event.kind, EventKind::HistoryIngressStarted))
574            .context("history ingress attempt reset has no initial ingress boundary")?;
575        let retained_events = std::mem::take(&mut self.events);
576        let retained_next_id = self.next_id;
577        *self = Self::opened(self.metadata.clone());
578        for event in &retained_events[..=baseline_end] {
579            self.apply_transition(&Transition {
580                recorded_at: event.recorded_at.clone(),
581                events: vec![event.clone()],
582            })?;
583        }
584        self.events = retained_events;
585        self.next_id = retained_next_id;
586        Ok(())
587    }
588
589    fn current_ingress_attempt_start(&self) -> usize {
590        self.events
591            .iter()
592            .rposition(|event| {
593                matches!(
594                    &event.kind,
595                    EventKind::Note { label, .. }
596                        if label == HISTORY_INGRESS_ATTEMPT_RESET_NOTE
597                )
598            })
599            .map_or(0, |index| index + 1)
600    }
601
602    /// Returns events belonging to the current provider-visible ingress attempt.
603    pub fn current_ingress_attempt_events(&self) -> &[Event] {
604        &self.events[self.current_ingress_attempt_start()..]
605    }
606
607    pub(crate) fn replay(
608        metadata: SessionMetadata,
609        log: &kcode_session_log::SessionLog,
610    ) -> anyhow::Result<Self> {
611        Self::replay_with_provider_costs(metadata, log, None, None)
612    }
613
614    pub(crate) fn replay_with_provider_costs(
615        metadata: SessionMetadata,
616        log: &kcode_session_log::SessionLog,
617        default_provider_model: Option<&str>,
618        estimator: Option<ProviderCostEstimator>,
619    ) -> anyhow::Result<Self> {
620        ensure!(
621            metadata.session_id == log.header.session_id,
622            "session metadata and session-log identities differ"
623        );
624        ensure!(
625            metadata.created_at == log.header.created_at,
626            "session metadata and session-log creation times differ"
627        );
628        let mut chatend = Self::opened(metadata);
629        for (position, stored) in log.events.iter().enumerate() {
630            let persisted = decode_context_event(stored)?;
631            let id = EventId(position as u64 + 1);
632            let mut kind = persisted.kind;
633            normalize_derived_identity(&mut kind, id)?;
634            chatend.apply_transition(&Transition {
635                recorded_at: persisted.recorded_at.clone(),
636                events: vec![Event {
637                    id,
638                    recorded_at: persisted.recorded_at,
639                    kind,
640                }],
641            })?;
642        }
643        if let Some(estimator) = estimator {
644            chatend.apply_legacy_provider_costs(default_provider_model, estimator);
645        }
646        Ok(chatend)
647    }
648
649    fn apply_legacy_provider_costs(
650        &mut self,
651        default_provider_model: Option<&str>,
652        estimator: ProviderCostEstimator,
653    ) {
654        let mut active_tool_models = Vec::<ActiveToolModel>::new();
655        let mut subagent_model = None::<String>;
656        for event in &mut self.events {
657            match &mut event.kind {
658                EventKind::ToolInvoked {
659                    tool_instance,
660                    tool_name,
661                    arguments,
662                    invocation_id,
663                } => {
664                    let model = arguments
665                        .get("model")
666                        .and_then(Value::as_str)
667                        .filter(|model| !model.trim().is_empty())
668                        .map(normalize_requested_model);
669                    active_tool_models.push(ActiveToolModel {
670                        invocation_id: invocation_id.clone(),
671                        tool_instance: tool_instance.clone(),
672                        tool_name: tool_name.clone(),
673                        model,
674                    });
675                }
676                EventKind::ToolCompleted {
677                    tool_instance,
678                    tool_name,
679                    invocation_id,
680                    ..
681                } => {
682                    if let Some(index) = active_tool_models.iter().rposition(|active| {
683                        if let Some(invocation_id) = invocation_id {
684                            active.invocation_id.as_ref() == Some(invocation_id)
685                        } else {
686                            active.tool_instance == *tool_instance && active.tool_name == *tool_name
687                        }
688                    }) {
689                        active_tool_models.remove(index);
690                    }
691                    if tool_name == "RunSubagent" {
692                        subagent_model = None;
693                    }
694                }
695                EventKind::Note { label, value } if label == "subagent_started" => {
696                    subagent_model = value
697                        .get("providerModel")
698                        .or_else(|| value.get("provider_model"))
699                        .and_then(Value::as_str)
700                        .filter(|model| !model.trim().is_empty())
701                        .map(str::to_owned);
702                }
703                EventKind::ProviderReceipt { provider_data, .. }
704                    if provider_data
705                        .get("estimatedCostUsdNanos")
706                        .and_then(Value::as_u64)
707                        .is_none() =>
708                {
709                    let source = provider_data.get("source").and_then(Value::as_str);
710                    let model = provider_data
711                        .get("providerModel")
712                        .or_else(|| provider_data.get("provider_model"))
713                        .and_then(Value::as_str)
714                        .filter(|model| !model.trim().is_empty())
715                        .map(str::to_owned)
716                        .or_else(|| {
717                            (source == Some("subagent"))
718                                .then(|| subagent_model.clone())
719                                .flatten()
720                        })
721                        .or_else(|| {
722                            source
723                                .is_some()
724                                .then(|| {
725                                    active_tool_models
726                                        .iter()
727                                        .rev()
728                                        .find_map(|active| active.model.clone())
729                                })
730                                .flatten()
731                        })
732                        .or_else(|| {
733                            source
734                                .is_none()
735                                .then(|| default_provider_model.map(str::to_owned))
736                                .flatten()
737                        });
738                    let Some(model) = model else {
739                        continue;
740                    };
741                    let metering = provider_metering(provider_data);
742                    let Some(cost) = estimator(&model, &metering) else {
743                        continue;
744                    };
745                    let Some(data) = provider_data.as_object_mut() else {
746                        continue;
747                    };
748                    data.entry("providerModel")
749                        .or_insert_with(|| Value::String(model));
750                    data.insert("estimatedCostUsdNanos".into(), Value::from(cost.usd_nanos));
751                    data.insert("costAccuracy".into(), cost.accuracy);
752                    data.insert("pricingVersion".into(), Value::String(cost.pricing_version));
753                }
754                _ => {}
755            }
756        }
757    }
758
759    pub fn event(&self, id: EventId) -> Option<&Event> {
760        self.events.iter().find(|event| event.id == id)
761    }
762
763    pub fn box_state(&self, id: BoxId) -> Option<&BoxState> {
764        self.boxes.get(&id)
765    }
766
767    pub fn active_boxes(&self) -> impl Iterator<Item = &BoxState> {
768        self.boxes.values().filter(|state| state.active)
769    }
770
771    pub fn live_context_limit(&self) -> u64 {
772        self.metadata.effective_context_tokens.saturating_mul(70) / 100
773    }
774
775    pub fn forced_ingress_context_limit(&self) -> u64 {
776        self.metadata.effective_context_tokens.saturating_mul(75) / 100
777    }
778
779    pub fn ingress_initial_context_limit(&self) -> u64 {
780        self.metadata.effective_context_tokens.saturating_mul(75) / 100
781    }
782
783    pub fn ingress_context_limit(&self) -> u64 {
784        self.metadata.effective_context_tokens
785    }
786
787    pub fn active_context_limit(&self) -> u64 {
788        if matches!(
789            self.metadata.kind,
790            SessionKind::HistoryIngress | SessionKind::AudioIngress
791        ) {
792            self.ingress_context_limit()
793        } else {
794            self.live_context_limit()
795        }
796    }
797
798    pub fn projection_with_new_boxes(
799        &self,
800        boxes: &[(String, BoxOwner, BoxContent)],
801    ) -> anyhow::Result<ContextProjection> {
802        self.projection_with_new_boxes_at("preview", boxes)
803    }
804
805    pub fn projection_with_new_boxes_at(
806        &self,
807        recorded_at: &str,
808        boxes: &[(String, BoxOwner, BoxContent)],
809    ) -> anyhow::Result<ContextProjection> {
810        self.projection_with_new_boxes_and_updates_at(recorded_at, boxes, &BTreeMap::new())
811    }
812
813    pub fn projection_with_new_boxes_and_updates(
814        &self,
815        boxes: &[(String, BoxOwner, BoxContent)],
816        updates: &BTreeMap<BoxId, BoxContent>,
817    ) -> anyhow::Result<ContextProjection> {
818        self.projection_with_new_boxes_and_updates_at("preview", boxes, updates)
819    }
820
821    pub fn projection_with_new_boxes_and_updates_at(
822        &self,
823        recorded_at: &str,
824        boxes: &[(String, BoxOwner, BoxContent)],
825        updates: &BTreeMap<BoxId, BoxContent>,
826    ) -> anyhow::Result<ContextProjection> {
827        let mut preview = self.clone();
828        let mut next = preview.next_id;
829        let mut events = Vec::with_capacity(boxes.len() + updates.len());
830        for (name, owner, content) in boxes {
831            let id = EventId(next);
832            let box_id = BoxId(next);
833            next = next.checked_add(1).context("event identity overflow")?;
834            events.push(Event {
835                id,
836                recorded_at: recorded_at.into(),
837                kind: EventKind::BoxCreated {
838                    box_id,
839                    name: name.clone(),
840                    owner: owner.clone(),
841                    content: content.clone(),
842                },
843            });
844        }
845        for (box_id, content) in updates {
846            let state = preview
847                .box_state(*box_id)
848                .with_context(|| format!("box {box_id} does not exist"))?;
849            ensure!(state.active, "box {box_id} is retired");
850            if state.canonical.content == *content {
851                continue;
852            }
853            let id = EventId(next);
854            next = next.checked_add(1).context("event identity overflow")?;
855            events.push(Event {
856                id,
857                recorded_at: recorded_at.into(),
858                kind: EventKind::CanonicalUpdated {
859                    box_id: *box_id,
860                    content: content.clone(),
861                },
862            });
863        }
864        if !events.is_empty() {
865            preview.apply_transition(&Transition {
866                recorded_at: recorded_at.into(),
867                events,
868            })?;
869        }
870        Ok(preview.projection())
871    }
872
873    pub fn projection_with_box_representations(
874        &self,
875        desired: &BTreeMap<BoxId, BoxRepresentation>,
876    ) -> anyhow::Result<ContextProjection> {
877        let mut preview = self.clone();
878        let events = preview.box_representation_events("preview", desired)?;
879        if !events.is_empty() {
880            preview.apply_transition(&Transition {
881                recorded_at: "preview".into(),
882                events,
883            })?;
884        }
885        Ok(preview.projection())
886    }
887
888    fn box_representation_events(
889        &self,
890        recorded_at: &str,
891        desired: &BTreeMap<BoxId, BoxRepresentation>,
892    ) -> anyhow::Result<Vec<Event>> {
893        let mut next = self.next_id;
894        let mut events = Vec::new();
895        for (box_id, desired) in desired {
896            let state = self
897                .box_state(*box_id)
898                .with_context(|| format!("box {box_id} does not exist"))?;
899            ensure!(state.active, "box {box_id} is retired");
900            let kind = match (desired, &state.representation) {
901                (BoxRepresentation::Hydrated, Representation::Hydrated { .. })
902                | (BoxRepresentation::Dehydrated, Representation::Dehydrated { .. }) => None,
903                (
904                    BoxRepresentation::Summarized(desired),
905                    Representation::Summarized { text, .. },
906                ) if desired == text => None,
907                (BoxRepresentation::Hydrated, _) => {
908                    Some(EventKind::BoxRehydrated { box_id: *box_id })
909                }
910                (BoxRepresentation::Dehydrated, _) => {
911                    Some(EventKind::BoxDehydrated { box_id: *box_id })
912                }
913                (BoxRepresentation::Summarized(text), _) => Some(EventKind::BoxSummarized {
914                    box_id: *box_id,
915                    text: text.clone(),
916                }),
917            };
918            let Some(kind) = kind else {
919                continue;
920            };
921            events.push(Event {
922                id: EventId(next),
923                recorded_at: recorded_at.into(),
924                kind,
925            });
926            next = next.checked_add(1).context("event ID overflow")?;
927        }
928        Ok(events)
929    }
930
931    fn latest_provider_input(&self) -> Option<(EventId, &ProviderContext, u64, &str)> {
932        self.current_ingress_attempt_events()
933            .iter()
934            .rev()
935            .find_map(|event| {
936                let EventKind::ProviderInputSubmitted {
937                    context,
938                    cacheable_prefix_bytes,
939                    material_fingerprint,
940                    ..
941                } = &event.kind
942                else {
943                    return None;
944                };
945                Some((
946                    event.id,
947                    context,
948                    *cacheable_prefix_bytes,
949                    material_fingerprint.as_str(),
950                ))
951            })
952    }
953
954    fn ingress_time_marker_state(&self) -> IngressTimeMarkerState {
955        let mut state = IngressTimeMarkerState::default();
956        for event in self.current_ingress_attempt_events() {
957            let EventKind::Note { label, value } = &event.kind else {
958                continue;
959            };
960            if label != INGRESS_TIME_MARKER_NOTE {
961                continue;
962            }
963            let Some(remaining_seconds) = value.get("remainingSeconds").and_then(Value::as_u64)
964            else {
965                continue;
966            };
967            state.any = true;
968            state.at_or_below_thirty_minutes |= remaining_seconds <= 30 * 60;
969            state.at_or_below_ten_minutes |= remaining_seconds <= 10 * 60;
970        }
971        state
972    }
973
974    fn marker_state(&self) -> MarkerState {
975        let reset = self.events.iter().rposition(|event| {
976            matches!(event.kind, EventKind::ProjectionMarkersReset)
977                || matches!(
978                    &event.kind,
979                    EventKind::Note { label, .. }
980                        if label == HISTORY_INGRESS_ATTEMPT_RESET_NOTE
981                )
982        });
983        let mut state = MarkerState::default();
984        for event in &self.events[reset.unwrap_or(0)..] {
985            match &event.kind {
986                EventKind::StaleBoxesMarked {
987                    box_ids,
988                    consolidated,
989                } => {
990                    if *consolidated {
991                        state.reported_stale_boxes.clear();
992                    }
993                    state
994                        .reported_stale_boxes
995                        .extend(box_ids.iter().map(|id| id.0));
996                }
997                EventKind::ContextSizeMarked { estimated_tokens } => {
998                    state.last_context_size_tokens = Some(*estimated_tokens);
999                }
1000                _ => {}
1001            }
1002        }
1003        state
1004    }
1005
1006    fn projection_rewrite_reason_after(&self, event_id: Option<EventId>) -> String {
1007        let mut reasons = std::collections::BTreeSet::new();
1008        for event in self
1009            .events
1010            .iter()
1011            .filter(|event| event_id.is_none_or(|id| event.id > id))
1012        {
1013            let reason = match event.kind {
1014                EventKind::BoxDehydrated { .. } => Some("dehydration"),
1015                EventKind::BoxRehydrated { .. } => Some("rehydration"),
1016                EventKind::BoxSummarized { .. } => Some("summarization"),
1017                EventKind::CanonicalUpdated { .. }
1018                | EventKind::BoxRenamed { .. }
1019                | EventKind::BoxRetired { .. } => Some("canonical_state_replacement"),
1020                EventKind::ToolLayoutChanged { .. } => Some("projection_reordered"),
1021                EventKind::SessionConfigured { .. } => Some("context_limit_changed"),
1022                EventKind::Note { ref label, .. }
1023                    if label == HISTORY_INGRESS_ATTEMPT_RESET_NOTE =>
1024                {
1025                    Some("ingress_attempt_reset")
1026                }
1027                _ => None,
1028            };
1029            if let Some(reason) = reason {
1030                reasons.insert(reason);
1031            }
1032        }
1033        reasons.into_iter().collect::<Vec<_>>().join("+")
1034    }
1035
1036    pub fn projection(&self) -> ContextProjection {
1037        self.projection_with_footer_lines(&[])
1038    }
1039
1040    /// Projects provider-facing context with caller-owned transient footer lines.
1041    pub fn projection_with_footer_lines(&self, footer_lines: &[String]) -> ContextProjection {
1042        self.projection_at(footer_lines)
1043    }
1044
1045    fn projection_at(&self, footer_lines: &[String]) -> ContextProjection {
1046        let items = self.projection_items(false);
1047        let stale_boxes = self
1048            .active_boxes()
1049            .filter(|state| state.stale())
1050            .map(|state| state.id)
1051            .collect::<Vec<_>>();
1052        let footer = footer_lines.join("\n");
1053        let context_bytes = projected_context_bytes(&items, &footer);
1054        let raw_estimated_tokens = estimate_bytes(context_bytes);
1055        let estimated_tokens = self.calibrated_estimate(context_bytes, raw_estimated_tokens);
1056        let fully_hydrated_context_tokens = self.fully_hydrated_context_tokens_at(footer_lines);
1057        let usage = self.cumulative_token_usage();
1058        let cost = self.cumulative_cost();
1059        let status = SessionStatus {
1060            current_context_tokens: estimated_tokens,
1061            fully_hydrated_context_tokens,
1062            context_limit_tokens: self.active_context_limit(),
1063            current_context_bytes: context_bytes,
1064            cached_input_tokens: usage.cached_input_tokens,
1065            non_cached_input_tokens: usage.non_cached_input_tokens,
1066            thinking_tokens: usage.thinking_tokens,
1067            output_tokens: usage.output_tokens,
1068            estimated_cost_usd_nanos: cost.estimated_cost_usd_nanos,
1069            unpriced_provider_calls: cost.unpriced_provider_calls,
1070        };
1071        ContextProjection {
1072            items,
1073            stale_boxes,
1074            footer,
1075            estimated_tokens,
1076            raw_estimated_tokens,
1077            context_bytes,
1078            status,
1079        }
1080    }
1081
1082    fn projection_items(&self, fully_hydrated: bool) -> Vec<ProjectionItem> {
1083        let mut next_occurrence = HashMap::new();
1084        for state in self.boxes.values() {
1085            for pair in state.occurrence_events.windows(2) {
1086                next_occurrence.insert(pair[0], pair[1]);
1087            }
1088        }
1089        let latest_marker_reset = self.events.iter().rev().find_map(|event| {
1090            (matches!(event.kind, EventKind::ProjectionMarkersReset)
1091                || matches!(
1092                    &event.kind,
1093                    EventKind::Note { label, .. }
1094                        if label == HISTORY_INGRESS_ATTEMPT_RESET_NOTE
1095                ))
1096            .then_some(event.id)
1097        });
1098        let current_ingress_attempt_start = self.current_ingress_attempt_start();
1099        let mut items = Vec::new();
1100        for (index, event) in self.events.iter().enumerate() {
1101            if index >= current_ingress_attempt_start
1102                && let EventKind::Note { label, value } = &event.kind
1103                && label == INGRESS_TIME_MARKER_NOTE
1104                && let Some(text) = ingress_time_marker_text(value)
1105            {
1106                items.push(ProjectionItem::projection_marker(event.id, text));
1107                continue;
1108            }
1109            if latest_marker_reset.is_none_or(|reset| event.id >= reset) {
1110                match &event.kind {
1111                    EventKind::StaleBoxesMarked {
1112                        box_ids,
1113                        consolidated,
1114                    } => {
1115                        let marker = if *consolidated {
1116                            StaleMarker::Consolidated(box_ids.iter().map(|id| id.0).collect())
1117                        } else {
1118                            StaleMarker::New(box_ids.iter().map(|id| id.0).collect())
1119                        };
1120                        items.push(ProjectionItem::projection_marker(event.id, marker.render()));
1121                        continue;
1122                    }
1123                    EventKind::ContextSizeMarked { estimated_tokens } => {
1124                        items.push(ProjectionItem::projection_marker(
1125                            event.id,
1126                            context_size_marker_text(*estimated_tokens),
1127                        ));
1128                        continue;
1129                    }
1130                    _ => {}
1131                }
1132            }
1133            let Some(box_id) = event_box_id(&event.kind) else {
1134                continue;
1135            };
1136            let Some(state) = self.boxes.get(&box_id) else {
1137                continue;
1138            };
1139            if next_occurrence.contains_key(&event.id) {
1140                items.push(ProjectionItem::marker(
1141                    event.id,
1142                    box_id,
1143                    "[box updated]".into(),
1144                ));
1145                continue;
1146            }
1147            if !state.active || state.occurrence_events.last() != Some(&event.id) {
1148                continue;
1149            }
1150            let (representation, body) = if fully_hydrated {
1151                ("hydrated", state.canonical.content.render())
1152            } else {
1153                match &state.representation {
1154                    Representation::Hydrated { .. } => {
1155                        ("hydrated", state.canonical.content.render())
1156                    }
1157                    Representation::Dehydrated { .. } => (
1158                        "dehydrated",
1159                        format!(
1160                            "[contents dehydrated; hydrate box {} to inspect the latest canonical revision]",
1161                            box_id
1162                        ),
1163                    ),
1164                    Representation::Summarized { text, .. } => ("summarized", text.clone()),
1165                }
1166            };
1167            let stale = !fully_hydrated && state.stale();
1168            let mut header = vec![format!("box {box_id}"), state.name.clone()];
1169            if matches!(
1170                (&state.owner, state.name.as_str()),
1171                (BoxOwner::User, "User message") | (BoxOwner::Kennedy, "Kennedy message")
1172            ) {
1173                header.push(format!("timestamp={}", event.recorded_at));
1174            }
1175            header.push(representation.into());
1176            if stale {
1177                header.push("stale".into());
1178            }
1179            let mut text = format!("[{}]\n{}", header.join(" | "), body);
1180            if text.ends_with('\n') {
1181                text.pop();
1182            }
1183            items.push(ProjectionItem {
1184                event_id: event.id,
1185                box_id,
1186                marker: false,
1187                stale,
1188                approximate_tokens: estimate_tokens(&text),
1189                text,
1190            });
1191        }
1192        for box_ids in self.tool_layouts.values() {
1193            arrange_tool_projection(&mut items, box_ids);
1194        }
1195        items
1196    }
1197
1198    fn fully_hydrated_context_tokens_at(&self, footer_lines: &[String]) -> u64 {
1199        let items = self.projection_items(true);
1200        let footer = footer_lines.join("\n");
1201        let context_bytes = projected_context_bytes(&items, &footer);
1202        self.calibrated_estimate(context_bytes, estimate_bytes(context_bytes))
1203    }
1204
1205    fn calibrated_estimate(&self, current_bytes: u64, raw_current: u64) -> u64 {
1206        let Some((manifest_hash, measured, bytes_at_receipt, raw_at_receipt)) =
1207            self.events.iter().rev().find_map(|event| {
1208                let EventKind::ProviderReceipt {
1209                    manifest_hash,
1210                    input_tokens: Some(input_tokens),
1211                    context_bytes,
1212                    raw_context_tokens,
1213                    ..
1214                } = &event.kind
1215                else {
1216                    return None;
1217                };
1218                Some((
1219                    manifest_hash,
1220                    *input_tokens,
1221                    *context_bytes,
1222                    *raw_context_tokens,
1223                ))
1224            })
1225        else {
1226            return raw_current;
1227        };
1228        if let Some(bytes_at_receipt) = bytes_at_receipt {
1229            let token_delta = current_bytes.abs_diff(bytes_at_receipt) / ESTIMATED_BYTES_PER_TOKEN;
1230            return if current_bytes >= bytes_at_receipt {
1231                measured.saturating_add(token_delta)
1232            } else {
1233                measured.saturating_sub(token_delta)
1234            };
1235        }
1236        let raw_at_measurement = match raw_at_receipt {
1237            Some(raw) => raw,
1238            None => {
1239                let Some(raw) = self.events.iter().rev().find_map(|event| {
1240                    let EventKind::InferenceSubmitted {
1241                        manifest_hash: submitted,
1242                        estimated_input_tokens,
1243                        raw_estimated_input_tokens,
1244                    } = &event.kind
1245                    else {
1246                        return None;
1247                    };
1248                    (submitted == manifest_hash)
1249                        .then_some(raw_estimated_input_tokens.unwrap_or(*estimated_input_tokens))
1250                }) else {
1251                    return raw_current;
1252                };
1253                raw
1254            }
1255        };
1256        if raw_current >= raw_at_measurement {
1257            measured.saturating_add(raw_current - raw_at_measurement)
1258        } else {
1259            measured.saturating_sub(raw_at_measurement - raw_current)
1260        }
1261    }
1262
1263    fn cumulative_token_usage(&self) -> CumulativeTokenUsage {
1264        let mut total = CumulativeTokenUsage::default();
1265        for event in &self.events {
1266            let EventKind::ProviderReceipt { provider_data, .. } = &event.kind else {
1267                continue;
1268            };
1269            let cached = provider_u64(provider_data, &["cachedInputTokens", "cached_input_tokens"]);
1270            let thinking = provider_u64(
1271                provider_data,
1272                &[
1273                    "thinkingTokens",
1274                    "thinking_tokens",
1275                    "reasoningOutputTokens",
1276                    "reasoning_output_tokens",
1277                ],
1278            );
1279            let normalized_delta = provider_data
1280                .get("usageIsDelta")
1281                .and_then(Value::as_bool)
1282                .unwrap_or(false);
1283            let non_cached = if normalized_delta {
1284                provider_u64(
1285                    provider_data,
1286                    &["nonCachedInputTokens", "non_cached_input_tokens"],
1287                )
1288            } else {
1289                provider_u64(provider_data, &["inputTokens", "input_tokens"]).saturating_sub(cached)
1290            };
1291            let output = if normalized_delta {
1292                provider_u64(provider_data, &["outputTokens", "output_tokens"])
1293            } else {
1294                provider_u64(provider_data, &["outputTokens", "output_tokens"])
1295                    .saturating_sub(thinking)
1296            };
1297            total.cached_input_tokens = total.cached_input_tokens.saturating_add(cached);
1298            total.non_cached_input_tokens =
1299                total.non_cached_input_tokens.saturating_add(non_cached);
1300            total.thinking_tokens = total.thinking_tokens.saturating_add(thinking);
1301            total.output_tokens = total.output_tokens.saturating_add(output);
1302        }
1303        total
1304    }
1305
1306    fn cumulative_cost(&self) -> CumulativeCost {
1307        let mut total = CumulativeCost::default();
1308        for event in &self.events {
1309            let EventKind::ProviderReceipt { provider_data, .. } = &event.kind else {
1310                continue;
1311            };
1312            if let Some(cost) = provider_data
1313                .get("estimatedCostUsdNanos")
1314                .and_then(Value::as_u64)
1315            {
1316                total.estimated_cost_usd_nanos =
1317                    total.estimated_cost_usd_nanos.saturating_add(cost);
1318            } else {
1319                total.unpriced_provider_calls = total.unpriced_provider_calls.saturating_add(1);
1320            }
1321        }
1322        total
1323    }
1324
1325    pub fn render(&self) -> String {
1326        self.projection().render()
1327    }
1328
1329    fn apply_transition(&mut self, transition: &Transition) -> anyhow::Result<()> {
1330        ensure!(
1331            !transition.events.is_empty(),
1332            "a transition cannot be empty"
1333        );
1334        for event in &transition.events {
1335            self.apply_event(event)?;
1336        }
1337        Ok(())
1338    }
1339
1340    fn apply_event(&mut self, event: &Event) -> anyhow::Result<()> {
1341        ensure!(
1342            event.id.0 >= self.next_id,
1343            "event {} reuses an allocated identity (next is {})",
1344            event.id,
1345            self.next_id
1346        );
1347        self.next_id = event.id.0.checked_add(1).context("event ID overflow")?;
1348        match &event.kind {
1349            EventKind::SessionConfigured {
1350                effective_context_tokens,
1351                kind,
1352            } => {
1353                ensure!(
1354                    *effective_context_tokens > 0,
1355                    "effective context window must be positive"
1356                );
1357                self.metadata.effective_context_tokens = *effective_context_tokens;
1358                self.metadata.kind = kind.clone();
1359            }
1360            EventKind::BoxCreated {
1361                box_id,
1362                name,
1363                owner,
1364                content,
1365            } => {
1366                ensure!(
1367                    box_id.0 == event.id.0,
1368                    "BoxId must equal its creation EventId"
1369                );
1370                ensure!(
1371                    !self.boxes.contains_key(box_id),
1372                    "box {} already exists",
1373                    box_id
1374                );
1375                self.boxes.insert(
1376                    *box_id,
1377                    BoxState {
1378                        id: *box_id,
1379                        name: name.clone(),
1380                        owner: owner.clone(),
1381                        created_at: event.id,
1382                        canonical: CanonicalRevision {
1383                            event_id: event.id,
1384                            content: content.clone(),
1385                        },
1386                        representation: Representation::Hydrated {
1387                            canonical_event: event.id,
1388                        },
1389                        occurrence_events: vec![event.id],
1390                        active: true,
1391                    },
1392                );
1393                if let BoxOwner::Tool {
1394                    tool_instance,
1395                    slot,
1396                } = owner
1397                {
1398                    self.tools
1399                        .entry(tool_instance.clone())
1400                        .or_default()
1401                        .slots
1402                        .push(ToolSlot {
1403                            slot: slot.clone(),
1404                            box_id: *box_id,
1405                            retired: false,
1406                        });
1407                }
1408            }
1409            EventKind::CanonicalUpdated { box_id, content } => {
1410                let state = active_box_mut(&mut self.boxes, *box_id)?;
1411                state.canonical = CanonicalRevision {
1412                    event_id: event.id,
1413                    content: content.clone(),
1414                };
1415                if matches!(state.representation, Representation::Hydrated { .. }) {
1416                    state.representation = Representation::Hydrated {
1417                        canonical_event: event.id,
1418                    };
1419                }
1420                state.occurrence_events.push(event.id);
1421            }
1422            EventKind::BoxRenamed { box_id, name } => {
1423                ensure!(!name.trim().is_empty(), "a box name cannot be empty");
1424                let state = active_box_mut(&mut self.boxes, *box_id)?;
1425                state.name = name.clone();
1426                state.occurrence_events.push(event.id);
1427            }
1428            EventKind::BoxDehydrated { box_id } => {
1429                let state = active_box_mut(&mut self.boxes, *box_id)?;
1430                state.representation = Representation::Dehydrated {
1431                    based_on: state.canonical.event_id,
1432                };
1433                state.occurrence_events.push(event.id);
1434            }
1435            EventKind::BoxSummarized { box_id, text } => {
1436                ensure!(!text.trim().is_empty(), "a box summary cannot be empty");
1437                let state = active_box_mut(&mut self.boxes, *box_id)?;
1438                state.representation = Representation::Summarized {
1439                    based_on: state.canonical.event_id,
1440                    text: text.clone(),
1441                };
1442                state.occurrence_events.push(event.id);
1443            }
1444            EventKind::BoxRehydrated { box_id } => {
1445                let state = active_box_mut(&mut self.boxes, *box_id)?;
1446                state.representation = Representation::Hydrated {
1447                    canonical_event: state.canonical.event_id,
1448                };
1449                state.occurrence_events.push(event.id);
1450            }
1451            EventKind::BoxRetired { box_id } => {
1452                let tool_instance = self.boxes.get(box_id).and_then(|state| {
1453                    let BoxOwner::Tool { tool_instance, .. } = &state.owner else {
1454                        return None;
1455                    };
1456                    Some(tool_instance.clone())
1457                });
1458                let state = active_box_mut(&mut self.boxes, *box_id)?;
1459                state.active = false;
1460                state.occurrence_events.push(event.id);
1461                if let Some(tool_instance) = tool_instance {
1462                    let slot = self
1463                        .tools
1464                        .get_mut(&tool_instance)
1465                        .and_then(|tool| tool.slots.iter_mut().find(|slot| slot.box_id == *box_id))
1466                        .with_context(|| {
1467                            format!("tool box {box_id} is missing from {tool_instance}")
1468                        })?;
1469                    slot.retired = true;
1470                }
1471            }
1472            EventKind::ToolLayoutChanged {
1473                tool_instance,
1474                box_ids,
1475            } => {
1476                let mut unique = std::collections::HashSet::new();
1477                for box_id in box_ids {
1478                    ensure!(
1479                        unique.insert(*box_id),
1480                        "tool layout contains duplicate box {box_id}"
1481                    );
1482                    let state = self
1483                        .boxes
1484                        .get(box_id)
1485                        .with_context(|| format!("tool layout references missing box {box_id}"))?;
1486                    ensure!(state.active, "tool layout references retired box {box_id}");
1487                    ensure!(
1488                        matches!(
1489                            &state.owner,
1490                            BoxOwner::Tool {
1491                                tool_instance: owner,
1492                                ..
1493                            } if owner == tool_instance
1494                        ),
1495                        "tool layout box {box_id} belongs to another tool"
1496                    );
1497                }
1498                self.tool_layouts
1499                    .insert(tool_instance.clone(), box_ids.clone());
1500            }
1501            EventKind::PendingAllocated {
1502                pending_id,
1503                resource,
1504            } => {
1505                ensure!(
1506                    pending_id.number() == event.id.0,
1507                    "pending identity must equal its allocation EventId"
1508                );
1509                ensure!(
1510                    self.pending
1511                        .insert(pending_id.clone(), resource.clone())
1512                        .is_none(),
1513                    "pending identity {} already exists",
1514                    pending_id
1515                );
1516            }
1517            EventKind::SourceTerminated { .. } => self.source_terminated = true,
1518            EventKind::HistoryIngressStarted => {
1519                ensure!(
1520                    self.source_terminated,
1521                    "history ingress requires source termination"
1522                );
1523                self.history_ingress_started = true;
1524            }
1525            EventKind::Note { label, .. } if label == HISTORY_INGRESS_ATTEMPT_RESET_NOTE => {
1526                self.restore_history_ingress_baseline()?;
1527            }
1528            EventKind::SessionCompleted { session_object_id } => {
1529                self.completed_session_object = Some(session_object_id.clone());
1530            }
1531            EventKind::ToolInvoked { .. }
1532            | EventKind::ToolCompleted { .. }
1533            | EventKind::InferenceSubmitted { .. }
1534            | EventKind::ProviderInputSubmitted { .. }
1535            | EventKind::ProjectionMarkersReset
1536            | EventKind::StaleBoxesMarked { .. }
1537            | EventKind::ContextSizeMarked { .. }
1538            | EventKind::ProviderReceipt { .. }
1539            | EventKind::CapacityError { .. }
1540            | EventKind::HistoryEventInspected { .. }
1541            | EventKind::HistoryEventReleased { .. }
1542            | EventKind::KwebPlanChanged { .. }
1543            | EventKind::KwebCommitted { .. }
1544            | EventKind::Note { .. } => {}
1545        }
1546        self.events.push(event.clone());
1547        Ok(())
1548    }
1549}
1550
1551fn active_box_mut(
1552    boxes: &mut BTreeMap<BoxId, BoxState>,
1553    box_id: BoxId,
1554) -> anyhow::Result<&mut BoxState> {
1555    let state = boxes
1556        .get_mut(&box_id)
1557        .with_context(|| format!("box {box_id} does not exist"))?;
1558    ensure!(state.active, "box {box_id} is retired");
1559    Ok(state)
1560}
1561
1562fn arrange_tool_projection(items: &mut Vec<ProjectionItem>, box_ids: &[BoxId]) {
1563    if box_ids.is_empty() {
1564        return;
1565    }
1566    let ranks = box_ids
1567        .iter()
1568        .enumerate()
1569        .map(|(rank, box_id)| (*box_id, rank))
1570        .collect::<HashMap<_, _>>();
1571    let insertion = items
1572        .iter()
1573        .position(|item| !item.marker && ranks.contains_key(&item.box_id));
1574    let Some(insertion) = insertion else {
1575        return;
1576    };
1577    let mut arranged = Vec::with_capacity(box_ids.len());
1578    let mut retained = Vec::with_capacity(items.len());
1579    for item in std::mem::take(items) {
1580        if !item.marker && ranks.contains_key(&item.box_id) {
1581            arranged.push(item);
1582        } else {
1583            retained.push(item);
1584        }
1585    }
1586    arranged.sort_by_key(|item| ranks[&item.box_id]);
1587    let insertion = insertion.min(retained.len());
1588    retained.splice(insertion..insertion, arranged);
1589    *items = retained;
1590}
1591
1592fn event_box_id(kind: &EventKind) -> Option<BoxId> {
1593    match kind {
1594        EventKind::BoxCreated { box_id, .. }
1595        | EventKind::CanonicalUpdated { box_id, .. }
1596        | EventKind::BoxRenamed { box_id, .. }
1597        | EventKind::BoxDehydrated { box_id }
1598        | EventKind::BoxSummarized { box_id, .. }
1599        | EventKind::BoxRehydrated { box_id }
1600        | EventKind::BoxRetired { box_id } => Some(*box_id),
1601        _ => None,
1602    }
1603}
1604
1605fn ingress_time_marker_text(value: &Value) -> Option<String> {
1606    let remaining_seconds = value.get("remainingSeconds")?.as_u64()?;
1607    let previous_attempt_timed_out = value
1608        .get("previousAttemptTimedOut")
1609        .and_then(Value::as_bool)
1610        .unwrap_or(false);
1611    let previous = if previous_attempt_timed_out {
1612        "Previous attempt ran out of time; please budget your ingress time carefully. "
1613    } else {
1614        ""
1615    };
1616    let label = if previous_attempt_timed_out {
1617        "Ingress"
1618    } else {
1619        "ingress"
1620    };
1621    let warning = if remaining_seconds <= 10 * 60 {
1622        ". If you do not call EndSession before time expires, the ingress will fail and all Kmap updates will be lost."
1623    } else {
1624        ""
1625    };
1626    Some(format!(
1627        "[{previous}{label} time remaining: {remaining_seconds} seconds{warning}]"
1628    ))
1629}
1630
1631fn context_size_marker_text(estimated_tokens: u64) -> String {
1632    format!("[current context size: approximately {estimated_tokens} tokens]")
1633}
1634
1635#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1636#[serde(rename_all = "camelCase")]
1637pub struct ProjectionItem {
1638    pub event_id: EventId,
1639    pub box_id: BoxId,
1640    pub marker: bool,
1641    pub stale: bool,
1642    pub approximate_tokens: u64,
1643    pub text: String,
1644}
1645
1646impl ProjectionItem {
1647    fn marker(event_id: EventId, box_id: BoxId, text: String) -> Self {
1648        Self {
1649            event_id,
1650            box_id,
1651            marker: true,
1652            stale: false,
1653            approximate_tokens: estimate_tokens(&text),
1654            text,
1655        }
1656    }
1657
1658    fn projection_marker(event_id: EventId, text: String) -> Self {
1659        Self::marker(event_id, BoxId(0), text)
1660    }
1661}
1662
1663#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1664#[serde(rename_all = "camelCase")]
1665pub struct ContextProjection {
1666    pub items: Vec<ProjectionItem>,
1667    pub stale_boxes: Vec<BoxId>,
1668    pub footer: String,
1669    pub estimated_tokens: u64,
1670    pub raw_estimated_tokens: u64,
1671    pub context_bytes: u64,
1672    pub status: SessionStatus,
1673}
1674
1675impl ContextProjection {
1676    pub fn render(&self) -> String {
1677        let mut blocks = self
1678            .items
1679            .iter()
1680            .map(|item| item.text.as_str())
1681            .collect::<Vec<_>>();
1682        if !self.footer.is_empty() {
1683            blocks.push(&self.footer);
1684        }
1685        blocks.join("\n\n")
1686    }
1687}
1688
1689#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
1690#[serde(rename_all = "camelCase")]
1691pub struct SessionStatus {
1692    pub current_context_tokens: u64,
1693    #[serde(default)]
1694    pub fully_hydrated_context_tokens: u64,
1695    pub context_limit_tokens: u64,
1696    pub current_context_bytes: u64,
1697    pub cached_input_tokens: u64,
1698    pub non_cached_input_tokens: u64,
1699    pub thinking_tokens: u64,
1700    pub output_tokens: u64,
1701    #[serde(default)]
1702    pub estimated_cost_usd_nanos: u64,
1703    #[serde(default)]
1704    pub unpriced_provider_calls: u64,
1705}
1706
1707#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1708struct CumulativeTokenUsage {
1709    cached_input_tokens: u64,
1710    non_cached_input_tokens: u64,
1711    thinking_tokens: u64,
1712    output_tokens: u64,
1713}
1714
1715#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1716struct CumulativeCost {
1717    estimated_cost_usd_nanos: u64,
1718    unpriced_provider_calls: u64,
1719}
1720
1721pub fn estimate_tokens(text: &str) -> u64 {
1722    estimate_bytes(text.len() as u64)
1723}
1724
1725fn estimate_bytes(bytes: u64) -> u64 {
1726    bytes.div_ceil(ESTIMATED_BYTES_PER_TOKEN)
1727}
1728
1729fn projected_context_bytes(items: &[ProjectionItem], footer: &str) -> u64 {
1730    let blocks = items.len() as u64 + u64::from(!footer.is_empty());
1731    items
1732        .iter()
1733        .fold(footer.len() as u64, |total, item| {
1734            total.saturating_add(item.text.len() as u64)
1735        })
1736        .saturating_add(blocks.saturating_sub(1).saturating_mul(2))
1737}
1738
1739fn provider_u64(value: &Value, keys: &[&str]) -> u64 {
1740    keys.iter()
1741        .find_map(|key| value.get(*key).and_then(Value::as_u64))
1742        .unwrap_or_default()
1743}
1744
1745struct ActiveToolModel {
1746    invocation_id: Option<String>,
1747    tool_instance: String,
1748    tool_name: String,
1749    model: Option<String>,
1750}
1751
1752fn normalize_requested_model(model: &str) -> String {
1753    model.strip_prefix("codex/").unwrap_or(model).to_owned()
1754}
1755
1756fn provider_metering(provider_data: &Value) -> ProviderMetering {
1757    let metering = provider_data.get("metering");
1758    if metering
1759        .and_then(Value::as_str)
1760        .is_some_and(|kind| kind == "unavailable")
1761    {
1762        return ProviderMetering::Unavailable;
1763    }
1764    if let Some(metering_value) = metering
1765        && let Some(metering) = metering_value.as_object()
1766    {
1767        match metering.get("kind").and_then(Value::as_str) {
1768            Some("duration_seconds") => {
1769                return metering
1770                    .get("seconds")
1771                    .and_then(Value::as_f64)
1772                    .map(|seconds| ProviderMetering::DurationSeconds { seconds })
1773                    .unwrap_or(ProviderMetering::Unavailable);
1774            }
1775            Some("unavailable") => return ProviderMetering::Unavailable,
1776            Some("tokens") => {
1777                return ProviderMetering::Tokens(token_metering(metering_value));
1778            }
1779            _ => {}
1780        }
1781    }
1782    let has_tokens = metering.and_then(Value::as_str) == Some("tokens")
1783        || [
1784            "inputTokens",
1785            "input_tokens",
1786            "nonCachedInputTokens",
1787            "non_cached_input_tokens",
1788            "cachedInputTokens",
1789            "cached_input_tokens",
1790            "thinkingTokens",
1791            "thinking_tokens",
1792            "outputTokens",
1793            "output_tokens",
1794        ]
1795        .iter()
1796        .any(|key| provider_data.get(*key).and_then(Value::as_u64).is_some());
1797    if has_tokens {
1798        ProviderMetering::Tokens(token_metering(provider_data))
1799    } else {
1800        ProviderMetering::Unavailable
1801    }
1802}
1803
1804fn token_metering(value: &Value) -> ProviderTokenUsage {
1805    let cached_input_tokens = provider_u64(value, &["cachedInputTokens", "cached_input_tokens"]);
1806    let thinking_tokens = provider_u64(
1807        value,
1808        &[
1809            "thinkingTokens",
1810            "thinking_tokens",
1811            "reasoningOutputTokens",
1812            "reasoning_output_tokens",
1813        ],
1814    );
1815    let normalized_delta = value
1816        .get("usageIsDelta")
1817        .or_else(|| value.get("usage_is_delta"))
1818        .and_then(Value::as_bool)
1819        .unwrap_or(false);
1820    let input_tokens = if normalized_delta {
1821        provider_u64(value, &["nonCachedInputTokens", "non_cached_input_tokens"])
1822    } else {
1823        provider_u64(value, &["inputTokens", "input_tokens"]).saturating_sub(cached_input_tokens)
1824    };
1825    let output_tokens = if normalized_delta {
1826        provider_u64(value, &["outputTokens", "output_tokens"])
1827    } else {
1828        provider_u64(value, &["outputTokens", "output_tokens"]).saturating_sub(thinking_tokens)
1829    };
1830    ProviderTokenUsage {
1831        input_tokens,
1832        cached_input_tokens,
1833        thinking_tokens,
1834        output_tokens,
1835    }
1836}
1837
1838fn context_event_role(kind: &EventKind) -> Role {
1839    match kind {
1840        EventKind::BoxCreated { owner, content, .. } => match owner {
1841            BoxOwner::System | BoxOwner::Controller => {
1842                if content
1843                    .metadata
1844                    .get("capacityError")
1845                    .and_then(Value::as_bool)
1846                    .unwrap_or(false)
1847                {
1848                    Role::SystemError
1849                } else {
1850                    Role::SystemMessage
1851                }
1852            }
1853            BoxOwner::User => Role::UserMessage,
1854            BoxOwner::Kennedy => Role::KennedyMessage,
1855            BoxOwner::Tool { .. } => Role::ToolResult,
1856        },
1857        EventKind::ToolInvoked { .. } => Role::KennedyToolCall,
1858        EventKind::ToolCompleted { outcome, .. } => {
1859            if outcome.get("ok").and_then(Value::as_bool).unwrap_or(true) {
1860                Role::ToolResult
1861            } else {
1862                Role::ToolError
1863            }
1864        }
1865        EventKind::CapacityError { .. } => Role::SystemError,
1866        EventKind::PendingAllocated {
1867            resource: PendingKind::Object,
1868            ..
1869        } => Role::PendingObject,
1870        EventKind::BoxDehydrated { .. }
1871        | EventKind::BoxSummarized { .. }
1872        | EventKind::BoxRehydrated { .. }
1873        | EventKind::BoxRetired { .. } => Role::KennedyToolCall,
1874        _ => Role::SystemMessage,
1875    }
1876}
1877
1878fn encode_context_event(recorded_at: &str, kind: &EventKind) -> anyhow::Result<String> {
1879    let mut kind = serde_json::to_value(kind)?;
1880    let kind_object = kind
1881        .as_object_mut()
1882        .context("Kennedy context event kind must encode as an object")?;
1883    match kind_object.get("type").and_then(Value::as_str) {
1884        Some("box_created") => {
1885            kind_object.remove("box_id");
1886        }
1887        Some("pending_allocated") => {
1888            kind_object.remove("pending_id");
1889        }
1890        _ => {}
1891    }
1892    Ok(serde_json::to_string(&PersistedContextEventWire {
1893        context_event_version: FORMAT_VERSION,
1894        recorded_at: recorded_at.into(),
1895        kind,
1896    })?)
1897}
1898
1899fn decode_context_event(
1900    event: &kcode_session_log::SessionEvent,
1901) -> anyhow::Result<PersistedContextEvent> {
1902    let wire: PersistedContextEventWire = match serde_json::from_str(&event.text) {
1903        Ok(persisted) => persisted,
1904        Err(_) if event.role == Role::PendingObject => {
1905            return Ok(PersistedContextEvent {
1906                context_event_version: FORMAT_VERSION,
1907                recorded_at: String::new(),
1908                kind: EventKind::PendingAllocated {
1909                    pending_id: PendingId::from_event(EventId(1)),
1910                    resource: PendingKind::Object,
1911                },
1912            });
1913        }
1914        Err(error) => {
1915            return Err(error).context("decoding Kennedy context event from session log");
1916        }
1917    };
1918    ensure!(
1919        wire.context_event_version == FORMAT_VERSION,
1920        "unsupported Kennedy context event version {}",
1921        wire.context_event_version
1922    );
1923    let mut kind = wire.kind;
1924    let kind_object = kind
1925        .as_object_mut()
1926        .context("Kennedy context event kind must be an object")?;
1927    match kind_object.get("type").and_then(Value::as_str) {
1928        Some("box_created") if !kind_object.contains_key("box_id") => {
1929            kind_object.insert("box_id".into(), Value::from(0));
1930        }
1931        Some("pending_allocated") if !kind_object.contains_key("pending_id") => {
1932            kind_object.insert("pending_id".into(), Value::String("pending:1".into()));
1933        }
1934        _ => {}
1935    }
1936    Ok(PersistedContextEvent {
1937        context_event_version: wire.context_event_version,
1938        recorded_at: wire.recorded_at,
1939        kind: serde_json::from_value(kind).context("decoding Kennedy context event kind")?,
1940    })
1941}
1942
1943fn normalize_derived_identity(kind: &mut EventKind, id: EventId) -> anyhow::Result<()> {
1944    match kind {
1945        EventKind::BoxCreated { box_id, .. } => *box_id = BoxId(id.0),
1946        EventKind::PendingAllocated { pending_id, .. } => {
1947            *pending_id = PendingId::from_event(id);
1948        }
1949        _ => {}
1950    }
1951    Ok(())
1952}
1953
1954pub struct Session {
1955    durable: DurableSession,
1956    chatend: Chatend,
1957    objects: BTreeMap<PendingId, ObjectLocation>,
1958}
1959
1960struct UnfinishedToolInvocation {
1961    invocation_id: Option<String>,
1962    tool_instance: String,
1963    tool_name: String,
1964}
1965
1966impl Session {
1967    pub(crate) fn create(
1968        path: impl AsRef<Path>,
1969        metadata: SessionMetadata,
1970    ) -> anyhow::Result<Self> {
1971        ensure!(
1972            metadata.effective_context_tokens > 0,
1973            "effective context window must be positive"
1974        );
1975        let requested = path.as_ref();
1976        let directory = requested
1977            .parent()
1978            .filter(|path| !path.as_os_str().is_empty())
1979            .unwrap_or_else(|| Path::new("."));
1980        let durable = DurableSessionStore::new(directory)
1981            .create_session(&metadata.session_id, &metadata.created_at)?;
1982        Ok(Self {
1983            durable,
1984            chatend: Chatend::opened(metadata),
1985            objects: BTreeMap::new(),
1986        })
1987    }
1988
1989    pub(crate) fn open_with_metadata(
1990        path: impl AsRef<Path>,
1991        metadata: SessionMetadata,
1992    ) -> anyhow::Result<Self> {
1993        Self::open_with_metadata_and_provider_costs(path, metadata, None, None)
1994    }
1995
1996    pub(crate) fn open_with_metadata_and_provider_costs(
1997        path: impl AsRef<Path>,
1998        metadata: SessionMetadata,
1999        default_provider_model: Option<&str>,
2000        estimator: Option<ProviderCostEstimator>,
2001    ) -> anyhow::Result<Self> {
2002        let requested = path.as_ref();
2003        ensure!(
2004            requested.extension().and_then(|value| value.to_str()) == Some("session-log"),
2005            "{} is not a session-log path",
2006            requested.display()
2007        );
2008        let directory = requested
2009            .parent()
2010            .filter(|path| !path.as_os_str().is_empty())
2011            .unwrap_or_else(|| Path::new("."));
2012        let session_id = requested
2013            .file_stem()
2014            .and_then(|value| value.to_str())
2015            .context("session-log filename is not valid UTF-8")?;
2016        let durable = DurableSessionStore::new(directory).open_session(session_id)?;
2017        let log = durable.list();
2018        let chatend =
2019            Chatend::replay_with_provider_costs(metadata, &log, default_provider_model, estimator)?;
2020        let mut objects = BTreeMap::new();
2021        for (position, stored) in log.events.iter().enumerate() {
2022            let persisted = decode_context_event(stored)?;
2023            let id = EventId(position as u64 + 1);
2024            let mut kind = persisted.kind;
2025            normalize_derived_identity(&mut kind, id)?;
2026            if let EventKind::PendingAllocated {
2027                pending_id,
2028                resource: PendingKind::Object,
2029            } = kind
2030            {
2031                let object = durable.read_pending_object(EventPosition(position as u64))?;
2032                let metadata = ObjectMetadata {
2033                    pending_id: pending_id.clone(),
2034                    event_id: id,
2035                    recorded_at: chatend
2036                        .event(id)
2037                        .map(|event| event.recorded_at.clone())
2038                        .unwrap_or_default(),
2039                    media_type: object.media_type,
2040                    file_name: Some(object.file_name),
2041                    transport: Value::Null,
2042                };
2043                objects.insert(
2044                    pending_id,
2045                    ObjectLocation {
2046                        metadata,
2047                        payload_offset: position as u64,
2048                        payload_len: object.bytes.len() as u64,
2049                    },
2050                );
2051            }
2052        }
2053        Ok(Self {
2054            durable,
2055            chatend,
2056            objects,
2057        })
2058    }
2059
2060    pub fn id(&self) -> &str {
2061        &self.chatend.metadata.session_id
2062    }
2063
2064    pub fn state(&self) -> &Chatend {
2065        &self.chatend
2066    }
2067
2068    pub fn objects(&self) -> &BTreeMap<PendingId, ObjectLocation> {
2069        &self.objects
2070    }
2071
2072    #[cfg(test)]
2073    fn session_log(&self) -> kcode_session_log::SessionLog {
2074        self.durable.list()
2075    }
2076
2077    pub fn archive_bytes(&self) -> anyhow::Result<Vec<u8>> {
2078        let mut archive =
2079            serde_json::to_value(self.durable.list()).context("serializing the session log")?;
2080        let object = archive
2081            .as_object_mut()
2082            .context("serialized session log is not an object")?;
2083        object.insert(
2084            "metadata".into(),
2085            serde_json::to_value(&self.chatend.metadata)?,
2086        );
2087        object.insert("boxes".into(), serde_json::to_value(&self.chatend.boxes)?);
2088        let projection = self.chatend.projection();
2089        let submitted = self
2090            .chatend
2091            .current_ingress_attempt_events()
2092            .iter()
2093            .rev()
2094            .find_map(|event| {
2095                let EventKind::ProviderInputSubmitted { round, context, .. } = &event.kind else {
2096                    return None;
2097                };
2098                Some((event.recorded_at.as_str(), *round, context))
2099            });
2100        if let Some((submitted_at, round, submitted)) = submitted {
2101            object.insert("chatendText".into(), Value::String(submitted.input.clone()));
2102            object.insert(
2103                "chatendTextSource".into(),
2104                Value::String("submitted".into()),
2105            );
2106            object.insert(
2107                "structuredMaterial".into(),
2108                json!({
2109                    "provider":submitted.provider,
2110                    "model":submitted.model,
2111                    "reasoningEffort":submitted.reasoning_effort,
2112                    "baseInstructions":submitted.base_instructions,
2113                    "developerInstructions":submitted.developer_instructions,
2114                    "tools":submitted.tools,
2115                    "round":round,
2116                    "submittedAt":submitted_at,
2117                }),
2118            );
2119        } else {
2120            object.insert("chatendText".into(), Value::String(projection.render()));
2121            object.insert(
2122                "chatendTextSource".into(),
2123                Value::String("reconstructed".into()),
2124            );
2125            object.insert("structuredMaterial".into(), Value::Null);
2126        }
2127        object.insert("context".into(), serde_json::to_value(projection)?);
2128        serde_json::to_vec(&archive).context("serializing the session archive")
2129    }
2130
2131    pub fn is_sealed(&self) -> bool {
2132        self.durable.is_sealed()
2133    }
2134
2135    pub fn seal(&mut self) -> anyhow::Result<()> {
2136        let unfinished_tools = self.unfinished_tool_invocations()?;
2137        ensure!(
2138            unfinished_tools.is_empty(),
2139            "session ends with unfinished tools {}",
2140            unfinished_tools
2141                .iter()
2142                .map(|tool| tool.tool_name.as_str())
2143                .collect::<Vec<_>>()
2144                .join(", ")
2145        );
2146        self.durable.seal()?;
2147        Ok(())
2148    }
2149
2150    pub fn repair_unfinished_tools(
2151        &mut self,
2152        recorded_at: impl Into<String>,
2153    ) -> anyhow::Result<Vec<EventId>> {
2154        let unfinished = self.unfinished_tool_invocations()?;
2155        if unfinished.is_empty() {
2156            return Ok(Vec::new());
2157        }
2158        let recorded_at = recorded_at.into();
2159        let mut repaired = Vec::with_capacity(unfinished.len());
2160        for tool in unfinished.iter().rev() {
2161            let message = format!(
2162                "{} was interrupted before a durable completion was recorded; the abandoned invocation was closed during session recovery.",
2163                tool.tool_name
2164            );
2165            let kind = if let Some(invocation_id) = &tool.invocation_id {
2166                EventKind::ToolCompleted {
2167                    tool_instance: tool.tool_instance.clone(),
2168                    tool_name: tool.tool_name.clone(),
2169                    outcome: serde_json::json!({"ok":false,"recovered":true,"result":message}),
2170                    invocation_id: Some(invocation_id.clone()),
2171                }
2172            } else {
2173                // Historical native Ktool completions used one generic
2174                // `call_ktool` event to close the most recent invocation.
2175                EventKind::ToolCompleted {
2176                    tool_instance: "call_ktool".into(),
2177                    tool_name: "call_ktool".into(),
2178                    outcome: serde_json::json!({"ok":false,"recovered":true,"result":message}),
2179                    invocation_id: None,
2180                }
2181            };
2182            repaired.push(self.record(recorded_at.clone(), kind)?);
2183        }
2184        ensure!(
2185            self.unfinished_tool_invocations()?.is_empty(),
2186            "session tool recovery left unfinished invocations"
2187        );
2188        Ok(repaired)
2189    }
2190
2191    fn unfinished_tool_invocations(&self) -> anyhow::Result<Vec<UnfinishedToolInvocation>> {
2192        let mut identified = BTreeMap::<String, UnfinishedToolInvocation>::new();
2193        let mut legacy = Vec::<UnfinishedToolInvocation>::new();
2194        for event in self.chatend.current_ingress_attempt_events() {
2195            match &event.kind {
2196                EventKind::ToolInvoked {
2197                    tool_instance,
2198                    tool_name,
2199                    invocation_id,
2200                    ..
2201                } => {
2202                    let pending = UnfinishedToolInvocation {
2203                        invocation_id: invocation_id.clone(),
2204                        tool_instance: tool_instance.clone(),
2205                        tool_name: tool_name.clone(),
2206                    };
2207                    if let Some(invocation_id) = invocation_id {
2208                        ensure!(
2209                            identified.insert(invocation_id.clone(), pending).is_none(),
2210                            "duplicate tool invocation identity {invocation_id}"
2211                        );
2212                    } else {
2213                        legacy.push(pending);
2214                    }
2215                }
2216                EventKind::ToolCompleted {
2217                    tool_instance,
2218                    tool_name,
2219                    invocation_id,
2220                    ..
2221                } => {
2222                    if let Some(invocation_id) = invocation_id {
2223                        let invoked = identified.remove(invocation_id).with_context(|| {
2224                            format!(
2225                                "tool {tool_name} completed without matching invocation {invocation_id}"
2226                            )
2227                        })?;
2228                        ensure!(
2229                            invoked.tool_instance.as_str() == tool_instance
2230                                && invoked.tool_name.as_str() == tool_name,
2231                            "tool completion {invocation_id} does not match its invocation"
2232                        );
2233                    } else if tool_name == "call_ktool" {
2234                        // An invalid native call still produces an error result
2235                        // even when it could not be decoded into ToolInvoked.
2236                        legacy.pop();
2237                    } else {
2238                        let before = legacy.len();
2239                        legacy.retain(|unfinished| unfinished.tool_name.as_str() != tool_name);
2240                        ensure!(
2241                            legacy.len() != before,
2242                            "tool {tool_name} completed without a matching invocation"
2243                        );
2244                    }
2245                }
2246                _ => {}
2247            }
2248        }
2249        legacy.extend(identified.into_values());
2250        Ok(legacy)
2251    }
2252
2253    pub fn mark_completed(&mut self, session_object_id: String) {
2254        self.chatend.completed_session_object = Some(session_object_id);
2255    }
2256
2257    pub fn configure_context(&mut self, kind: SessionKind, effective_context_tokens: u64) {
2258        self.chatend.metadata.kind = kind;
2259        self.chatend.metadata.effective_context_tokens = effective_context_tokens;
2260    }
2261
2262    pub fn create_box(
2263        &mut self,
2264        recorded_at: impl Into<String>,
2265        name: impl Into<String>,
2266        owner: BoxOwner,
2267        content: BoxContent,
2268    ) -> anyhow::Result<BoxId> {
2269        let recorded_at = recorded_at.into();
2270        let id = EventId(self.chatend.next_id);
2271        let box_id = BoxId(id.0);
2272        self.commit_events(
2273            recorded_at.clone(),
2274            vec![Event {
2275                id,
2276                recorded_at,
2277                kind: EventKind::BoxCreated {
2278                    box_id,
2279                    name: name.into(),
2280                    owner,
2281                    content,
2282                },
2283            }],
2284        )?;
2285        Ok(box_id)
2286    }
2287
2288    pub fn update_box(
2289        &mut self,
2290        recorded_at: impl Into<String>,
2291        box_id: BoxId,
2292        content: BoxContent,
2293    ) -> anyhow::Result<Option<EventId>> {
2294        let state = self
2295            .chatend
2296            .boxes
2297            .get(&box_id)
2298            .with_context(|| format!("box {box_id} does not exist"))?;
2299        ensure!(state.active, "box {box_id} is retired");
2300        if state.canonical.content == content {
2301            return Ok(None);
2302        }
2303        let id = EventId(self.chatend.next_id);
2304        let recorded_at = recorded_at.into();
2305        self.commit_events(
2306            recorded_at.clone(),
2307            vec![Event {
2308                id,
2309                recorded_at,
2310                kind: EventKind::CanonicalUpdated { box_id, content },
2311            }],
2312        )?;
2313        Ok(Some(id))
2314    }
2315
2316    pub fn dehydrate_boxes(
2317        &mut self,
2318        recorded_at: impl Into<String>,
2319        box_ids: &[BoxId],
2320    ) -> anyhow::Result<Vec<EventId>> {
2321        ensure!(
2322            !box_ids.is_empty(),
2323            "at least one box must be selected for dehydration"
2324        );
2325        ensure!(
2326            box_ids.iter().copied().collect::<HashSet<_>>().len() == box_ids.len(),
2327            "box dehydration cannot contain duplicate box IDs"
2328        );
2329        let recorded_at = recorded_at.into();
2330        let events = box_ids
2331            .iter()
2332            .enumerate()
2333            .map(|(offset, box_id)| {
2334                let offset = u64::try_from(offset).context("box dehydration batch is too large")?;
2335                let id = self
2336                    .chatend
2337                    .next_id
2338                    .checked_add(offset)
2339                    .context("event ID overflow")?;
2340                Ok(Event {
2341                    id: EventId(id),
2342                    recorded_at: recorded_at.clone(),
2343                    kind: EventKind::BoxDehydrated { box_id: *box_id },
2344                })
2345            })
2346            .collect::<anyhow::Result<Vec<_>>>()?;
2347        let ids = events.iter().map(|event| event.id).collect();
2348        self.commit_events(recorded_at, events)?;
2349        Ok(ids)
2350    }
2351
2352    pub fn summarize_box(
2353        &mut self,
2354        recorded_at: impl Into<String>,
2355        box_id: BoxId,
2356        text: impl Into<String>,
2357    ) -> anyhow::Result<EventId> {
2358        self.box_operation(
2359            recorded_at,
2360            EventKind::BoxSummarized {
2361                box_id,
2362                text: text.into(),
2363            },
2364        )
2365    }
2366
2367    pub fn rehydrate_box(
2368        &mut self,
2369        recorded_at: impl Into<String>,
2370        box_id: BoxId,
2371    ) -> anyhow::Result<EventId> {
2372        self.box_operation(recorded_at, EventKind::BoxRehydrated { box_id })
2373    }
2374
2375    pub fn retire_box(
2376        &mut self,
2377        recorded_at: impl Into<String>,
2378        box_id: BoxId,
2379    ) -> anyhow::Result<EventId> {
2380        self.box_operation(recorded_at, EventKind::BoxRetired { box_id })
2381    }
2382
2383    fn box_operation(
2384        &mut self,
2385        recorded_at: impl Into<String>,
2386        kind: EventKind,
2387    ) -> anyhow::Result<EventId> {
2388        let box_id = event_box_id(&kind).context("box operation has no box identity")?;
2389        let state = self
2390            .chatend
2391            .box_state(box_id)
2392            .with_context(|| format!("box {box_id} does not exist"))?;
2393        ensure!(state.active, "box {box_id} is retired");
2394        if let EventKind::BoxSummarized { text, .. } = &kind {
2395            ensure!(!text.trim().is_empty(), "a box summary cannot be empty");
2396        }
2397        if matches!(kind, EventKind::BoxRetired { .. })
2398            && let BoxOwner::Tool { tool_instance, .. } = &state.owner
2399        {
2400            ensure!(
2401                self.chatend
2402                    .tools
2403                    .get(tool_instance)
2404                    .is_some_and(|tool| tool.slots.iter().any(|slot| slot.box_id == box_id)),
2405                "tool box {box_id} is missing from {tool_instance}"
2406            );
2407        }
2408        let id = EventId(self.chatend.next_id);
2409        let recorded_at = recorded_at.into();
2410        self.commit_events(
2411            recorded_at.clone(),
2412            vec![Event {
2413                id,
2414                recorded_at,
2415                kind,
2416            }],
2417        )?;
2418        Ok(id)
2419    }
2420
2421    pub fn allocate_pending_node(
2422        &mut self,
2423        recorded_at: impl Into<String>,
2424    ) -> anyhow::Result<PendingId> {
2425        let id = EventId(self.chatend.next_id);
2426        let pending_id = PendingId::from_event(id);
2427        let recorded_at = recorded_at.into();
2428        self.commit_events(
2429            recorded_at.clone(),
2430            vec![Event {
2431                id,
2432                recorded_at,
2433                kind: EventKind::PendingAllocated {
2434                    pending_id: pending_id.clone(),
2435                    resource: PendingKind::Node,
2436                },
2437            }],
2438        )?;
2439        Ok(pending_id)
2440    }
2441
2442    pub fn stage_object(
2443        &mut self,
2444        recorded_at: impl Into<String>,
2445        media_type: impl Into<String>,
2446        file_name: Option<String>,
2447        transport: Value,
2448        bytes: &[u8],
2449    ) -> anyhow::Result<PendingId> {
2450        ensure!(
2451            bytes.len() as u64 <= MAX_OBJECT_BYTES,
2452            "object exceeds the 32 GiB V1 limit"
2453        );
2454        let aggregate = self
2455            .objects
2456            .values()
2457            .try_fold(bytes.len() as u64, |total, object| {
2458                total.checked_add(object.payload_len)
2459            })
2460            .context("staged object aggregate length overflow")?;
2461        ensure!(
2462            aggregate <= MAX_OBJECT_BYTES,
2463            "session object payload total exceeds the 32 GiB V1 limit"
2464        );
2465        let event_id = EventId(self.chatend.next_id);
2466        let pending_id = PendingId::from_event(event_id);
2467        let metadata = ObjectMetadata {
2468            pending_id: pending_id.clone(),
2469            event_id,
2470            recorded_at: recorded_at.into(),
2471            media_type: media_type.into(),
2472            file_name,
2473            transport,
2474        };
2475        let kind = EventKind::PendingAllocated {
2476            pending_id: pending_id.clone(),
2477            resource: PendingKind::Object,
2478        };
2479        let text = encode_context_event(&metadata.recorded_at, &kind)?;
2480        let object_file_name = metadata
2481            .file_name
2482            .clone()
2483            .unwrap_or_else(|| format!("object-{}", event_id.0));
2484        let position = self.durable.add_pending_object(
2485            text,
2486            object_file_name,
2487            metadata.media_type.clone(),
2488            bytes,
2489        )?;
2490        ensure!(
2491            position.0 + 1 == event_id.0,
2492            "session-log event position diverged from Kennedy context identity"
2493        );
2494        let allocation = Event {
2495            id: event_id,
2496            recorded_at: metadata.recorded_at.clone(),
2497            kind,
2498        };
2499        self.chatend.apply_transition(&Transition {
2500            recorded_at: metadata.recorded_at.clone(),
2501            events: vec![allocation],
2502        })?;
2503        self.objects.insert(
2504            pending_id.clone(),
2505            ObjectLocation {
2506                metadata,
2507                payload_offset: position.0,
2508                payload_len: bytes.len() as u64,
2509            },
2510        );
2511        Ok(pending_id)
2512    }
2513
2514    pub fn read_object(&mut self, id: &PendingId) -> anyhow::Result<Vec<u8>> {
2515        let location = self
2516            .objects
2517            .get(id)
2518            .with_context(|| format!("staged object {id} does not exist"))?
2519            .clone();
2520        Ok(self
2521            .durable
2522            .read_pending_object(EventPosition(location.payload_offset))?
2523            .bytes)
2524    }
2525
2526    /// Starts a fresh provider-visible ingress attempt while retaining audit history.
2527    pub fn reset_history_ingress_attempt(
2528        &mut self,
2529        recorded_at: impl Into<String>,
2530    ) -> anyhow::Result<EventId> {
2531        ensure!(
2532            !self.is_sealed(),
2533            "a sealed session cannot start another ingress attempt"
2534        );
2535        ensure!(
2536            self.chatend.history_ingress_started,
2537            "history ingress must have started before an attempt can be reset"
2538        );
2539        let recorded_at = recorded_at.into();
2540        self.record(
2541            recorded_at,
2542            EventKind::Note {
2543                label: HISTORY_INGRESS_ATTEMPT_RESET_NOTE.into(),
2544                value: json!({}),
2545            },
2546        )
2547    }
2548
2549    /// Reconciles sparse cache-epoch markers and returns the exact projection
2550    /// to submit with caller-owned transient budget lines.
2551    pub fn prepare_provider_projection(
2552        &mut self,
2553        recorded_at: impl Into<String>,
2554        footer_lines: &[String],
2555        material_fingerprint: &str,
2556        resume_after: Option<EventId>,
2557    ) -> anyhow::Result<PreparedProviderProjection> {
2558        self.prepare_provider_projection_at(
2559            recorded_at,
2560            footer_lines,
2561            material_fingerprint,
2562            resume_after,
2563            None,
2564        )
2565    }
2566
2567    /// Prepares a projection and sparsely appends the current ingress time.
2568    pub fn prepare_provider_projection_with_ingress_time(
2569        &mut self,
2570        recorded_at: impl Into<String>,
2571        footer_lines: &[String],
2572        material_fingerprint: &str,
2573        resume_after: Option<EventId>,
2574        remaining_seconds: u64,
2575        previous_attempt_timed_out: bool,
2576    ) -> anyhow::Result<PreparedProviderProjection> {
2577        self.prepare_provider_projection_at(
2578            recorded_at,
2579            footer_lines,
2580            material_fingerprint,
2581            resume_after,
2582            Some((remaining_seconds, previous_attempt_timed_out)),
2583        )
2584    }
2585
2586    /// Commits and returns every persistent marker due before a native provider resume.
2587    pub fn prepare_provider_resume_markers(
2588        &mut self,
2589        recorded_at: impl Into<String>,
2590        synchronized_after: EventId,
2591        ingress_time: Option<(u64, bool)>,
2592    ) -> anyhow::Result<Vec<String>> {
2593        let rewrite_reason = self
2594            .chatend
2595            .projection_rewrite_reason_after(Some(synchronized_after));
2596        let expectation = if rewrite_reason.is_empty() {
2597            CacheExpectation::ExpectedWarm
2598        } else {
2599            CacheExpectation::PlannedInvalidation {
2600                reason: rewrite_reason,
2601            }
2602        };
2603        self.append_provider_markers(recorded_at.into(), expectation, ingress_time)
2604    }
2605
2606    fn prepare_provider_projection_at(
2607        &mut self,
2608        recorded_at: impl Into<String>,
2609        footer_lines: &[String],
2610        material_fingerprint: &str,
2611        resume_after: Option<EventId>,
2612        ingress_time: Option<(u64, bool)>,
2613    ) -> anyhow::Result<PreparedProviderProjection> {
2614        let cacheable_projection = self.chatend.projection().render();
2615        let previous = self.chatend.latest_provider_input();
2616        let previous_projection = previous
2617            .map(|(_, context, prefix_bytes, fingerprint)| {
2618                let prefix_bytes = usize::try_from(prefix_bytes)
2619                    .context("cacheable provider-prefix length does not fit usize")?;
2620                ensure!(
2621                    prefix_bytes <= context.input.len()
2622                        && context.input.is_char_boundary(prefix_bytes),
2623                    "cacheable provider-prefix length is outside the exact input"
2624                );
2625                Ok(PreviousProjection {
2626                    text: &context.input[..prefix_bytes],
2627                    material_fingerprint: fingerprint,
2628                })
2629            })
2630            .transpose()?;
2631        let expectation = classify(
2632            previous_projection,
2633            &cacheable_projection,
2634            material_fingerprint,
2635            &self
2636                .chatend
2637                .projection_rewrite_reason_after(previous.map(|(id, ..)| id)),
2638        );
2639        self.append_provider_markers(recorded_at.into(), expectation.clone(), ingress_time)?;
2640        let cacheable_projection = self.chatend.projection().render();
2641        let projection = self.chatend.projection_with_footer_lines(footer_lines);
2642        let rewrite_reason = self.chatend.projection_rewrite_reason_after(resume_after);
2643        let thread_reset_reason = resume_after
2644            .is_some()
2645            .then_some(rewrite_reason)
2646            .filter(|reason| !reason.is_empty());
2647        let provider_input = if let Some(resume_after) = resume_after
2648            && thread_reset_reason.is_none()
2649        {
2650            let mut blocks = projection
2651                .items
2652                .iter()
2653                .filter(|item| item.event_id > resume_after)
2654                .map(|item| item.text.as_str())
2655                .collect::<Vec<_>>();
2656            if !projection.footer.is_empty() {
2657                blocks.push(&projection.footer);
2658            }
2659            blocks.join("\n\n")
2660        } else {
2661            projection.render()
2662        };
2663        Ok(PreparedProviderProjection {
2664            projection,
2665            provider_input,
2666            thread_reset_reason,
2667            cacheable_prefix_bytes: cacheable_projection.len() as u64,
2668            expectation,
2669        })
2670    }
2671
2672    fn append_provider_markers(
2673        &mut self,
2674        recorded_at: String,
2675        expectation: CacheExpectation,
2676        ingress_time: Option<(u64, bool)>,
2677    ) -> anyhow::Result<Vec<String>> {
2678        let projection = if expectation.expects_cache_hit() {
2679            self.chatend.projection()
2680        } else {
2681            let mut preview = self.chatend.clone();
2682            preview.apply_transition(&Transition {
2683                recorded_at: "marker-preview".into(),
2684                events: vec![Event {
2685                    id: EventId(preview.next_id),
2686                    recorded_at: "marker-preview".into(),
2687                    kind: EventKind::ProjectionMarkersReset,
2688                }],
2689            })?;
2690            preview.projection()
2691        };
2692        let decision = decide_markers(
2693            &self.chatend.marker_state(),
2694            MarkerObservation {
2695                stale_boxes: projection.stale_boxes.iter().map(|id| id.0).collect(),
2696                current_context_tokens: projection.estimated_tokens,
2697                context_limit_tokens: projection.status.context_limit_tokens,
2698                expectation: expectation.clone(),
2699            },
2700        );
2701        let mut next = self.chatend.next_id;
2702        let mut events = Vec::new();
2703        let mut marker_lines = Vec::new();
2704        let ingress_time_marker_state = self.chatend.ingress_time_marker_state();
2705        let append_ingress_time = ingress_time.is_some_and(|(remaining_seconds, _)| {
2706            should_append_ingress_time_marker(
2707                ingress_time_marker_state,
2708                remaining_seconds,
2709                decision.context_size_tokens.is_some(),
2710            )
2711        });
2712        {
2713            let mut push = |kind| -> anyhow::Result<()> {
2714                events.push(Event {
2715                    id: EventId(next),
2716                    recorded_at: recorded_at.clone(),
2717                    kind,
2718                });
2719                next = next.checked_add(1).context("event ID overflow")?;
2720                Ok(())
2721            };
2722            if decision.reset_epoch {
2723                push(EventKind::ProjectionMarkersReset)?;
2724            }
2725            if let Some(stale) = decision.stale {
2726                marker_lines.push(stale.render());
2727                let (box_ids, consolidated) = match stale {
2728                    StaleMarker::New(ids) => (ids, false),
2729                    StaleMarker::Consolidated(ids) => (ids, true),
2730                };
2731                push(EventKind::StaleBoxesMarked {
2732                    box_ids: box_ids.into_iter().map(BoxId).collect(),
2733                    consolidated,
2734                })?;
2735            }
2736            if let Some(estimated_tokens) = decision.context_size_tokens {
2737                marker_lines.push(context_size_marker_text(estimated_tokens));
2738                push(EventKind::ContextSizeMarked { estimated_tokens })?;
2739            }
2740            if append_ingress_time
2741                && let Some((remaining_seconds, previous_attempt_timed_out)) = ingress_time
2742            {
2743                let value = json!({
2744                    "remainingSeconds":remaining_seconds,
2745                    "previousAttemptTimedOut":previous_attempt_timed_out
2746                        && !ingress_time_marker_state.any,
2747                });
2748                let marker_line = ingress_time_marker_text(&value)
2749                    .expect("constructed ingress marker value is valid");
2750                push(EventKind::Note {
2751                    label: INGRESS_TIME_MARKER_NOTE.into(),
2752                    value,
2753                })?;
2754                marker_lines.push(marker_line);
2755            }
2756        }
2757        if !events.is_empty() {
2758            self.commit_events(recorded_at, events)?;
2759        }
2760        Ok(marker_lines)
2761    }
2762
2763    pub fn record(
2764        &mut self,
2765        recorded_at: impl Into<String>,
2766        kind: EventKind,
2767    ) -> anyhow::Result<EventId> {
2768        let id = EventId(self.chatend.next_id);
2769        let recorded_at = recorded_at.into();
2770        self.commit_events(
2771            recorded_at.clone(),
2772            vec![Event {
2773                id,
2774                recorded_at,
2775                kind,
2776            }],
2777        )?;
2778        Ok(id)
2779    }
2780
2781    pub fn commit_events(
2782        &mut self,
2783        recorded_at: impl Into<String>,
2784        events: Vec<Event>,
2785    ) -> anyhow::Result<()> {
2786        let transition = Transition {
2787            recorded_at: recorded_at.into(),
2788            events,
2789        };
2790        ensure!(
2791            !transition.events.is_empty(),
2792            "a transition cannot be empty"
2793        );
2794        ensure!(
2795            !transition.events.iter().any(|event| {
2796                matches!(
2797                    event.kind,
2798                    EventKind::PendingAllocated {
2799                        resource: PendingKind::Object,
2800                        ..
2801                    }
2802                )
2803            }),
2804            "pending objects must be added through stage_object"
2805        );
2806        let mut preview = self.chatend.clone();
2807        preview.apply_transition(&transition)?;
2808        for event in &transition.events {
2809            let expected = self.durable.list().events.len() as u64 + 1;
2810            ensure!(
2811                event.id.0 == expected,
2812                "Kennedy context event {} does not match session-log position {}",
2813                event.id,
2814                expected - 1
2815            );
2816            self.durable.add_event(
2817                context_event_role(&event.kind),
2818                encode_context_event(&event.recorded_at, &event.kind)?,
2819            )?;
2820        }
2821        self.chatend = preview;
2822        Ok(())
2823    }
2824
2825    pub fn apply_tool_slots(
2826        &mut self,
2827        recorded_at: impl Into<String>,
2828        tool_instance: impl Into<String>,
2829        slots: Vec<ToolSlotInput>,
2830    ) -> anyhow::Result<Vec<EventId>> {
2831        self.apply_tool_slots_inner(recorded_at, tool_instance, slots, None)
2832    }
2833
2834    pub fn apply_tool_slots_with_layout(
2835        &mut self,
2836        recorded_at: impl Into<String>,
2837        tool_instance: impl Into<String>,
2838        slots: Vec<ToolSlotInput>,
2839        layout_slots: &[String],
2840    ) -> anyhow::Result<Vec<EventId>> {
2841        self.apply_tool_slots_inner(recorded_at, tool_instance, slots, Some(layout_slots))
2842    }
2843
2844    fn apply_tool_slots_inner(
2845        &mut self,
2846        recorded_at: impl Into<String>,
2847        tool_instance: impl Into<String>,
2848        slots: Vec<ToolSlotInput>,
2849        layout_slots: Option<&[String]>,
2850    ) -> anyhow::Result<Vec<EventId>> {
2851        let recorded_at = recorded_at.into();
2852        let tool_instance = tool_instance.into();
2853        let current = self
2854            .chatend
2855            .tools
2856            .get(&tool_instance)
2857            .cloned()
2858            .unwrap_or_default();
2859        ensure!(
2860            slots.len() >= current.slots.len(),
2861            "stateful tool slot sequence was truncated"
2862        );
2863        for (index, existing) in current.slots.iter().enumerate() {
2864            ensure!(
2865                slots[index].slot == existing.slot,
2866                "stateful tool slot sequence was reordered at index {index}"
2867            );
2868            ensure!(
2869                !existing.retired || slots[index].retired,
2870                "retired tool slot {} cannot be reactivated",
2871                existing.slot
2872            );
2873        }
2874        let mut events = Vec::new();
2875        let mut next = self.chatend.next_id;
2876        let mut next_state = current.clone();
2877        for (index, input) in slots.iter().enumerate() {
2878            if let Some(existing) = current.slots.get(index) {
2879                let state = self
2880                    .chatend
2881                    .boxes
2882                    .get(&existing.box_id)
2883                    .context("tool slot references a missing box")?;
2884                if input.retired && !existing.retired {
2885                    let id = EventId(next);
2886                    next += 1;
2887                    events.push(Event {
2888                        id,
2889                        recorded_at: recorded_at.clone(),
2890                        kind: EventKind::BoxRetired {
2891                            box_id: existing.box_id,
2892                        },
2893                    });
2894                    next_state.slots[index].retired = true;
2895                } else if !input.retired {
2896                    if state.name != input.name {
2897                        let id = EventId(next);
2898                        next += 1;
2899                        events.push(Event {
2900                            id,
2901                            recorded_at: recorded_at.clone(),
2902                            kind: EventKind::BoxRenamed {
2903                                box_id: existing.box_id,
2904                                name: input.name.clone(),
2905                            },
2906                        });
2907                    }
2908                    if state.canonical.content != input.content {
2909                        let id = EventId(next);
2910                        next += 1;
2911                        events.push(Event {
2912                            id,
2913                            recorded_at: recorded_at.clone(),
2914                            kind: EventKind::CanonicalUpdated {
2915                                box_id: existing.box_id,
2916                                content: input.content.clone(),
2917                            },
2918                        });
2919                    }
2920                }
2921            } else {
2922                ensure!(
2923                    !input.retired,
2924                    "a newly appended tool slot cannot start retired"
2925                );
2926                let id = EventId(next);
2927                next += 1;
2928                let box_id = BoxId(id.0);
2929                events.push(Event {
2930                    id,
2931                    recorded_at: recorded_at.clone(),
2932                    kind: EventKind::BoxCreated {
2933                        box_id,
2934                        name: input.name.clone(),
2935                        owner: BoxOwner::Tool {
2936                            tool_instance: tool_instance.clone(),
2937                            slot: input.slot.clone(),
2938                        },
2939                        content: input.content.clone(),
2940                    },
2941                });
2942                next_state.slots.push(ToolSlot {
2943                    slot: input.slot.clone(),
2944                    box_id,
2945                    retired: false,
2946                });
2947            }
2948        }
2949        if let Some(layout_slots) = layout_slots {
2950            let mut unique = std::collections::HashSet::new();
2951            let box_ids = layout_slots
2952                .iter()
2953                .map(|slot_name| {
2954                    ensure!(
2955                        unique.insert(slot_name),
2956                        "tool layout contains duplicate slot {slot_name}"
2957                    );
2958                    let slot = next_state
2959                        .slots
2960                        .iter()
2961                        .find(|slot| &slot.slot == slot_name)
2962                        .with_context(|| {
2963                            format!("tool layout references missing slot {slot_name}")
2964                        })?;
2965                    ensure!(
2966                        !slot.retired,
2967                        "tool layout references retired slot {slot_name}"
2968                    );
2969                    Ok(slot.box_id)
2970                })
2971                .collect::<anyhow::Result<Vec<_>>>()?;
2972            if self.chatend.tool_layouts.get(&tool_instance) != Some(&box_ids) {
2973                let id = EventId(next);
2974                events.push(Event {
2975                    id,
2976                    recorded_at: recorded_at.clone(),
2977                    kind: EventKind::ToolLayoutChanged {
2978                        tool_instance: tool_instance.clone(),
2979                        box_ids,
2980                    },
2981                });
2982            }
2983        }
2984        if events.is_empty() {
2985            return Ok(Vec::new());
2986        }
2987        let ids = events.iter().map(|event| event.id).collect::<Vec<_>>();
2988        self.commit_events(recorded_at, events)?;
2989        Ok(ids)
2990    }
2991
2992    pub fn apply_box_representations(
2993        &mut self,
2994        recorded_at: impl Into<String>,
2995        desired: &BTreeMap<BoxId, BoxRepresentation>,
2996    ) -> anyhow::Result<Vec<EventId>> {
2997        let recorded_at = recorded_at.into();
2998        let events = self
2999            .chatend
3000            .box_representation_events(&recorded_at, desired)?;
3001        if events.is_empty() {
3002            return Ok(Vec::new());
3003        }
3004        let ids = events.iter().map(|event| event.id).collect::<Vec<_>>();
3005        self.commit_events(recorded_at, events)?;
3006        Ok(ids)
3007    }
3008}
3009
3010/// Narrow integration surface used by the `kcode-session-history` facade.
3011///
3012/// Ordinary callers should create and reopen sessions through
3013/// `kcode_session_history::SessionHistory`. Keeping construction and raw-log
3014/// replay here lets that facade retain sole ownership of session lifecycle
3015/// while this crate owns Chatend's mechanical persistence and projection.
3016pub struct SessionHistoryIntegration;
3017
3018impl SessionHistoryIntegration {
3019    /// Creates a durable Chatend session for the history facade.
3020    pub fn create_session(
3021        path: impl AsRef<Path>,
3022        metadata: SessionMetadata,
3023    ) -> anyhow::Result<Session> {
3024        Session::create(path, metadata)
3025    }
3026
3027    /// Reopens a durable Chatend session for the history facade.
3028    ///
3029    /// When `estimator` is present, historical provider receipts that predate
3030    /// stored cost fields are projected using `default_provider_model` and the
3031    /// supplied estimator without rewriting durable history.
3032    pub fn open_session(
3033        path: impl AsRef<Path>,
3034        metadata: SessionMetadata,
3035        default_provider_model: Option<&str>,
3036        estimator: Option<ProviderCostEstimator>,
3037    ) -> anyhow::Result<Session> {
3038        match (default_provider_model, estimator) {
3039            (None, None) => Session::open_with_metadata(path, metadata),
3040            (default_provider_model, estimator) => Session::open_with_metadata_and_provider_costs(
3041                path,
3042                metadata,
3043                default_provider_model,
3044                estimator,
3045            ),
3046        }
3047    }
3048
3049    /// Replays a raw session log into exact Chatend state for presentation.
3050    ///
3051    /// Optional compatibility pricing affects only the returned projection and
3052    /// never mutates the supplied log.
3053    pub fn replay(
3054        metadata: SessionMetadata,
3055        log: &kcode_session_log::SessionLog,
3056        default_provider_model: Option<&str>,
3057        estimator: Option<ProviderCostEstimator>,
3058    ) -> anyhow::Result<Chatend> {
3059        match (default_provider_model, estimator) {
3060            (None, None) => Chatend::replay(metadata, log),
3061            (default_provider_model, estimator) => Chatend::replay_with_provider_costs(
3062                metadata,
3063                log,
3064                default_provider_model,
3065                estimator,
3066            ),
3067        }
3068    }
3069
3070    /// Reconstructs compatible cost fields from an immutable session archive.
3071    ///
3072    /// The archive is not modified.
3073    pub fn legacy_provider_cost_summary_for_archive(
3074        archive: &Value,
3075        default_provider_model: Option<&str>,
3076        estimator: ProviderCostEstimator,
3077    ) -> anyhow::Result<ProviderCostSummary> {
3078        legacy_provider_cost_summary_for_archive(archive, default_provider_model, estimator)
3079    }
3080}
3081
3082#[cfg(test)]
3083type SessionJournal = Session;
3084
3085#[cfg(test)]
3086mod tests {
3087    use std::path::PathBuf;
3088    use std::time::{SystemTime, UNIX_EPOCH};
3089
3090    use serde_json::json;
3091
3092    use super::*;
3093
3094    fn path(label: &str) -> PathBuf {
3095        std::env::temp_dir()
3096            .join(format!(
3097                "kennedy-chatend-{label}-{}-{}",
3098                std::process::id(),
3099                SystemTime::now()
3100                    .duration_since(UNIX_EPOCH)
3101                    .unwrap()
3102                    .as_nanos()
3103            ))
3104            .join("session-1.session-log")
3105    }
3106
3107    fn metadata() -> SessionMetadata {
3108        SessionMetadata {
3109            session_id: "session-1".into(),
3110            kind: SessionKind::Conversation,
3111            created_at: "2026-07-23T00:00:00Z".into(),
3112            effective_context_tokens: 1_000,
3113            channel: json!({"kind":"test"}),
3114        }
3115    }
3116
3117    #[test]
3118    fn box_identity_continuations_staleness_and_replay_are_exact() {
3119        let path = path("boxes");
3120        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3121        let id = journal
3122            .create_box(
3123                "t1",
3124                "message",
3125                BoxOwner::User,
3126                BoxContent::text("original"),
3127            )
3128            .unwrap();
3129        assert_eq!(id, BoxId(1));
3130        journal.summarize_box("t2", id, "summary").unwrap();
3131        journal
3132            .update_box("t3", id, BoxContent::text("changed"))
3133            .unwrap();
3134        let state = journal.state().box_state(id).unwrap().clone();
3135        assert!(state.stale());
3136        assert_eq!(
3137            state.representation,
3138            Representation::Summarized {
3139                based_on: EventId(1),
3140                text: "summary".into()
3141            }
3142        );
3143        let projection = journal.state().projection();
3144        assert_eq!(projection.items[0].text, "[box updated]");
3145        assert_eq!(projection.items[1].text, "[box updated]");
3146        assert!(projection.items[2].text.contains("summary"));
3147        assert!(projection.items[2].stale);
3148        assert!(projection.footer.is_empty());
3149        assert!(projection.render().ends_with("summary"));
3150
3151        let transient = vec![
3152            "[provider calls remaining after this call: 249 | max provider calls: 250]".into(),
3153            "[provider call time remaining: 3h 45m]".into(),
3154        ];
3155        let with_limits = journal.state().projection_with_footer_lines(&transient);
3156        assert_eq!(with_limits.footer, transient.join("\n"));
3157        assert!(with_limits.render().ends_with(&transient.join("\n")));
3158        assert!(with_limits.context_bytes > projection.context_bytes);
3159        drop(journal);
3160        let reopened = SessionJournal::open_with_metadata(&path, metadata()).unwrap();
3161        assert_eq!(reopened.state().box_state(id), Some(&state));
3162        std::fs::remove_file(path).unwrap();
3163    }
3164
3165    #[test]
3166    fn box_headers_hide_internal_ownership_and_timestamp_messages() {
3167        let path = path("box-headers");
3168        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3169        journal
3170            .create_box(
3171                "2026-07-23T00:00:01Z",
3172                "User message",
3173                BoxOwner::User,
3174                BoxContent::text("Hello"),
3175            )
3176            .unwrap();
3177        journal
3178            .create_box(
3179                "2026-07-23T00:00:02Z",
3180                "Kennedy message",
3181                BoxOwner::Kennedy,
3182                BoxContent::text("Hi"),
3183            )
3184            .unwrap();
3185        let mut content = BoxContent::text("Node ID: AAAAAAAB");
3186        content.use_concise_header();
3187        journal
3188            .create_box(
3189                "2026-07-23T00:00:03Z",
3190                "Kweb loaded node",
3191                BoxOwner::Tool {
3192                    tool_instance: "kweb".into(),
3193                    slot: "loaded".into(),
3194                },
3195                content,
3196            )
3197            .unwrap();
3198
3199        let projection = journal.state().projection();
3200        assert_eq!(
3201            projection.items[0].text,
3202            "[box 1 | User message | timestamp=2026-07-23T00:00:01Z | hydrated]\nHello"
3203        );
3204        assert_eq!(
3205            projection.items[1].text,
3206            "[box 2 | Kennedy message | timestamp=2026-07-23T00:00:02Z | hydrated]\nHi"
3207        );
3208        assert_eq!(
3209            projection.items[2].text,
3210            "[box 3 | Kweb loaded node | hydrated]\nNode ID: AAAAAAAB"
3211        );
3212        assert!(
3213            projection
3214                .items
3215                .iter()
3216                .all(|item| !item.text.contains("owner="))
3217        );
3218        assert!(projection.footer.is_empty());
3219        std::fs::remove_file(path).unwrap();
3220    }
3221
3222    #[test]
3223    fn provider_preparation_anchors_sparse_markers_and_resets_rewritten_epochs() {
3224        let path = path("provider-markers");
3225        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3226        let first_box = journal
3227            .create_box(
3228                "t1",
3229                "System prompt",
3230                BoxOwner::System,
3231                BoxContent::text("x".repeat(1_000)),
3232            )
3233            .unwrap();
3234        let first = journal
3235            .prepare_provider_projection(
3236                "t2",
3237                &["[provider calls remaining: 9]".into()],
3238                "m1",
3239                None,
3240            )
3241            .unwrap();
3242        assert_eq!(first.expectation, CacheExpectation::ColdStart);
3243        assert!(first.projection.render().contains("[current context size:"));
3244        assert!(
3245            first
3246                .projection
3247                .render()
3248                .ends_with("[provider calls remaining: 9]")
3249        );
3250        let first_input = first.projection.render();
3251        let synchronized_event_id = journal
3252            .record(
3253                "t3",
3254                EventKind::ProviderInputSubmitted {
3255                    round: 1,
3256                    context: provider_context(first_input.clone()),
3257                    transport_input_hash: None,
3258                    transport_input_bytes: None,
3259                    thread_action: None,
3260                    thread_reset_reason: None,
3261                    cacheable_prefix_bytes: first.cacheable_prefix_bytes,
3262                    material_fingerprint: "m1".into(),
3263                    cache_expectation: first.expectation.label().into(),
3264                    planned_invalidation_reason: None,
3265                },
3266            )
3267            .unwrap();
3268        let archive: Value = serde_json::from_slice(&journal.archive_bytes().unwrap()).unwrap();
3269        assert_eq!(archive["chatendText"], first_input);
3270        assert_eq!(archive["chatendTextSource"], "submitted");
3271        assert_eq!(archive["structuredMaterial"]["provider"], "codex");
3272        assert_eq!(archive["structuredMaterial"]["model"], "test");
3273        journal
3274            .create_box(
3275                "t4",
3276                "User message",
3277                BoxOwner::User,
3278                BoxContent::text("next"),
3279            )
3280            .unwrap();
3281        let warm = journal
3282            .prepare_provider_projection("t5", &[], "m1", Some(synchronized_event_id))
3283            .unwrap();
3284        assert_eq!(warm.expectation, CacheExpectation::ExpectedWarm);
3285        assert!(warm.provider_input.contains("next"));
3286        assert!(!warm.provider_input.contains(&"x".repeat(1_000)));
3287        assert!(warm.thread_reset_reason.is_none());
3288
3289        journal.summarize_box("t6", first_box, "short").unwrap();
3290        let reset = journal
3291            .prepare_provider_projection("t7", &[], "m1", Some(synchronized_event_id))
3292            .unwrap();
3293        assert_eq!(
3294            reset.expectation,
3295            CacheExpectation::PlannedInvalidation {
3296                reason: "summarization".into()
3297            }
3298        );
3299        assert_eq!(reset.thread_reset_reason.as_deref(), Some("summarization"));
3300        assert_eq!(reset.provider_input, reset.projection.render());
3301        assert!(!reset.projection.render().contains("[current context size:"));
3302        assert_eq!(
3303            journal
3304                .state()
3305                .events
3306                .iter()
3307                .filter(|event| matches!(event.kind, EventKind::ProjectionMarkersReset))
3308                .count(),
3309            2
3310        );
3311        std::fs::remove_file(path).unwrap();
3312    }
3313
3314    #[test]
3315    fn provider_resume_markers_follow_growth_and_rewritten_epochs() {
3316        let path = path("provider-resume-markers");
3317        let mut journal = SessionJournal::create(
3318            &path,
3319            SessionMetadata {
3320                effective_context_tokens: 100_000,
3321                ..metadata()
3322            },
3323        )
3324        .unwrap();
3325        let first_box = journal
3326            .create_box(
3327                "t1",
3328                "System prompt",
3329                BoxOwner::System,
3330                BoxContent::text("small"),
3331            )
3332            .unwrap();
3333        let initial = journal
3334            .prepare_provider_projection("t2", &[], "m1", None)
3335            .unwrap();
3336        assert!(
3337            !initial
3338                .projection
3339                .render()
3340                .contains("[current context size:")
3341        );
3342        let mut synchronized_after = journal
3343            .record(
3344                "t3",
3345                EventKind::ProviderInputSubmitted {
3346                    round: 1,
3347                    context: provider_context(initial.projection.render()),
3348                    transport_input_hash: None,
3349                    transport_input_bytes: None,
3350                    thread_action: None,
3351                    thread_reset_reason: None,
3352                    cacheable_prefix_bytes: initial.cacheable_prefix_bytes,
3353                    material_fingerprint: "m1".into(),
3354                    cache_expectation: initial.expectation.label().into(),
3355                    planned_invalidation_reason: None,
3356                },
3357            )
3358            .unwrap();
3359
3360        journal
3361            .create_box(
3362                "t4",
3363                "large result",
3364                BoxOwner::Controller,
3365                BoxContent::text("x".repeat(130_000)),
3366            )
3367            .unwrap();
3368        let first = journal
3369            .prepare_provider_resume_markers("t5", synchronized_after, None)
3370            .unwrap();
3371        assert_eq!(first.len(), 1);
3372        assert!(first[0].starts_with("[current context size: approximately "));
3373
3374        synchronized_after = journal.state().events.last().unwrap().id;
3375        journal
3376            .create_box(
3377                "t6",
3378                "small growth",
3379                BoxOwner::Controller,
3380                BoxContent::text("y".repeat(20_000)),
3381            )
3382            .unwrap();
3383        assert!(
3384            journal
3385                .prepare_provider_resume_markers("t7", synchronized_after, None)
3386                .unwrap()
3387                .is_empty()
3388        );
3389
3390        synchronized_after = journal.state().events.last().unwrap().id;
3391        journal
3392            .create_box(
3393                "t8",
3394                "large growth",
3395                BoxOwner::Controller,
3396                BoxContent::text("z".repeat(40_000)),
3397            )
3398            .unwrap();
3399        let second = journal
3400            .prepare_provider_resume_markers("t9", synchronized_after, None)
3401            .unwrap();
3402        assert_eq!(second.len(), 1);
3403        assert!(second[0].starts_with("[current context size: approximately "));
3404
3405        synchronized_after = journal.state().events.last().unwrap().id;
3406        journal
3407            .update_box(
3408                "t10",
3409                first_box,
3410                BoxContent::text("rewritten system prompt"),
3411            )
3412            .unwrap();
3413        let reset = journal
3414            .prepare_provider_resume_markers("t11", synchronized_after, None)
3415            .unwrap();
3416        assert_eq!(reset.len(), 1);
3417        assert!(reset[0].starts_with("[current context size: approximately "));
3418        assert_eq!(
3419            journal
3420                .state()
3421                .events
3422                .iter()
3423                .filter(|event| matches!(event.kind, EventKind::ProjectionMarkersReset))
3424                .count(),
3425            2,
3426        );
3427        std::fs::remove_file(path).unwrap();
3428    }
3429
3430    fn provider_context(input: String) -> ProviderContext {
3431        ProviderContext {
3432            input,
3433            provider: "codex".into(),
3434            model: "test".into(),
3435            reasoning_effort: "xhigh".into(),
3436            base_instructions: Some("base".into()),
3437            developer_instructions: Some(String::new()),
3438            tools: Vec::new(),
3439        }
3440    }
3441
3442    #[test]
3443    fn shared_pending_and_box_identity_space_never_overlaps() {
3444        let path = path("pending");
3445        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3446        let first = journal.allocate_pending_node("t1").unwrap();
3447        let box_id = journal
3448            .create_box("t2", "box", BoxOwner::Kennedy, BoxContent::text("hello"))
3449            .unwrap();
3450        let object = journal
3451            .stage_object(
3452                "t3",
3453                "application/octet-stream",
3454                None,
3455                Value::Null,
3456                b"\0binary\xff",
3457            )
3458            .unwrap();
3459        assert_eq!(first.to_string(), "pending:1");
3460        assert_eq!(box_id, BoxId(2));
3461        assert_eq!(object.to_string(), "pending:3");
3462        assert_eq!(journal.read_object(&object).unwrap(), b"\0binary\xff");
3463        let stored = journal.session_log();
3464        assert!(!stored.events[0].text.contains("pending_id"));
3465        assert!(!stored.events[1].text.contains("box_id"));
3466        assert!(!stored.events[2].text.contains("pending_id"));
3467        drop(journal);
3468        let mut reopened = SessionJournal::open_with_metadata(&path, metadata()).unwrap();
3469        assert_eq!(reopened.read_object(&object).unwrap(), b"\0binary\xff");
3470        assert_eq!(reopened.state().next_id, 4);
3471        std::fs::remove_file(path).unwrap();
3472    }
3473
3474    #[test]
3475    fn kennedy_tool_completion_is_validated_before_storage_seals() {
3476        let path = path("seal-tool");
3477        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3478        journal
3479            .record(
3480                "t1",
3481                EventKind::ToolInvoked {
3482                    tool_instance: "CreateNode:1".into(),
3483                    tool_name: "CreateNode".into(),
3484                    arguments: json!({}),
3485                    invocation_id: None,
3486                },
3487            )
3488            .unwrap();
3489        assert!(journal.seal().is_err());
3490        journal
3491            .record(
3492                "t2",
3493                EventKind::ToolCompleted {
3494                    tool_instance: "call_ktool".into(),
3495                    tool_name: "call_ktool".into(),
3496                    outcome: json!({"ok":true}),
3497                    invocation_id: None,
3498                },
3499            )
3500            .unwrap();
3501        journal.seal().unwrap();
3502        assert!(journal.is_sealed());
3503        std::fs::remove_file(path).unwrap();
3504    }
3505
3506    #[test]
3507    fn interrupted_tools_are_recovered_by_identity_without_losing_legacy_journals() {
3508        let path = path("repair-tools");
3509        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3510        journal
3511            .record(
3512                "t1",
3513                EventKind::ToolInvoked {
3514                    tool_instance: "WebSearch:legacy".into(),
3515                    tool_name: "WebSearch".into(),
3516                    arguments: json!({"question":"legacy"}),
3517                    invocation_id: None,
3518                },
3519            )
3520            .unwrap();
3521        journal
3522            .record(
3523                "t2",
3524                EventKind::ToolInvoked {
3525                    tool_instance: "WebSearch:new".into(),
3526                    tool_name: "WebSearch".into(),
3527                    arguments: json!({"question":"identified"}),
3528                    invocation_id: Some("call-1".into()),
3529                },
3530            )
3531            .unwrap();
3532        assert!(journal.seal().is_err());
3533
3534        let repaired = journal.repair_unfinished_tools("recovery").unwrap();
3535        assert_eq!(repaired.len(), 2);
3536        assert!(
3537            journal
3538                .state()
3539                .events
3540                .iter()
3541                .rev()
3542                .take(2)
3543                .all(|event| matches!(
3544                    &event.kind,
3545                    EventKind::ToolCompleted { outcome, .. }
3546                        if outcome.get("recovered").and_then(Value::as_bool) == Some(true)
3547                ))
3548        );
3549        journal.seal().unwrap();
3550        assert!(journal.is_sealed());
3551        std::fs::remove_file(path).unwrap();
3552    }
3553
3554    #[test]
3555    fn identified_tool_completions_can_arrive_out_of_order() {
3556        let path = path("tool-identity");
3557        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3558        for id in ["call-1", "call-2"] {
3559            journal
3560                .record(
3561                    id,
3562                    EventKind::ToolInvoked {
3563                        tool_instance: format!("WebSearch:{id}"),
3564                        tool_name: "WebSearch".into(),
3565                        arguments: json!({"question":id}),
3566                        invocation_id: Some(id.into()),
3567                    },
3568                )
3569                .unwrap();
3570        }
3571        for id in ["call-1", "call-2"] {
3572            journal
3573                .record(
3574                    format!("{id}-complete"),
3575                    EventKind::ToolCompleted {
3576                        tool_instance: format!("WebSearch:{id}"),
3577                        tool_name: "WebSearch".into(),
3578                        outcome: json!({"ok":true}),
3579                        invocation_id: Some(id.into()),
3580                    },
3581                )
3582                .unwrap();
3583        }
3584        journal.seal().unwrap();
3585        std::fs::remove_file(path).unwrap();
3586    }
3587
3588    #[test]
3589    fn invalid_box_operations_do_not_poison_the_append_only_journal() {
3590        let path = path("invalid-box-operation");
3591        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3592        let box_id = journal
3593            .create_box(
3594                "t1",
3595                "valid",
3596                BoxOwner::Controller,
3597                BoxContent::text("canonical"),
3598            )
3599            .unwrap();
3600        let valid_length = std::fs::metadata(&path).unwrap().len();
3601
3602        let result = journal.dehydrate_boxes("t2", &[box_id, BoxId(97)]);
3603        assert_eq!(result.unwrap_err().to_string(), "box 97 does not exist");
3604        assert_eq!(std::fs::metadata(&path).unwrap().len(), valid_length);
3605        assert!(matches!(
3606            journal.state().box_state(box_id).unwrap().representation,
3607            Representation::Hydrated { .. }
3608        ));
3609
3610        for result in [
3611            journal.summarize_box("t3", BoxId(97), "summary"),
3612            journal.rehydrate_box("t4", BoxId(97)),
3613            journal.retire_box("t5", BoxId(97)),
3614        ] {
3615            assert_eq!(result.unwrap_err().to_string(), "box 97 does not exist");
3616            assert_eq!(std::fs::metadata(&path).unwrap().len(), valid_length);
3617        }
3618
3619        drop(journal);
3620        let reopened = SessionJournal::open_with_metadata(&path, metadata()).unwrap();
3621        assert_eq!(
3622            reopened
3623                .state()
3624                .box_state(box_id)
3625                .unwrap()
3626                .canonical
3627                .content,
3628            BoxContent::text("canonical")
3629        );
3630        std::fs::remove_file(path).unwrap();
3631    }
3632
3633    #[test]
3634    fn multiple_boxes_dehydrate_in_one_replayable_batch() {
3635        let path = path("dehydrate-boxes");
3636        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3637        let boxes = ["first", "second", "third"]
3638            .into_iter()
3639            .map(|text| {
3640                journal
3641                    .create_box("t1", text, BoxOwner::Controller, BoxContent::text(text))
3642                    .unwrap()
3643            })
3644            .collect::<Vec<_>>();
3645
3646        let event_ids = journal
3647            .dehydrate_boxes("t2", &[boxes[0], boxes[2]])
3648            .unwrap();
3649        assert_eq!(event_ids, [EventId(4), EventId(5)]);
3650        assert!(matches!(
3651            journal.state().box_state(boxes[0]).unwrap().representation,
3652            Representation::Dehydrated { .. }
3653        ));
3654        assert!(matches!(
3655            journal.state().box_state(boxes[1]).unwrap().representation,
3656            Representation::Hydrated { .. }
3657        ));
3658        assert!(matches!(
3659            journal.state().box_state(boxes[2]).unwrap().representation,
3660            Representation::Dehydrated { .. }
3661        ));
3662
3663        let valid_length = std::fs::metadata(&path).unwrap().len();
3664        assert_eq!(
3665            journal.dehydrate_boxes("t3", &[]).unwrap_err().to_string(),
3666            "at least one box must be selected for dehydration"
3667        );
3668        assert_eq!(
3669            journal
3670                .dehydrate_boxes("t3", &[boxes[1], boxes[1]])
3671                .unwrap_err()
3672                .to_string(),
3673            "box dehydration cannot contain duplicate box IDs"
3674        );
3675        assert_eq!(std::fs::metadata(&path).unwrap().len(), valid_length);
3676
3677        drop(journal);
3678        let reopened = SessionJournal::open_with_metadata(&path, metadata()).unwrap();
3679        assert!(matches!(
3680            reopened.state().box_state(boxes[0]).unwrap().representation,
3681            Representation::Dehydrated { .. }
3682        ));
3683        assert!(matches!(
3684            reopened.state().box_state(boxes[2]).unwrap().representation,
3685            Representation::Dehydrated { .. }
3686        ));
3687        std::fs::remove_file(path).unwrap();
3688    }
3689
3690    #[test]
3691    fn status_estimates_the_context_with_every_active_box_hydrated() {
3692        let path = path("fully-hydrated-estimate");
3693        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3694        let box_id = journal
3695            .create_box(
3696                "t1",
3697                "large",
3698                BoxOwner::Controller,
3699                BoxContent::text("canonical material ".repeat(1_000)),
3700            )
3701            .unwrap();
3702        journal.dehydrate_boxes("t2", &[box_id]).unwrap();
3703
3704        let status = journal.state().projection().status;
3705        assert!(
3706            status.fully_hydrated_context_tokens > status.current_context_tokens,
3707            "the hydrated estimate should include the canonical body"
3708        );
3709        std::fs::remove_file(path).unwrap();
3710    }
3711
3712    #[test]
3713    fn provider_measurements_recalibrate_the_matching_manifest_then_track_deltas() {
3714        let path = path("calibration");
3715        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3716        journal
3717            .create_box(
3718                "t1",
3719                "system",
3720                BoxOwner::System,
3721                BoxContent::text("baseline provider content"),
3722            )
3723            .unwrap();
3724        let submitted = journal.state().projection();
3725        journal
3726            .record(
3727                "t2",
3728                EventKind::InferenceSubmitted {
3729                    manifest_hash: "manifest-1".into(),
3730                    estimated_input_tokens: submitted.estimated_tokens,
3731                    raw_estimated_input_tokens: Some(submitted.raw_estimated_tokens),
3732                },
3733            )
3734            .unwrap();
3735        let tool_box = journal
3736            .create_box(
3737                "t3",
3738                "tool result",
3739                BoxOwner::Controller,
3740                BoxContent::text("x".repeat(3_000)),
3741            )
3742            .unwrap();
3743        let measured_context = journal.state().projection();
3744        journal
3745            .record(
3746                "t4",
3747                EventKind::ProviderReceipt {
3748                    manifest_hash: "manifest-1".into(),
3749                    input_tokens: Some(777),
3750                    output_tokens: Some(3),
3751                    context_bytes: Some(measured_context.context_bytes),
3752                    raw_context_tokens: Some(measured_context.raw_estimated_tokens),
3753                    provider_data: Value::Null,
3754                },
3755            )
3756            .unwrap();
3757        assert_eq!(journal.state().projection().estimated_tokens, 777);
3758        journal
3759            .create_box(
3760                "t5",
3761                "new material",
3762                BoxOwner::User,
3763                BoxContent::text("x".repeat(300)),
3764            )
3765            .unwrap();
3766        let expanded = journal.state().projection();
3767        assert_eq!(
3768            expanded.estimated_tokens,
3769            777 + expanded
3770                .context_bytes
3771                .abs_diff(measured_context.context_bytes)
3772                / ESTIMATED_BYTES_PER_TOKEN
3773        );
3774        assert!(expanded.raw_estimated_tokens > submitted.raw_estimated_tokens);
3775        journal.dehydrate_boxes("t6", &[tool_box]).unwrap();
3776        let shrunken = journal.state().projection();
3777        assert_eq!(
3778            shrunken.estimated_tokens,
3779            777_u64.saturating_sub(
3780                measured_context
3781                    .context_bytes
3782                    .abs_diff(shrunken.context_bytes)
3783                    / ESTIMATED_BYTES_PER_TOKEN
3784            )
3785        );
3786        std::fs::remove_file(path).unwrap();
3787    }
3788
3789    #[test]
3790    fn session_status_keeps_provider_usage_categories_exact_and_exclusive() {
3791        let path = path("session-status");
3792        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3793        journal
3794            .create_box("t1", "system", BoxOwner::System, BoxContent::text("é"))
3795            .unwrap();
3796        let before_usage = journal.state().projection();
3797        assert_eq!(
3798            before_usage.raw_estimated_tokens,
3799            before_usage
3800                .context_bytes
3801                .div_ceil(ESTIMATED_BYTES_PER_TOKEN)
3802        );
3803        journal
3804            .record(
3805                "t2",
3806                EventKind::ProviderReceipt {
3807                    manifest_hash: "manifest-1".into(),
3808                    input_tokens: Some(100),
3809                    output_tokens: Some(20),
3810                    context_bytes: Some(before_usage.context_bytes),
3811                    raw_context_tokens: Some(before_usage.raw_estimated_tokens),
3812                    provider_data: json!({
3813                        "usageIsDelta":true,
3814                        "cachedInputTokens":40,
3815                        "nonCachedInputTokens":60,
3816                        "thinkingTokens":8,
3817                        "outputTokens":12,
3818                        "estimatedCostUsdNanos":12345
3819                    }),
3820                },
3821            )
3822            .unwrap();
3823        let second_anchor = journal.state().projection();
3824        journal
3825            .record(
3826                "t3",
3827                EventKind::ProviderReceipt {
3828                    manifest_hash: "manifest-1".into(),
3829                    input_tokens: Some(120),
3830                    output_tokens: Some(5),
3831                    context_bytes: Some(second_anchor.context_bytes),
3832                    raw_context_tokens: Some(second_anchor.raw_estimated_tokens),
3833                    provider_data: json!({
3834                        "usageIsDelta":true,
3835                        "cachedInputTokens":10,
3836                        "nonCachedInputTokens":10,
3837                        "thinkingTokens":2,
3838                        "outputTokens":3,
3839                        "estimatedCostUsdNanos":6789
3840                    }),
3841                },
3842            )
3843            .unwrap();
3844        let projection = journal.state().projection();
3845        assert_eq!(
3846            projection.status,
3847            SessionStatus {
3848                current_context_tokens: 120,
3849                fully_hydrated_context_tokens: 120,
3850                context_limit_tokens: 700,
3851                current_context_bytes: projection.context_bytes,
3852                cached_input_tokens: 50,
3853                non_cached_input_tokens: 70,
3854                thinking_tokens: 10,
3855                output_tokens: 15,
3856                estimated_cost_usd_nanos: 19_134,
3857                unpriced_provider_calls: 0,
3858            }
3859        );
3860        let archive: Value = serde_json::from_slice(&journal.archive_bytes().unwrap()).unwrap();
3861        assert_eq!(archive["context"]["status"]["cachedInputTokens"], 50);
3862        assert!(
3863            archive["chatendText"]
3864                .as_str()
3865                .unwrap()
3866                .ends_with(archive["context"]["footer"].as_str().unwrap())
3867        );
3868        std::fs::remove_file(path).unwrap();
3869    }
3870
3871    fn compatibility_cost(
3872        model: &str,
3873        metering: &ProviderMetering,
3874    ) -> Option<ProviderCostEstimate> {
3875        let ProviderMetering::Tokens(usage) = metering else {
3876            return None;
3877        };
3878        let base = match model {
3879            "gpt-5.6-sol" => 1_000,
3880            "gemini-3.1-pro-preview" => 2_000,
3881            _ => return None,
3882        };
3883        Some(ProviderCostEstimate {
3884            usd_nanos: base + usage.input_tokens,
3885            accuracy: json!("exact"),
3886            pricing_version: "test-prices".into(),
3887        })
3888    }
3889
3890    #[test]
3891    fn legacy_provider_costs_are_reconstructed_without_rewriting_history() {
3892        let path = path("legacy-provider-costs");
3893        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3894        journal
3895            .record(
3896                "t1",
3897                EventKind::ProviderReceipt {
3898                    manifest_hash: "top".into(),
3899                    input_tokens: Some(10),
3900                    output_tokens: Some(1),
3901                    context_bytes: None,
3902                    raw_context_tokens: None,
3903                    provider_data: json!({
3904                        "usageIsDelta":true,
3905                        "nonCachedInputTokens":10,
3906                        "cachedInputTokens":0,
3907                        "thinkingTokens":0,
3908                        "outputTokens":1
3909                    }),
3910                },
3911            )
3912            .unwrap();
3913        journal
3914            .record(
3915                "t2",
3916                EventKind::ToolInvoked {
3917                    tool_instance: "RunSubagent:1".into(),
3918                    tool_name: "RunSubagent".into(),
3919                    arguments: json!({"model":"codex/gpt-5.6-sol"}),
3920                    invocation_id: Some("subagent-1".into()),
3921                },
3922            )
3923            .unwrap();
3924        journal
3925            .record(
3926                "t3",
3927                EventKind::Note {
3928                    label: "subagent_started".into(),
3929                    value: json!({"providerModel":"gpt-5.6-sol"}),
3930                },
3931            )
3932            .unwrap();
3933        journal
3934            .record(
3935                "t4",
3936                EventKind::ProviderReceipt {
3937                    manifest_hash: "subagent".into(),
3938                    input_tokens: None,
3939                    output_tokens: None,
3940                    context_bytes: None,
3941                    raw_context_tokens: None,
3942                    provider_data: json!({
3943                        "source":"subagent",
3944                        "usageIsDelta":true,
3945                        "nonCachedInputTokens":20,
3946                        "cachedInputTokens":0,
3947                        "thinkingTokens":0,
3948                        "outputTokens":1
3949                    }),
3950                },
3951            )
3952            .unwrap();
3953        journal
3954            .record(
3955                "t5",
3956                EventKind::ToolInvoked {
3957                    tool_instance: "AnnotateMedia:1".into(),
3958                    tool_name: "AnnotateMedia".into(),
3959                    arguments: json!({"model":"gemini-3.1-pro-preview"}),
3960                    invocation_id: Some("media-1".into()),
3961                },
3962            )
3963            .unwrap();
3964        journal
3965            .record(
3966                "t6",
3967                EventKind::ProviderReceipt {
3968                    manifest_hash: "media".into(),
3969                    input_tokens: None,
3970                    output_tokens: None,
3971                    context_bytes: None,
3972                    raw_context_tokens: None,
3973                    provider_data: json!({
3974                        "source":"media_annotation",
3975                        "usageIsDelta":true,
3976                        "nonCachedInputTokens":30,
3977                        "cachedInputTokens":0,
3978                        "thinkingTokens":0,
3979                        "outputTokens":1
3980                    }),
3981                },
3982            )
3983            .unwrap();
3984        journal
3985            .record(
3986                "t7",
3987                EventKind::ToolCompleted {
3988                    tool_instance: "AnnotateMedia:1".into(),
3989                    tool_name: "AnnotateMedia".into(),
3990                    outcome: json!({"ok":true}),
3991                    invocation_id: Some("media-1".into()),
3992                },
3993            )
3994            .unwrap();
3995        journal
3996            .record(
3997                "t8",
3998                EventKind::ToolCompleted {
3999                    tool_instance: "RunSubagent:1".into(),
4000                    tool_name: "RunSubagent".into(),
4001                    outcome: json!({"ok":true}),
4002                    invocation_id: Some("subagent-1".into()),
4003                },
4004            )
4005            .unwrap();
4006        journal
4007            .record(
4008                "t9",
4009                EventKind::ProviderReceipt {
4010                    manifest_hash: "unknown".into(),
4011                    input_tokens: None,
4012                    output_tokens: None,
4013                    context_bytes: None,
4014                    raw_context_tokens: None,
4015                    provider_data: json!({
4016                        "source":"web_search",
4017                        "usageIsDelta":true,
4018                        "nonCachedInputTokens":40,
4019                        "cachedInputTokens":0,
4020                        "thinkingTokens":0,
4021                        "outputTokens":1
4022                    }),
4023                },
4024            )
4025            .unwrap();
4026        drop(journal);
4027
4028        let compatible = SessionJournal::open_with_metadata_and_provider_costs(
4029            &path,
4030            metadata(),
4031            Some("gpt-5.6-sol"),
4032            Some(compatibility_cost),
4033        )
4034        .unwrap();
4035        let status = &compatible.state().projection().status;
4036        assert_eq!(status.estimated_cost_usd_nanos, 4_060);
4037        assert_eq!(status.unpriced_provider_calls, 1);
4038        let inferred_models = compatible
4039            .state()
4040            .events
4041            .iter()
4042            .filter_map(|event| match &event.kind {
4043                EventKind::ProviderReceipt { provider_data, .. } => provider_data
4044                    .get("providerModel")
4045                    .and_then(Value::as_str)
4046                    .map(str::to_owned),
4047                _ => None,
4048            })
4049            .collect::<Vec<_>>();
4050        assert_eq!(
4051            inferred_models,
4052            vec!["gpt-5.6-sol", "gpt-5.6-sol", "gemini-3.1-pro-preview"]
4053        );
4054        drop(compatible);
4055
4056        let unchanged = SessionJournal::open_with_metadata(&path, metadata()).unwrap();
4057        assert_eq!(
4058            unchanged
4059                .state()
4060                .projection()
4061                .status
4062                .estimated_cost_usd_nanos,
4063            0
4064        );
4065        assert_eq!(
4066            unchanged
4067                .state()
4068                .projection()
4069                .status
4070                .unpriced_provider_calls,
4071            4
4072        );
4073        let archive: Value = serde_json::from_slice(&unchanged.archive_bytes().unwrap()).unwrap();
4074        let archive_summary = legacy_provider_cost_summary_for_archive(
4075            &archive,
4076            Some("gpt-5.6-sol"),
4077            compatibility_cost,
4078        )
4079        .unwrap();
4080        assert_eq!(archive_summary.estimated_cost_usd_nanos, 4_060);
4081        assert_eq!(archive_summary.unpriced_provider_calls, 1);
4082        assert_eq!(archive["context"]["status"]["estimatedCostUsdNanos"], 0);
4083        assert_eq!(archive["context"]["status"]["unpricedProviderCalls"], 4);
4084        drop(unchanged);
4085        std::fs::remove_file(path).unwrap();
4086    }
4087
4088    #[test]
4089    fn legacy_provider_receipts_without_a_raw_context_anchor_remain_readable() {
4090        let receipt: EventKind = serde_json::from_value(json!({
4091            "type":"provider_receipt",
4092            "manifest_hash":"legacy-manifest",
4093            "input_tokens":123,
4094            "output_tokens":4,
4095            "provider_data":null
4096        }))
4097        .unwrap();
4098        assert!(matches!(
4099            receipt,
4100            EventKind::ProviderReceipt {
4101                context_bytes: None,
4102                raw_context_tokens: None,
4103                ..
4104            }
4105        ));
4106    }
4107
4108    #[test]
4109    fn stateful_tool_slots_are_batched_append_only_and_do_not_see_summaries() {
4110        let path = path("slots");
4111        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
4112        journal
4113            .apply_tool_slots(
4114                "t1",
4115                "rust-1",
4116                vec![
4117                    ToolSlotInput {
4118                        slot: "a.rs".into(),
4119                        name: "a.rs".into(),
4120                        content: BoxContent::text("a"),
4121                        retired: false,
4122                    },
4123                    ToolSlotInput {
4124                        slot: "b.rs".into(),
4125                        name: "b.rs".into(),
4126                        content: BoxContent::text("b"),
4127                        retired: false,
4128                    },
4129                ],
4130            )
4131            .unwrap();
4132        let a = journal.state().tools["rust-1"].slots[0].box_id;
4133        journal.summarize_box("t2", a, "Kennedy summary").unwrap();
4134        let before = journal.state().clone();
4135        assert!(
4136            journal
4137                .apply_tool_slots(
4138                    "t3",
4139                    "rust-1",
4140                    vec![ToolSlotInput {
4141                        slot: "b.rs".into(),
4142                        name: "b.rs".into(),
4143                        content: BoxContent::text("b"),
4144                        retired: false,
4145                    }]
4146                )
4147                .is_err()
4148        );
4149        assert_eq!(journal.state(), &before);
4150        journal
4151            .apply_tool_slots(
4152                "t4",
4153                "rust-1",
4154                vec![
4155                    ToolSlotInput {
4156                        slot: "a.rs".into(),
4157                        name: "a.rs".into(),
4158                        content: BoxContent::text("a2"),
4159                        retired: false,
4160                    },
4161                    ToolSlotInput {
4162                        slot: "b.rs".into(),
4163                        name: "b.rs".into(),
4164                        content: BoxContent::text("b"),
4165                        retired: true,
4166                    },
4167                    ToolSlotInput {
4168                        slot: "c.rs".into(),
4169                        name: "c.rs".into(),
4170                        content: BoxContent::text("c"),
4171                        retired: false,
4172                    },
4173                ],
4174            )
4175            .unwrap();
4176        let a_state = journal.state().box_state(a).unwrap();
4177        assert_eq!(a_state.canonical.content.text, "a2");
4178        assert!(matches!(
4179            a_state.representation,
4180            Representation::Summarized { .. }
4181        ));
4182        drop(journal);
4183        let reopened = SessionJournal::open_with_metadata(&path, metadata()).unwrap();
4184        assert_eq!(reopened.state().tools["rust-1"].slots.len(), 3);
4185        assert!(reopened.state().tools["rust-1"].slots[1].retired);
4186        std::fs::remove_file(path).unwrap();
4187    }
4188
4189    #[test]
4190    fn tool_layout_orders_current_boxes_without_changing_their_identities() {
4191        let path = path("tool-layout");
4192        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
4193        journal
4194            .apply_tool_slots_with_layout(
4195                "t1",
4196                "kweb",
4197                vec![
4198                    ToolSlotInput {
4199                        slot: "active".into(),
4200                        name: "Active node".into(),
4201                        content: BoxContent::text("ACTIVE NODE"),
4202                        retired: false,
4203                    },
4204                    ToolSlotInput {
4205                        slot: "direct".into(),
4206                        name: "Direct node".into(),
4207                        content: BoxContent::text("DIRECT NODE"),
4208                        retired: false,
4209                    },
4210                ],
4211                &["direct".into(), "active".into()],
4212            )
4213            .unwrap();
4214        let tool = &journal.state().tools["kweb"];
4215        let active_id = tool.slots[0].box_id;
4216        let direct_id = tool.slots[1].box_id;
4217        let projected_items = journal.state().projection().items;
4218        let rendered = projected_items
4219            .iter()
4220            .map(|item| item.text.as_str())
4221            .collect::<Vec<_>>()
4222            .join("\n\n");
4223        assert!(rendered.find("DIRECT NODE") < rendered.find("ACTIVE NODE"));
4224        assert_eq!(
4225            journal.state().tool_layouts["kweb"],
4226            vec![direct_id, active_id]
4227        );
4228        drop(journal);
4229        let reopened = SessionJournal::open_with_metadata(&path, metadata()).unwrap();
4230        assert_eq!(reopened.state().projection().items, projected_items);
4231        std::fs::remove_file(path).unwrap();
4232    }
4233
4234    #[test]
4235    fn ingress_attempt_reset_restores_the_initial_provider_visible_state() {
4236        let path = path("ingress-attempt-reset");
4237        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
4238        let baseline_box = journal
4239            .create_box(
4240                "t1",
4241                "source",
4242                BoxOwner::User,
4243                BoxContent::text("original source"),
4244            )
4245            .unwrap();
4246        journal
4247            .record(
4248                "t2",
4249                EventKind::SourceTerminated {
4250                    reason: "history_ingress".into(),
4251                },
4252            )
4253            .unwrap();
4254        journal
4255            .record("t3", EventKind::HistoryIngressStarted)
4256            .unwrap();
4257        let baseline = journal.state().projection().render();
4258
4259        journal
4260            .update_box(
4261                "t4",
4262                baseline_box,
4263                BoxContent::text("failed attempt update"),
4264            )
4265            .unwrap();
4266        journal
4267            .create_box(
4268                "t5",
4269                "failed attempt",
4270                BoxOwner::Kennedy,
4271                BoxContent::text("discard me"),
4272            )
4273            .unwrap();
4274        journal.reset_history_ingress_attempt("t6").unwrap();
4275
4276        assert_eq!(journal.state().projection().render(), baseline);
4277        assert!(journal.state().current_ingress_attempt_events().is_empty());
4278        drop(journal);
4279        let reopened = SessionJournal::open_with_metadata(&path, metadata()).unwrap();
4280        assert_eq!(reopened.state().projection().render(), baseline);
4281        std::fs::remove_file(path).unwrap();
4282    }
4283
4284    #[test]
4285    fn ingress_time_markers_are_sparse_resettable_and_system_bracketed() {
4286        let path = path("ingress-time-markers");
4287        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
4288        journal
4289            .create_box(
4290                "t1",
4291                "source",
4292                BoxOwner::User,
4293                BoxContent::text("short source"),
4294            )
4295            .unwrap();
4296        journal
4297            .record(
4298                "t2",
4299                EventKind::SourceTerminated {
4300                    reason: "history_ingress".into(),
4301                },
4302            )
4303            .unwrap();
4304        journal
4305            .record("t3", EventKind::HistoryIngressStarted)
4306            .unwrap();
4307
4308        journal
4309            .prepare_provider_projection_with_ingress_time("t4", &[], "m1", None, 2_700, false)
4310            .unwrap();
4311        let mut synchronized_after = journal.state().events.last().unwrap().id;
4312        let twenty = journal
4313            .prepare_provider_resume_markers("t5", synchronized_after, Some((1_200, false)))
4314            .unwrap();
4315        assert_eq!(twenty, ["[ingress time remaining: 1200 seconds]"]);
4316        synchronized_after = journal.state().events.last().unwrap().id;
4317        assert!(
4318            journal
4319                .prepare_provider_resume_markers("t6", synchronized_after, Some((1_199, false)),)
4320                .unwrap()
4321                .is_empty()
4322        );
4323        let ten = journal
4324            .prepare_provider_resume_markers("t7", synchronized_after, Some((600, false)))
4325            .unwrap();
4326        assert_eq!(
4327            ten,
4328            [
4329                "[ingress time remaining: 600 seconds. If you do not call EndSession before time expires, the ingress will fail and all Kmap updates will be lost.]"
4330            ],
4331        );
4332        let markers = journal
4333            .state()
4334            .projection()
4335            .items
4336            .into_iter()
4337            .filter(|item| item.text.contains("ingress time remaining"))
4338            .map(|item| item.text)
4339            .collect::<Vec<_>>();
4340        assert_eq!(markers.len(), 3);
4341        assert_eq!(markers[0], "[ingress time remaining: 2700 seconds]");
4342        assert_eq!(markers[1], "[ingress time remaining: 1200 seconds]");
4343        assert_eq!(
4344            markers[2],
4345            "[ingress time remaining: 600 seconds. If you do not call EndSession before time expires, the ingress will fail and all Kmap updates will be lost.]"
4346        );
4347        assert!(should_append_ingress_time_marker(
4348            IngressTimeMarkerState {
4349                any: true,
4350                at_or_below_thirty_minutes: true,
4351                at_or_below_ten_minutes: true,
4352            },
4353            500,
4354            true,
4355        ));
4356
4357        journal.reset_history_ingress_attempt("t8").unwrap();
4358        let retry = journal
4359            .prepare_provider_projection_with_ingress_time("t9", &[], "m1", None, 2_700, true)
4360            .unwrap();
4361        assert!(retry.projection.render().contains(
4362            "[Previous attempt ran out of time; please budget your ingress time carefully. Ingress time remaining: 2700 seconds]"
4363        ));
4364        assert!(
4365            !retry
4366                .projection
4367                .render()
4368                .contains("[ingress time remaining: 600 seconds. If you do not call EndSession")
4369        );
4370        std::fs::remove_file(path).unwrap();
4371    }
4372
4373    #[test]
4374    fn limits_use_exact_floor_percentages() {
4375        let state = Chatend::opened(SessionMetadata {
4376            effective_context_tokens: 101,
4377            ..metadata()
4378        });
4379        assert_eq!(state.live_context_limit(), 70);
4380        assert_eq!(state.forced_ingress_context_limit(), 75);
4381        assert_eq!(state.ingress_initial_context_limit(), 75);
4382        assert_eq!(state.ingress_context_limit(), 101);
4383        assert_eq!(state.active_context_limit(), 70);
4384        let ingress = Chatend::opened(SessionMetadata {
4385            kind: SessionKind::HistoryIngress,
4386            effective_context_tokens: 101,
4387            ..metadata()
4388        });
4389        assert_eq!(ingress.active_context_limit(), 101);
4390        assert_eq!(estimate_tokens("1234"), 1);
4391    }
4392
4393    #[test]
4394    fn new_box_projection_preview_matches_the_committed_projection() {
4395        let path = path("new-box-preview");
4396        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
4397        let boxes = vec![
4398            (
4399                "User message".into(),
4400                BoxOwner::User,
4401                BoxContent::text("prospective user text"),
4402            ),
4403            (
4404                "attachment".into(),
4405                BoxOwner::User,
4406                BoxContent::text("prospective attachment text"),
4407            ),
4408        ];
4409        let preview = journal
4410            .state()
4411            .projection_with_new_boxes_at("t1", &boxes)
4412            .unwrap();
4413        for (name, owner, content) in boxes {
4414            journal.create_box("t1", name, owner, content).unwrap();
4415        }
4416        assert_eq!(journal.state().projection(), preview);
4417        std::fs::remove_file(path).unwrap();
4418    }
4419
4420    #[test]
4421    fn box_representation_preview_matches_the_batched_append() {
4422        let path = path("representation-plan");
4423        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
4424        let hydrated = journal
4425            .create_box(
4426                "t1",
4427                "large result",
4428                BoxOwner::Controller,
4429                BoxContent::text("x".repeat(3_000)),
4430            )
4431            .unwrap();
4432        let summarized = journal
4433            .create_box(
4434                "t2",
4435                "Kennedy summary",
4436                BoxOwner::Kennedy,
4437                BoxContent::text("y".repeat(3_000)),
4438            )
4439            .unwrap();
4440        journal
4441            .summarize_box("t3", summarized, "important points")
4442            .unwrap();
4443        let desired = BTreeMap::from([
4444            (hydrated, BoxRepresentation::Dehydrated),
4445            (
4446                summarized,
4447                BoxRepresentation::Summarized("important points".into()),
4448            ),
4449        ]);
4450        let preview = journal
4451            .state()
4452            .projection_with_box_representations(&desired)
4453            .unwrap();
4454        let next_id = journal.state().next_id;
4455        let ids = journal.apply_box_representations("t4", &desired).unwrap();
4456        assert_eq!(ids, vec![EventId(next_id)]);
4457        assert_eq!(journal.state().projection(), preview);
4458        assert!(matches!(
4459            journal.state().box_state(hydrated).unwrap().representation,
4460            Representation::Dehydrated { .. }
4461        ));
4462        assert_eq!(
4463            journal
4464                .state()
4465                .box_state(summarized)
4466                .unwrap()
4467                .representation,
4468            Representation::Summarized {
4469                based_on: EventId(2),
4470                text: "important points".into(),
4471            }
4472        );
4473        std::fs::remove_file(path).unwrap();
4474    }
4475}