Skip to main content

kcode_chatend/
lib.rs

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