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