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