Skip to main content

kcode_session_history/
chatend.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},
9    path::Path,
10};
11
12use anyhow::{Context as _, ensure};
13use kcode_session_log::{
14    EventPosition, Role, Session as DurableSession, SessionStore as DurableSessionStore,
15};
16use serde::{Deserialize, Serialize};
17use serde_json::{Value, json};
18
19pub const FORMAT_VERSION: u32 = 1;
20pub const MAX_OBJECT_BYTES: u64 = 32 * 1024 * 1024 * 1024;
21pub const ESTIMATED_CHARACTERS_PER_TOKEN: u64 = 3;
22
23#[derive(
24    Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize,
25)]
26#[serde(transparent)]
27pub struct EventId(pub u64);
28
29impl std::fmt::Display for EventId {
30    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        self.0.fmt(formatter)
32    }
33}
34
35#[derive(
36    Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize,
37)]
38#[serde(transparent)]
39pub struct BoxId(pub u64);
40
41impl std::fmt::Display for BoxId {
42    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        self.0.fmt(formatter)
44    }
45}
46
47#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
48#[serde(transparent)]
49pub struct PendingId(String);
50
51impl PendingId {
52    pub fn from_event(id: EventId) -> Self {
53        Self(format!("pending:{}", id.0))
54    }
55
56    pub fn parse(value: impl Into<String>) -> anyhow::Result<Self> {
57        let value = value.into();
58        let number = value
59            .strip_prefix("pending:")
60            .context("pending identity must begin with `pending:`")?
61            .parse::<u64>()
62            .context("pending identity must end in an unsigned integer")?;
63        ensure!(number > 0, "pending identity zero is reserved");
64        Ok(Self(value))
65    }
66
67    pub fn number(&self) -> u64 {
68        self.0["pending:".len()..]
69            .parse()
70            .expect("validated PendingId")
71    }
72}
73
74impl std::fmt::Display for PendingId {
75    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        formatter.write_str(&self.0)
77    }
78}
79
80#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum SessionKind {
83    Conversation,
84    Telegram,
85    TelegramGroup,
86    SelfTime,
87    AudioIngress,
88    HistoryIngress,
89    Other(String),
90}
91
92#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct SessionMetadata {
95    pub session_id: String,
96    pub kind: SessionKind,
97    pub created_at: String,
98    pub effective_context_tokens: u64,
99    #[serde(default)]
100    pub channel: Value,
101}
102
103#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
104#[serde(tag = "kind", rename_all = "snake_case")]
105pub enum BoxOwner {
106    User,
107    Kennedy,
108    Controller,
109    System,
110    Tool { tool_instance: String, slot: String },
111}
112
113impl BoxOwner {
114    fn label(&self) -> String {
115        match self {
116            Self::User => "user".into(),
117            Self::Kennedy => "kennedy".into(),
118            Self::Controller => "controller".into(),
119            Self::System => "system".into(),
120            Self::Tool {
121                tool_instance,
122                slot,
123            } => format!("tool:{tool_instance}:{slot}"),
124        }
125    }
126}
127
128#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
129#[serde(rename_all = "camelCase")]
130pub struct BoxContent {
131    #[serde(default)]
132    pub text: String,
133    #[serde(default)]
134    pub objects: Vec<String>,
135    #[serde(default)]
136    pub metadata: Value,
137}
138
139impl BoxContent {
140    pub fn text(value: impl Into<String>) -> Self {
141        Self {
142            text: value.into(),
143            ..Self::default()
144        }
145    }
146
147    /// Selects the compact projection header that omits Chatend's internal
148    /// owner label. Domain ownership should be included in the box body when
149    /// it is relevant to the provider.
150    pub fn use_concise_header(&mut self) {
151        if !self.metadata.is_object() {
152            self.metadata = json!({});
153        }
154        self.metadata["chatendConciseHeader"] = json!(true);
155    }
156
157    fn concise_header(&self) -> bool {
158        self.metadata
159            .get("chatendConciseHeader")
160            .and_then(Value::as_bool)
161            .unwrap_or(false)
162    }
163
164    fn render(&self) -> String {
165        let mut rendered = self.text.clone();
166        for object in &self.objects {
167            if !rendered.is_empty() && !rendered.ends_with('\n') {
168                rendered.push('\n');
169            }
170            rendered.push_str("Object provided: ");
171            rendered.push_str(object);
172        }
173        rendered
174    }
175}
176
177#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
178#[serde(tag = "kind", rename_all = "snake_case")]
179pub enum Representation {
180    Hydrated { canonical_event: EventId },
181    Dehydrated { based_on: EventId },
182    Summarized { based_on: EventId, text: String },
183}
184
185#[derive(Clone, Debug, Eq, PartialEq)]
186pub enum BoxRepresentation {
187    Hydrated,
188    Dehydrated,
189    Summarized(String),
190}
191
192impl Representation {
193    fn based_on(&self) -> EventId {
194        match self {
195            Self::Hydrated { canonical_event } => *canonical_event,
196            Self::Dehydrated { based_on } | Self::Summarized { based_on, .. } => *based_on,
197        }
198    }
199}
200
201#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
202#[serde(rename_all = "camelCase")]
203pub struct CanonicalRevision {
204    pub event_id: EventId,
205    pub content: BoxContent,
206}
207
208#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct BoxState {
211    pub id: BoxId,
212    pub name: String,
213    pub owner: BoxOwner,
214    pub created_at: EventId,
215    pub canonical: CanonicalRevision,
216    pub representation: Representation,
217    pub occurrence_events: Vec<EventId>,
218    pub active: bool,
219}
220
221impl BoxState {
222    pub fn stale(&self) -> bool {
223        !matches!(self.representation, Representation::Hydrated { .. })
224            && self.representation.based_on() != self.canonical.event_id
225    }
226}
227
228#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
229#[serde(rename_all = "snake_case")]
230pub enum PendingKind {
231    Node,
232    Object,
233}
234
235#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
236#[serde(tag = "type", rename_all = "snake_case")]
237pub enum EventKind {
238    SessionConfigured {
239        effective_context_tokens: u64,
240        kind: SessionKind,
241    },
242    BoxCreated {
243        box_id: BoxId,
244        name: String,
245        owner: BoxOwner,
246        content: BoxContent,
247    },
248    CanonicalUpdated {
249        box_id: BoxId,
250        content: BoxContent,
251    },
252    BoxRenamed {
253        box_id: BoxId,
254        name: String,
255    },
256    BoxDehydrated {
257        box_id: BoxId,
258    },
259    BoxSummarized {
260        box_id: BoxId,
261        text: String,
262    },
263    BoxRehydrated {
264        box_id: BoxId,
265    },
266    BoxRetired {
267        box_id: BoxId,
268    },
269    PendingAllocated {
270        pending_id: PendingId,
271        resource: PendingKind,
272    },
273    ToolInvoked {
274        tool_instance: String,
275        tool_name: String,
276        arguments: Value,
277        #[serde(default, skip_serializing_if = "Option::is_none")]
278        invocation_id: Option<String>,
279    },
280    ToolCompleted {
281        tool_instance: String,
282        tool_name: String,
283        outcome: Value,
284        #[serde(default, skip_serializing_if = "Option::is_none")]
285        invocation_id: Option<String>,
286    },
287    ToolLayoutChanged {
288        tool_instance: String,
289        box_ids: Vec<BoxId>,
290    },
291    InferenceSubmitted {
292        manifest_hash: String,
293        estimated_input_tokens: u64,
294        #[serde(default)]
295        raw_estimated_input_tokens: Option<u64>,
296    },
297    ProviderReceipt {
298        manifest_hash: String,
299        input_tokens: Option<u64>,
300        output_tokens: Option<u64>,
301        #[serde(default)]
302        raw_context_tokens: Option<u64>,
303        provider_data: Value,
304    },
305    CapacityError {
306        attempted_operation: String,
307        projected_tokens: u64,
308        limit_tokens: u64,
309    },
310    SourceTerminated {
311        reason: String,
312    },
313    HistoryIngressStarted,
314    HistoryEventInspected {
315        source_event: EventId,
316    },
317    HistoryEventReleased {
318        source_event: EventId,
319    },
320    KwebPlanChanged {
321        operation: Value,
322    },
323    KwebCommitted {
324        transaction_id: String,
325        session_object_id: String,
326        mappings: Value,
327    },
328    SessionCompleted {
329        session_object_id: String,
330    },
331    Note {
332        label: String,
333        value: Value,
334    },
335}
336
337#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
338#[serde(rename_all = "camelCase")]
339pub struct Event {
340    pub id: EventId,
341    pub recorded_at: String,
342    pub kind: EventKind,
343}
344
345#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
346#[serde(rename_all = "camelCase")]
347pub struct Transition {
348    pub recorded_at: String,
349    pub events: Vec<Event>,
350}
351
352#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
353#[serde(rename_all = "camelCase")]
354struct PersistedContextEvent {
355    context_event_version: u32,
356    recorded_at: String,
357    kind: EventKind,
358}
359
360#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
361#[serde(rename_all = "camelCase")]
362struct PersistedContextEventWire {
363    context_event_version: u32,
364    recorded_at: String,
365    kind: Value,
366}
367
368#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
369#[serde(rename_all = "camelCase")]
370pub struct ObjectMetadata {
371    pub pending_id: PendingId,
372    pub event_id: EventId,
373    pub recorded_at: String,
374    pub media_type: String,
375    pub file_name: Option<String>,
376    #[serde(default)]
377    pub transport: Value,
378}
379
380#[derive(Clone, Debug, Eq, PartialEq)]
381pub struct ObjectLocation {
382    pub metadata: ObjectMetadata,
383    pub payload_offset: u64,
384    pub payload_len: u64,
385}
386
387#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
388#[serde(rename_all = "camelCase")]
389pub struct ToolSlot {
390    pub slot: String,
391    pub box_id: BoxId,
392    pub retired: bool,
393}
394
395#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
396#[serde(rename_all = "camelCase")]
397pub struct ToolState {
398    pub slots: Vec<ToolSlot>,
399}
400
401#[derive(Clone, Debug, Eq, PartialEq)]
402pub struct ToolSlotInput {
403    pub slot: String,
404    pub name: String,
405    pub content: BoxContent,
406    pub retired: bool,
407}
408
409#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
410#[serde(rename_all = "camelCase")]
411pub struct Chatend {
412    pub metadata: SessionMetadata,
413    pub next_id: u64,
414    pub events: Vec<Event>,
415    pub boxes: BTreeMap<BoxId, BoxState>,
416    pub pending: BTreeMap<PendingId, PendingKind>,
417    pub tools: BTreeMap<String, ToolState>,
418    #[serde(default)]
419    pub tool_layouts: BTreeMap<String, Vec<BoxId>>,
420    pub source_terminated: bool,
421    pub history_ingress_started: bool,
422    pub completed_session_object: Option<String>,
423}
424
425impl Chatend {
426    fn opened(metadata: SessionMetadata) -> Self {
427        Self {
428            metadata,
429            next_id: 1,
430            events: Vec::new(),
431            boxes: BTreeMap::new(),
432            pending: BTreeMap::new(),
433            tools: BTreeMap::new(),
434            tool_layouts: BTreeMap::new(),
435            source_terminated: false,
436            history_ingress_started: false,
437            completed_session_object: None,
438        }
439    }
440
441    pub fn event(&self, id: EventId) -> Option<&Event> {
442        self.events.iter().find(|event| event.id == id)
443    }
444
445    pub fn box_state(&self, id: BoxId) -> Option<&BoxState> {
446        self.boxes.get(&id)
447    }
448
449    pub fn active_boxes(&self) -> impl Iterator<Item = &BoxState> {
450        self.boxes.values().filter(|state| state.active)
451    }
452
453    pub fn live_context_limit(&self) -> u64 {
454        self.metadata.effective_context_tokens.saturating_mul(70) / 100
455    }
456
457    pub fn forced_ingress_context_limit(&self) -> u64 {
458        self.metadata.effective_context_tokens.saturating_mul(75) / 100
459    }
460
461    pub fn ingress_initial_context_limit(&self) -> u64 {
462        self.metadata.effective_context_tokens.saturating_mul(75) / 100
463    }
464
465    pub fn ingress_context_limit(&self) -> u64 {
466        self.metadata.effective_context_tokens
467    }
468
469    pub fn active_context_limit(&self) -> u64 {
470        if matches!(
471            self.metadata.kind,
472            SessionKind::HistoryIngress | SessionKind::AudioIngress
473        ) {
474            self.ingress_context_limit()
475        } else {
476            self.live_context_limit()
477        }
478    }
479
480    pub fn projection_with_new_boxes(
481        &self,
482        boxes: &[(String, BoxOwner, BoxContent)],
483    ) -> anyhow::Result<ContextProjection> {
484        self.projection_with_new_boxes_and_updates(boxes, &BTreeMap::new())
485    }
486
487    pub fn projection_with_new_boxes_and_updates(
488        &self,
489        boxes: &[(String, BoxOwner, BoxContent)],
490        updates: &BTreeMap<BoxId, BoxContent>,
491    ) -> anyhow::Result<ContextProjection> {
492        let mut preview = self.clone();
493        let mut next = preview.next_id;
494        let mut events = Vec::with_capacity(boxes.len() + updates.len());
495        for (name, owner, content) in boxes {
496            let id = EventId(next);
497            let box_id = BoxId(next);
498            next = next.checked_add(1).context("event identity overflow")?;
499            events.push(Event {
500                id,
501                recorded_at: "preview".into(),
502                kind: EventKind::BoxCreated {
503                    box_id,
504                    name: name.clone(),
505                    owner: owner.clone(),
506                    content: content.clone(),
507                },
508            });
509        }
510        for (box_id, content) in updates {
511            let state = preview
512                .box_state(*box_id)
513                .with_context(|| format!("box {box_id} does not exist"))?;
514            ensure!(state.active, "box {box_id} is retired");
515            if state.canonical.content == *content {
516                continue;
517            }
518            let id = EventId(next);
519            next = next.checked_add(1).context("event identity overflow")?;
520            events.push(Event {
521                id,
522                recorded_at: "preview".into(),
523                kind: EventKind::CanonicalUpdated {
524                    box_id: *box_id,
525                    content: content.clone(),
526                },
527            });
528        }
529        if !events.is_empty() {
530            preview.apply_transition(&Transition {
531                recorded_at: "preview".into(),
532                events,
533            })?;
534        }
535        Ok(preview.projection())
536    }
537
538    pub fn projection_with_box_representations(
539        &self,
540        desired: &BTreeMap<BoxId, BoxRepresentation>,
541    ) -> anyhow::Result<ContextProjection> {
542        let mut preview = self.clone();
543        let events = preview.box_representation_events("preview", desired)?;
544        if !events.is_empty() {
545            preview.apply_transition(&Transition {
546                recorded_at: "preview".into(),
547                events,
548            })?;
549        }
550        Ok(preview.projection())
551    }
552
553    fn box_representation_events(
554        &self,
555        recorded_at: &str,
556        desired: &BTreeMap<BoxId, BoxRepresentation>,
557    ) -> anyhow::Result<Vec<Event>> {
558        let mut next = self.next_id;
559        let mut events = Vec::new();
560        for (box_id, desired) in desired {
561            let state = self
562                .box_state(*box_id)
563                .with_context(|| format!("box {box_id} does not exist"))?;
564            ensure!(state.active, "box {box_id} is retired");
565            let kind = match (desired, &state.representation) {
566                (BoxRepresentation::Hydrated, Representation::Hydrated { .. })
567                | (BoxRepresentation::Dehydrated, Representation::Dehydrated { .. }) => None,
568                (
569                    BoxRepresentation::Summarized(desired),
570                    Representation::Summarized { text, .. },
571                ) if desired == text => None,
572                (BoxRepresentation::Hydrated, _) => {
573                    Some(EventKind::BoxRehydrated { box_id: *box_id })
574                }
575                (BoxRepresentation::Dehydrated, _) => {
576                    Some(EventKind::BoxDehydrated { box_id: *box_id })
577                }
578                (BoxRepresentation::Summarized(text), _) => Some(EventKind::BoxSummarized {
579                    box_id: *box_id,
580                    text: text.clone(),
581                }),
582            };
583            let Some(kind) = kind else {
584                continue;
585            };
586            events.push(Event {
587                id: EventId(next),
588                recorded_at: recorded_at.into(),
589                kind,
590            });
591            next = next.checked_add(1).context("event ID overflow")?;
592        }
593        Ok(events)
594    }
595
596    pub fn projection(&self) -> ContextProjection {
597        let mut next_occurrence = HashMap::new();
598        for state in self.boxes.values() {
599            for pair in state.occurrence_events.windows(2) {
600                next_occurrence.insert(pair[0], pair[1]);
601            }
602        }
603        let mut items = Vec::new();
604        for event in &self.events {
605            let Some(box_id) = event_box_id(&event.kind) else {
606                continue;
607            };
608            let Some(state) = self.boxes.get(&box_id) else {
609                continue;
610            };
611            if next_occurrence.contains_key(&event.id) {
612                items.push(ProjectionItem::marker(
613                    event.id,
614                    box_id,
615                    "[box updated]".into(),
616                ));
617                continue;
618            }
619            if !state.active || state.occurrence_events.last() != Some(&event.id) {
620                continue;
621            }
622            let (representation, body) = match &state.representation {
623                Representation::Hydrated { .. } => ("hydrated", state.canonical.content.render()),
624                Representation::Dehydrated { .. } => (
625                    "dehydrated",
626                    format!(
627                        "[contents dehydrated; hydrate box {} to inspect the latest canonical revision]",
628                        box_id
629                    ),
630                ),
631                Representation::Summarized { text, .. } => ("summarized", text.clone()),
632            };
633            let stale = state.stale();
634            let mut text = if state.canonical.content.concise_header() {
635                format!(
636                    "[box {} | {} | {}{}]\n{}",
637                    box_id,
638                    state.name,
639                    representation,
640                    if stale { " | stale" } else { "" },
641                    body
642                )
643            } else {
644                format!(
645                    "[box {} | {} | owner={} | {}{}]\n{}",
646                    box_id,
647                    state.name,
648                    state.owner.label(),
649                    representation,
650                    if stale { " | stale" } else { "" },
651                    body
652                )
653            };
654            if text.ends_with('\n') {
655                text.pop();
656            }
657            items.push(ProjectionItem {
658                event_id: event.id,
659                box_id,
660                marker: false,
661                stale,
662                approximate_tokens: estimate_tokens(&text),
663                text,
664            });
665        }
666        let stale_boxes = self
667            .active_boxes()
668            .filter(|state| state.stale())
669            .map(|state| state.id)
670            .collect::<Vec<_>>();
671        for box_ids in self.tool_layouts.values() {
672            arrange_tool_projection(&mut items, box_ids);
673        }
674        let body_tokens = items
675            .iter()
676            .map(|item| item.approximate_tokens)
677            .sum::<u64>();
678        let preliminary_footer = format!(
679            "[context budget | estimated={} | turn_limit={} | effective={}]\n[stale boxes: {}]",
680            body_tokens,
681            self.active_context_limit(),
682            self.metadata.effective_context_tokens,
683            if stale_boxes.is_empty() {
684                "none".into()
685            } else {
686                stale_boxes
687                    .iter()
688                    .map(ToString::to_string)
689                    .collect::<Vec<_>>()
690                    .join(", ")
691            }
692        );
693        let preliminary_raw = body_tokens.saturating_add(estimate_tokens(&preliminary_footer));
694        let preliminary_estimate = self.calibrated_estimate(preliminary_raw);
695        let footer = format!(
696            "[context budget | estimated={} | turn_limit={} | effective={}]\n[stale boxes: {}]",
697            preliminary_estimate,
698            self.active_context_limit(),
699            self.metadata.effective_context_tokens,
700            if stale_boxes.is_empty() {
701                "none".into()
702            } else {
703                stale_boxes
704                    .iter()
705                    .map(ToString::to_string)
706                    .collect::<Vec<_>>()
707                    .join(", ")
708            }
709        );
710        let raw_estimated_tokens = body_tokens.saturating_add(estimate_tokens(&footer));
711        let estimated_tokens = self.calibrated_estimate(raw_estimated_tokens);
712        ContextProjection {
713            items,
714            stale_boxes,
715            footer,
716            estimated_tokens,
717            raw_estimated_tokens,
718        }
719    }
720
721    fn calibrated_estimate(&self, raw_current: u64) -> u64 {
722        let Some((manifest_hash, measured, raw_at_receipt)) =
723            self.events.iter().rev().find_map(|event| {
724                let EventKind::ProviderReceipt {
725                    manifest_hash,
726                    input_tokens: Some(input_tokens),
727                    raw_context_tokens,
728                    ..
729                } = &event.kind
730                else {
731                    return None;
732                };
733                Some((manifest_hash, *input_tokens, *raw_context_tokens))
734            })
735        else {
736            return raw_current;
737        };
738        let raw_at_measurement = match raw_at_receipt {
739            Some(raw) => raw,
740            None => {
741                let Some(raw) = self.events.iter().rev().find_map(|event| {
742                    let EventKind::InferenceSubmitted {
743                        manifest_hash: submitted,
744                        estimated_input_tokens,
745                        raw_estimated_input_tokens,
746                    } = &event.kind
747                    else {
748                        return None;
749                    };
750                    (submitted == manifest_hash)
751                        .then_some(raw_estimated_input_tokens.unwrap_or(*estimated_input_tokens))
752                }) else {
753                    return raw_current;
754                };
755                raw
756            }
757        };
758        if raw_current >= raw_at_measurement {
759            measured.saturating_add(raw_current - raw_at_measurement)
760        } else {
761            measured.saturating_sub(raw_at_measurement - raw_current)
762        }
763    }
764
765    pub fn render(&self) -> String {
766        let projection = self.projection();
767        let mut blocks = projection
768            .items
769            .iter()
770            .map(|item| item.text.as_str())
771            .collect::<Vec<_>>();
772        blocks.push(&projection.footer);
773        blocks.join("\n\n")
774    }
775
776    fn apply_transition(&mut self, transition: &Transition) -> anyhow::Result<()> {
777        ensure!(
778            !transition.events.is_empty(),
779            "a transition cannot be empty"
780        );
781        for event in &transition.events {
782            self.apply_event(event)?;
783        }
784        Ok(())
785    }
786
787    fn apply_event(&mut self, event: &Event) -> anyhow::Result<()> {
788        ensure!(
789            event.id.0 >= self.next_id,
790            "event {} reuses an allocated identity (next is {})",
791            event.id,
792            self.next_id
793        );
794        self.next_id = event.id.0.checked_add(1).context("event ID overflow")?;
795        match &event.kind {
796            EventKind::SessionConfigured {
797                effective_context_tokens,
798                kind,
799            } => {
800                ensure!(
801                    *effective_context_tokens > 0,
802                    "effective context window must be positive"
803                );
804                self.metadata.effective_context_tokens = *effective_context_tokens;
805                self.metadata.kind = kind.clone();
806            }
807            EventKind::BoxCreated {
808                box_id,
809                name,
810                owner,
811                content,
812            } => {
813                ensure!(
814                    box_id.0 == event.id.0,
815                    "BoxId must equal its creation EventId"
816                );
817                ensure!(
818                    !self.boxes.contains_key(box_id),
819                    "box {} already exists",
820                    box_id
821                );
822                self.boxes.insert(
823                    *box_id,
824                    BoxState {
825                        id: *box_id,
826                        name: name.clone(),
827                        owner: owner.clone(),
828                        created_at: event.id,
829                        canonical: CanonicalRevision {
830                            event_id: event.id,
831                            content: content.clone(),
832                        },
833                        representation: Representation::Hydrated {
834                            canonical_event: event.id,
835                        },
836                        occurrence_events: vec![event.id],
837                        active: true,
838                    },
839                );
840                if let BoxOwner::Tool {
841                    tool_instance,
842                    slot,
843                } = owner
844                {
845                    self.tools
846                        .entry(tool_instance.clone())
847                        .or_default()
848                        .slots
849                        .push(ToolSlot {
850                            slot: slot.clone(),
851                            box_id: *box_id,
852                            retired: false,
853                        });
854                }
855            }
856            EventKind::CanonicalUpdated { box_id, content } => {
857                let state = active_box_mut(&mut self.boxes, *box_id)?;
858                state.canonical = CanonicalRevision {
859                    event_id: event.id,
860                    content: content.clone(),
861                };
862                if matches!(state.representation, Representation::Hydrated { .. }) {
863                    state.representation = Representation::Hydrated {
864                        canonical_event: event.id,
865                    };
866                }
867                state.occurrence_events.push(event.id);
868            }
869            EventKind::BoxRenamed { box_id, name } => {
870                ensure!(!name.trim().is_empty(), "a box name cannot be empty");
871                let state = active_box_mut(&mut self.boxes, *box_id)?;
872                state.name = name.clone();
873                state.occurrence_events.push(event.id);
874            }
875            EventKind::BoxDehydrated { box_id } => {
876                let state = active_box_mut(&mut self.boxes, *box_id)?;
877                state.representation = Representation::Dehydrated {
878                    based_on: state.canonical.event_id,
879                };
880                state.occurrence_events.push(event.id);
881            }
882            EventKind::BoxSummarized { box_id, text } => {
883                ensure!(!text.trim().is_empty(), "a box summary cannot be empty");
884                let state = active_box_mut(&mut self.boxes, *box_id)?;
885                state.representation = Representation::Summarized {
886                    based_on: state.canonical.event_id,
887                    text: text.clone(),
888                };
889                state.occurrence_events.push(event.id);
890            }
891            EventKind::BoxRehydrated { box_id } => {
892                let state = active_box_mut(&mut self.boxes, *box_id)?;
893                state.representation = Representation::Hydrated {
894                    canonical_event: state.canonical.event_id,
895                };
896                state.occurrence_events.push(event.id);
897            }
898            EventKind::BoxRetired { box_id } => {
899                let tool_instance = self.boxes.get(box_id).and_then(|state| {
900                    let BoxOwner::Tool { tool_instance, .. } = &state.owner else {
901                        return None;
902                    };
903                    Some(tool_instance.clone())
904                });
905                let state = active_box_mut(&mut self.boxes, *box_id)?;
906                state.active = false;
907                state.occurrence_events.push(event.id);
908                if let Some(tool_instance) = tool_instance {
909                    let slot = self
910                        .tools
911                        .get_mut(&tool_instance)
912                        .and_then(|tool| tool.slots.iter_mut().find(|slot| slot.box_id == *box_id))
913                        .with_context(|| {
914                            format!("tool box {box_id} is missing from {tool_instance}")
915                        })?;
916                    slot.retired = true;
917                }
918            }
919            EventKind::ToolLayoutChanged {
920                tool_instance,
921                box_ids,
922            } => {
923                let mut unique = std::collections::HashSet::new();
924                for box_id in box_ids {
925                    ensure!(
926                        unique.insert(*box_id),
927                        "tool layout contains duplicate box {box_id}"
928                    );
929                    let state = self
930                        .boxes
931                        .get(box_id)
932                        .with_context(|| format!("tool layout references missing box {box_id}"))?;
933                    ensure!(state.active, "tool layout references retired box {box_id}");
934                    ensure!(
935                        matches!(
936                            &state.owner,
937                            BoxOwner::Tool {
938                                tool_instance: owner,
939                                ..
940                            } if owner == tool_instance
941                        ),
942                        "tool layout box {box_id} belongs to another tool"
943                    );
944                }
945                self.tool_layouts
946                    .insert(tool_instance.clone(), box_ids.clone());
947            }
948            EventKind::PendingAllocated {
949                pending_id,
950                resource,
951            } => {
952                ensure!(
953                    pending_id.number() == event.id.0,
954                    "pending identity must equal its allocation EventId"
955                );
956                ensure!(
957                    self.pending
958                        .insert(pending_id.clone(), resource.clone())
959                        .is_none(),
960                    "pending identity {} already exists",
961                    pending_id
962                );
963            }
964            EventKind::SourceTerminated { .. } => self.source_terminated = true,
965            EventKind::HistoryIngressStarted => {
966                ensure!(
967                    self.source_terminated,
968                    "history ingress requires source termination"
969                );
970                self.history_ingress_started = true;
971            }
972            EventKind::SessionCompleted { session_object_id } => {
973                self.completed_session_object = Some(session_object_id.clone());
974            }
975            EventKind::ToolInvoked { .. }
976            | EventKind::ToolCompleted { .. }
977            | EventKind::InferenceSubmitted { .. }
978            | EventKind::ProviderReceipt { .. }
979            | EventKind::CapacityError { .. }
980            | EventKind::HistoryEventInspected { .. }
981            | EventKind::HistoryEventReleased { .. }
982            | EventKind::KwebPlanChanged { .. }
983            | EventKind::KwebCommitted { .. }
984            | EventKind::Note { .. } => {}
985        }
986        self.events.push(event.clone());
987        Ok(())
988    }
989}
990
991fn active_box_mut(
992    boxes: &mut BTreeMap<BoxId, BoxState>,
993    box_id: BoxId,
994) -> anyhow::Result<&mut BoxState> {
995    let state = boxes
996        .get_mut(&box_id)
997        .with_context(|| format!("box {box_id} does not exist"))?;
998    ensure!(state.active, "box {box_id} is retired");
999    Ok(state)
1000}
1001
1002fn arrange_tool_projection(items: &mut Vec<ProjectionItem>, box_ids: &[BoxId]) {
1003    if box_ids.is_empty() {
1004        return;
1005    }
1006    let ranks = box_ids
1007        .iter()
1008        .enumerate()
1009        .map(|(rank, box_id)| (*box_id, rank))
1010        .collect::<HashMap<_, _>>();
1011    let insertion = items
1012        .iter()
1013        .position(|item| !item.marker && ranks.contains_key(&item.box_id));
1014    let Some(insertion) = insertion else {
1015        return;
1016    };
1017    let mut arranged = Vec::with_capacity(box_ids.len());
1018    let mut retained = Vec::with_capacity(items.len());
1019    for item in std::mem::take(items) {
1020        if !item.marker && ranks.contains_key(&item.box_id) {
1021            arranged.push(item);
1022        } else {
1023            retained.push(item);
1024        }
1025    }
1026    arranged.sort_by_key(|item| ranks[&item.box_id]);
1027    let insertion = insertion.min(retained.len());
1028    retained.splice(insertion..insertion, arranged);
1029    *items = retained;
1030}
1031
1032fn event_box_id(kind: &EventKind) -> Option<BoxId> {
1033    match kind {
1034        EventKind::BoxCreated { box_id, .. }
1035        | EventKind::CanonicalUpdated { box_id, .. }
1036        | EventKind::BoxRenamed { box_id, .. }
1037        | EventKind::BoxDehydrated { box_id }
1038        | EventKind::BoxSummarized { box_id, .. }
1039        | EventKind::BoxRehydrated { box_id }
1040        | EventKind::BoxRetired { box_id } => Some(*box_id),
1041        _ => None,
1042    }
1043}
1044
1045#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1046#[serde(rename_all = "camelCase")]
1047pub struct ProjectionItem {
1048    pub event_id: EventId,
1049    pub box_id: BoxId,
1050    pub marker: bool,
1051    pub stale: bool,
1052    pub approximate_tokens: u64,
1053    pub text: String,
1054}
1055
1056impl ProjectionItem {
1057    fn marker(event_id: EventId, box_id: BoxId, text: String) -> Self {
1058        Self {
1059            event_id,
1060            box_id,
1061            marker: true,
1062            stale: false,
1063            approximate_tokens: estimate_tokens(&text),
1064            text,
1065        }
1066    }
1067}
1068
1069#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1070#[serde(rename_all = "camelCase")]
1071pub struct ContextProjection {
1072    pub items: Vec<ProjectionItem>,
1073    pub stale_boxes: Vec<BoxId>,
1074    pub footer: String,
1075    pub estimated_tokens: u64,
1076    pub raw_estimated_tokens: u64,
1077}
1078
1079pub fn estimate_tokens(text: &str) -> u64 {
1080    (text.chars().count() as u64).div_ceil(ESTIMATED_CHARACTERS_PER_TOKEN)
1081}
1082
1083fn context_event_role(kind: &EventKind) -> Role {
1084    match kind {
1085        EventKind::BoxCreated { owner, content, .. } => match owner {
1086            BoxOwner::System | BoxOwner::Controller => {
1087                if content
1088                    .metadata
1089                    .get("capacityError")
1090                    .and_then(Value::as_bool)
1091                    .unwrap_or(false)
1092                {
1093                    Role::SystemError
1094                } else {
1095                    Role::SystemMessage
1096                }
1097            }
1098            BoxOwner::User => Role::UserMessage,
1099            BoxOwner::Kennedy => Role::KennedyMessage,
1100            BoxOwner::Tool { .. } => Role::ToolResult,
1101        },
1102        EventKind::ToolInvoked { .. } => Role::KennedyToolCall,
1103        EventKind::ToolCompleted { outcome, .. } => {
1104            if outcome.get("ok").and_then(Value::as_bool).unwrap_or(true) {
1105                Role::ToolResult
1106            } else {
1107                Role::ToolError
1108            }
1109        }
1110        EventKind::CapacityError { .. } => Role::SystemError,
1111        EventKind::PendingAllocated {
1112            resource: PendingKind::Object,
1113            ..
1114        } => Role::PendingObject,
1115        EventKind::BoxDehydrated { .. }
1116        | EventKind::BoxSummarized { .. }
1117        | EventKind::BoxRehydrated { .. }
1118        | EventKind::BoxRetired { .. } => Role::KennedyToolCall,
1119        _ => Role::SystemMessage,
1120    }
1121}
1122
1123fn encode_context_event(recorded_at: &str, kind: &EventKind) -> anyhow::Result<String> {
1124    let mut kind = serde_json::to_value(kind)?;
1125    let kind_object = kind
1126        .as_object_mut()
1127        .context("Kennedy context event kind must encode as an object")?;
1128    match kind_object.get("type").and_then(Value::as_str) {
1129        Some("box_created") => {
1130            kind_object.remove("box_id");
1131        }
1132        Some("pending_allocated") => {
1133            kind_object.remove("pending_id");
1134        }
1135        _ => {}
1136    }
1137    Ok(serde_json::to_string(&PersistedContextEventWire {
1138        context_event_version: FORMAT_VERSION,
1139        recorded_at: recorded_at.into(),
1140        kind,
1141    })?)
1142}
1143
1144fn decode_context_event(
1145    event: &kcode_session_log::SessionEvent,
1146) -> anyhow::Result<PersistedContextEvent> {
1147    let wire: PersistedContextEventWire = match serde_json::from_str(&event.text) {
1148        Ok(persisted) => persisted,
1149        Err(_) if event.role == Role::PendingObject => {
1150            return Ok(PersistedContextEvent {
1151                context_event_version: FORMAT_VERSION,
1152                recorded_at: String::new(),
1153                kind: EventKind::PendingAllocated {
1154                    pending_id: PendingId::from_event(EventId(1)),
1155                    resource: PendingKind::Object,
1156                },
1157            });
1158        }
1159        Err(error) => {
1160            return Err(error).context("decoding Kennedy context event from session log");
1161        }
1162    };
1163    ensure!(
1164        wire.context_event_version == FORMAT_VERSION,
1165        "unsupported Kennedy context event version {}",
1166        wire.context_event_version
1167    );
1168    let mut kind = wire.kind;
1169    let kind_object = kind
1170        .as_object_mut()
1171        .context("Kennedy context event kind must be an object")?;
1172    match kind_object.get("type").and_then(Value::as_str) {
1173        Some("box_created") if !kind_object.contains_key("box_id") => {
1174            kind_object.insert("box_id".into(), Value::from(0));
1175        }
1176        Some("pending_allocated") if !kind_object.contains_key("pending_id") => {
1177            kind_object.insert("pending_id".into(), Value::String("pending:1".into()));
1178        }
1179        _ => {}
1180    }
1181    Ok(PersistedContextEvent {
1182        context_event_version: wire.context_event_version,
1183        recorded_at: wire.recorded_at,
1184        kind: serde_json::from_value(kind).context("decoding Kennedy context event kind")?,
1185    })
1186}
1187
1188fn normalize_derived_identity(kind: &mut EventKind, id: EventId) -> anyhow::Result<()> {
1189    match kind {
1190        EventKind::BoxCreated { box_id, .. } => *box_id = BoxId(id.0),
1191        EventKind::PendingAllocated { pending_id, .. } => {
1192            *pending_id = PendingId::from_event(id);
1193        }
1194        _ => {}
1195    }
1196    Ok(())
1197}
1198
1199pub struct Session {
1200    durable: DurableSession,
1201    chatend: Chatend,
1202    objects: BTreeMap<PendingId, ObjectLocation>,
1203}
1204
1205struct UnfinishedToolInvocation {
1206    invocation_id: Option<String>,
1207    tool_instance: String,
1208    tool_name: String,
1209}
1210
1211impl Session {
1212    pub(crate) fn create(
1213        path: impl AsRef<Path>,
1214        metadata: SessionMetadata,
1215    ) -> anyhow::Result<Self> {
1216        ensure!(
1217            metadata.effective_context_tokens > 0,
1218            "effective context window must be positive"
1219        );
1220        let requested = path.as_ref();
1221        let directory = requested
1222            .parent()
1223            .filter(|path| !path.as_os_str().is_empty())
1224            .unwrap_or_else(|| Path::new("."));
1225        let durable = DurableSessionStore::new(directory)
1226            .create_session(&metadata.session_id, &metadata.created_at)?;
1227        Ok(Self {
1228            durable,
1229            chatend: Chatend::opened(metadata),
1230            objects: BTreeMap::new(),
1231        })
1232    }
1233
1234    pub(crate) fn open_with_metadata(
1235        path: impl AsRef<Path>,
1236        metadata: SessionMetadata,
1237    ) -> anyhow::Result<Self> {
1238        let requested = path.as_ref();
1239        ensure!(
1240            requested.extension().and_then(|value| value.to_str()) == Some("session-log"),
1241            "{} is not a session-log path",
1242            requested.display()
1243        );
1244        let directory = requested
1245            .parent()
1246            .filter(|path| !path.as_os_str().is_empty())
1247            .unwrap_or_else(|| Path::new("."));
1248        let session_id = requested
1249            .file_stem()
1250            .and_then(|value| value.to_str())
1251            .context("session-log filename is not valid UTF-8")?;
1252        let durable = DurableSessionStore::new(directory).open_session(session_id)?;
1253        let log = durable.list();
1254        ensure!(
1255            metadata.session_id == log.header.session_id,
1256            "session metadata and session-log identities differ"
1257        );
1258        ensure!(
1259            metadata.created_at == log.header.created_at,
1260            "session metadata and session-log creation times differ"
1261        );
1262        let mut chatend = Chatend::opened(metadata);
1263        let mut objects = BTreeMap::new();
1264        for (position, stored) in log.events.iter().enumerate() {
1265            let persisted = decode_context_event(stored)?;
1266            let id = EventId(position as u64 + 1);
1267            let mut kind = persisted.kind;
1268            normalize_derived_identity(&mut kind, id)?;
1269            let event = Event {
1270                id,
1271                recorded_at: persisted.recorded_at.clone(),
1272                kind: kind.clone(),
1273            };
1274            chatend.apply_transition(&Transition {
1275                recorded_at: persisted.recorded_at,
1276                events: vec![event],
1277            })?;
1278            if let EventKind::PendingAllocated {
1279                pending_id,
1280                resource: PendingKind::Object,
1281            } = kind
1282            {
1283                let object = durable.read_pending_object(EventPosition(position as u64))?;
1284                let metadata = ObjectMetadata {
1285                    pending_id: pending_id.clone(),
1286                    event_id: id,
1287                    recorded_at: chatend
1288                        .event(id)
1289                        .map(|event| event.recorded_at.clone())
1290                        .unwrap_or_default(),
1291                    media_type: object.media_type,
1292                    file_name: Some(object.file_name),
1293                    transport: Value::Null,
1294                };
1295                objects.insert(
1296                    pending_id,
1297                    ObjectLocation {
1298                        metadata,
1299                        payload_offset: position as u64,
1300                        payload_len: object.bytes.len() as u64,
1301                    },
1302                );
1303            }
1304        }
1305        Ok(Self {
1306            durable,
1307            chatend,
1308            objects,
1309        })
1310    }
1311
1312    pub fn id(&self) -> &str {
1313        &self.chatend.metadata.session_id
1314    }
1315
1316    pub fn state(&self) -> &Chatend {
1317        &self.chatend
1318    }
1319
1320    pub fn objects(&self) -> &BTreeMap<PendingId, ObjectLocation> {
1321        &self.objects
1322    }
1323
1324    #[cfg(test)]
1325    fn session_log(&self) -> kcode_session_log::SessionLog {
1326        self.durable.list()
1327    }
1328
1329    pub fn archive_bytes(&self) -> anyhow::Result<Vec<u8>> {
1330        serde_json::to_vec(&self.durable.list()).context("serializing the session archive")
1331    }
1332
1333    pub fn is_sealed(&self) -> bool {
1334        self.durable.is_sealed()
1335    }
1336
1337    pub fn seal(&mut self) -> anyhow::Result<()> {
1338        let unfinished_tools = self.unfinished_tool_invocations()?;
1339        ensure!(
1340            unfinished_tools.is_empty(),
1341            "session ends with unfinished tools {}",
1342            unfinished_tools
1343                .iter()
1344                .map(|tool| tool.tool_name.as_str())
1345                .collect::<Vec<_>>()
1346                .join(", ")
1347        );
1348        self.durable.seal()?;
1349        Ok(())
1350    }
1351
1352    pub fn repair_unfinished_tools(
1353        &mut self,
1354        recorded_at: impl Into<String>,
1355    ) -> anyhow::Result<Vec<EventId>> {
1356        let unfinished = self.unfinished_tool_invocations()?;
1357        if unfinished.is_empty() {
1358            return Ok(Vec::new());
1359        }
1360        let recorded_at = recorded_at.into();
1361        let mut repaired = Vec::with_capacity(unfinished.len());
1362        for tool in unfinished.iter().rev() {
1363            let message = format!(
1364                "{} was interrupted before a durable completion was recorded; the abandoned invocation was closed during session recovery.",
1365                tool.tool_name
1366            );
1367            let kind = if let Some(invocation_id) = &tool.invocation_id {
1368                EventKind::ToolCompleted {
1369                    tool_instance: tool.tool_instance.clone(),
1370                    tool_name: tool.tool_name.clone(),
1371                    outcome: serde_json::json!({"ok":false,"recovered":true,"result":message}),
1372                    invocation_id: Some(invocation_id.clone()),
1373                }
1374            } else {
1375                // Historical native Ktool completions used one generic
1376                // `call_ktool` event to close the most recent invocation.
1377                EventKind::ToolCompleted {
1378                    tool_instance: "call_ktool".into(),
1379                    tool_name: "call_ktool".into(),
1380                    outcome: serde_json::json!({"ok":false,"recovered":true,"result":message}),
1381                    invocation_id: None,
1382                }
1383            };
1384            repaired.push(self.record(recorded_at.clone(), kind)?);
1385        }
1386        ensure!(
1387            self.unfinished_tool_invocations()?.is_empty(),
1388            "session tool recovery left unfinished invocations"
1389        );
1390        Ok(repaired)
1391    }
1392
1393    fn unfinished_tool_invocations(&self) -> anyhow::Result<Vec<UnfinishedToolInvocation>> {
1394        let mut identified = BTreeMap::<String, UnfinishedToolInvocation>::new();
1395        let mut legacy = Vec::<UnfinishedToolInvocation>::new();
1396        for event in &self.chatend.events {
1397            match &event.kind {
1398                EventKind::ToolInvoked {
1399                    tool_instance,
1400                    tool_name,
1401                    invocation_id,
1402                    ..
1403                } => {
1404                    let pending = UnfinishedToolInvocation {
1405                        invocation_id: invocation_id.clone(),
1406                        tool_instance: tool_instance.clone(),
1407                        tool_name: tool_name.clone(),
1408                    };
1409                    if let Some(invocation_id) = invocation_id {
1410                        ensure!(
1411                            identified.insert(invocation_id.clone(), pending).is_none(),
1412                            "duplicate tool invocation identity {invocation_id}"
1413                        );
1414                    } else {
1415                        legacy.push(pending);
1416                    }
1417                }
1418                EventKind::ToolCompleted {
1419                    tool_instance,
1420                    tool_name,
1421                    invocation_id,
1422                    ..
1423                } => {
1424                    if let Some(invocation_id) = invocation_id {
1425                        let invoked = identified.remove(invocation_id).with_context(|| {
1426                            format!(
1427                                "tool {tool_name} completed without matching invocation {invocation_id}"
1428                            )
1429                        })?;
1430                        ensure!(
1431                            invoked.tool_instance.as_str() == tool_instance
1432                                && invoked.tool_name.as_str() == tool_name,
1433                            "tool completion {invocation_id} does not match its invocation"
1434                        );
1435                    } else if tool_name == "call_ktool" {
1436                        // An invalid native call still produces an error result
1437                        // even when it could not be decoded into ToolInvoked.
1438                        legacy.pop();
1439                    } else {
1440                        let before = legacy.len();
1441                        legacy.retain(|unfinished| unfinished.tool_name.as_str() != tool_name);
1442                        ensure!(
1443                            legacy.len() != before,
1444                            "tool {tool_name} completed without a matching invocation"
1445                        );
1446                    }
1447                }
1448                _ => {}
1449            }
1450        }
1451        legacy.extend(identified.into_values());
1452        Ok(legacy)
1453    }
1454
1455    pub fn mark_completed(&mut self, session_object_id: String) {
1456        self.chatend.completed_session_object = Some(session_object_id);
1457    }
1458
1459    pub fn configure_context(&mut self, kind: SessionKind, effective_context_tokens: u64) {
1460        self.chatend.metadata.kind = kind;
1461        self.chatend.metadata.effective_context_tokens = effective_context_tokens;
1462    }
1463
1464    pub fn create_box(
1465        &mut self,
1466        recorded_at: impl Into<String>,
1467        name: impl Into<String>,
1468        owner: BoxOwner,
1469        content: BoxContent,
1470    ) -> anyhow::Result<BoxId> {
1471        let recorded_at = recorded_at.into();
1472        let id = EventId(self.chatend.next_id);
1473        let box_id = BoxId(id.0);
1474        self.commit_events(
1475            recorded_at.clone(),
1476            vec![Event {
1477                id,
1478                recorded_at,
1479                kind: EventKind::BoxCreated {
1480                    box_id,
1481                    name: name.into(),
1482                    owner,
1483                    content,
1484                },
1485            }],
1486        )?;
1487        Ok(box_id)
1488    }
1489
1490    pub fn update_box(
1491        &mut self,
1492        recorded_at: impl Into<String>,
1493        box_id: BoxId,
1494        content: BoxContent,
1495    ) -> anyhow::Result<Option<EventId>> {
1496        let state = self
1497            .chatend
1498            .boxes
1499            .get(&box_id)
1500            .with_context(|| format!("box {box_id} does not exist"))?;
1501        ensure!(state.active, "box {box_id} is retired");
1502        if state.canonical.content == content {
1503            return Ok(None);
1504        }
1505        let id = EventId(self.chatend.next_id);
1506        let recorded_at = recorded_at.into();
1507        self.commit_events(
1508            recorded_at.clone(),
1509            vec![Event {
1510                id,
1511                recorded_at,
1512                kind: EventKind::CanonicalUpdated { box_id, content },
1513            }],
1514        )?;
1515        Ok(Some(id))
1516    }
1517
1518    pub fn dehydrate_box(
1519        &mut self,
1520        recorded_at: impl Into<String>,
1521        box_id: BoxId,
1522    ) -> anyhow::Result<EventId> {
1523        self.box_operation(recorded_at, EventKind::BoxDehydrated { box_id })
1524    }
1525
1526    pub fn summarize_box(
1527        &mut self,
1528        recorded_at: impl Into<String>,
1529        box_id: BoxId,
1530        text: impl Into<String>,
1531    ) -> anyhow::Result<EventId> {
1532        self.box_operation(
1533            recorded_at,
1534            EventKind::BoxSummarized {
1535                box_id,
1536                text: text.into(),
1537            },
1538        )
1539    }
1540
1541    pub fn rehydrate_box(
1542        &mut self,
1543        recorded_at: impl Into<String>,
1544        box_id: BoxId,
1545    ) -> anyhow::Result<EventId> {
1546        self.box_operation(recorded_at, EventKind::BoxRehydrated { box_id })
1547    }
1548
1549    pub fn retire_box(
1550        &mut self,
1551        recorded_at: impl Into<String>,
1552        box_id: BoxId,
1553    ) -> anyhow::Result<EventId> {
1554        self.box_operation(recorded_at, EventKind::BoxRetired { box_id })
1555    }
1556
1557    fn box_operation(
1558        &mut self,
1559        recorded_at: impl Into<String>,
1560        kind: EventKind,
1561    ) -> anyhow::Result<EventId> {
1562        let box_id = event_box_id(&kind).context("box operation has no box identity")?;
1563        let state = self
1564            .chatend
1565            .box_state(box_id)
1566            .with_context(|| format!("box {box_id} does not exist"))?;
1567        ensure!(state.active, "box {box_id} is retired");
1568        if let EventKind::BoxSummarized { text, .. } = &kind {
1569            ensure!(!text.trim().is_empty(), "a box summary cannot be empty");
1570        }
1571        if matches!(kind, EventKind::BoxRetired { .. })
1572            && let BoxOwner::Tool { tool_instance, .. } = &state.owner
1573        {
1574            ensure!(
1575                self.chatend
1576                    .tools
1577                    .get(tool_instance)
1578                    .is_some_and(|tool| tool.slots.iter().any(|slot| slot.box_id == box_id)),
1579                "tool box {box_id} is missing from {tool_instance}"
1580            );
1581        }
1582        let id = EventId(self.chatend.next_id);
1583        let recorded_at = recorded_at.into();
1584        self.commit_events(
1585            recorded_at.clone(),
1586            vec![Event {
1587                id,
1588                recorded_at,
1589                kind,
1590            }],
1591        )?;
1592        Ok(id)
1593    }
1594
1595    pub fn allocate_pending_node(
1596        &mut self,
1597        recorded_at: impl Into<String>,
1598    ) -> anyhow::Result<PendingId> {
1599        let id = EventId(self.chatend.next_id);
1600        let pending_id = PendingId::from_event(id);
1601        let recorded_at = recorded_at.into();
1602        self.commit_events(
1603            recorded_at.clone(),
1604            vec![Event {
1605                id,
1606                recorded_at,
1607                kind: EventKind::PendingAllocated {
1608                    pending_id: pending_id.clone(),
1609                    resource: PendingKind::Node,
1610                },
1611            }],
1612        )?;
1613        Ok(pending_id)
1614    }
1615
1616    pub fn stage_object(
1617        &mut self,
1618        recorded_at: impl Into<String>,
1619        media_type: impl Into<String>,
1620        file_name: Option<String>,
1621        transport: Value,
1622        bytes: &[u8],
1623    ) -> anyhow::Result<PendingId> {
1624        ensure!(
1625            bytes.len() as u64 <= MAX_OBJECT_BYTES,
1626            "object exceeds the 32 GiB V1 limit"
1627        );
1628        let aggregate = self
1629            .objects
1630            .values()
1631            .try_fold(bytes.len() as u64, |total, object| {
1632                total.checked_add(object.payload_len)
1633            })
1634            .context("staged object aggregate length overflow")?;
1635        ensure!(
1636            aggregate <= MAX_OBJECT_BYTES,
1637            "session object payload total exceeds the 32 GiB V1 limit"
1638        );
1639        let event_id = EventId(self.chatend.next_id);
1640        let pending_id = PendingId::from_event(event_id);
1641        let metadata = ObjectMetadata {
1642            pending_id: pending_id.clone(),
1643            event_id,
1644            recorded_at: recorded_at.into(),
1645            media_type: media_type.into(),
1646            file_name,
1647            transport,
1648        };
1649        let kind = EventKind::PendingAllocated {
1650            pending_id: pending_id.clone(),
1651            resource: PendingKind::Object,
1652        };
1653        let text = encode_context_event(&metadata.recorded_at, &kind)?;
1654        let object_file_name = metadata
1655            .file_name
1656            .clone()
1657            .unwrap_or_else(|| format!("object-{}", event_id.0));
1658        let position = self.durable.add_pending_object(
1659            text,
1660            object_file_name,
1661            metadata.media_type.clone(),
1662            bytes,
1663        )?;
1664        ensure!(
1665            position.0 + 1 == event_id.0,
1666            "session-log event position diverged from Kennedy context identity"
1667        );
1668        let allocation = Event {
1669            id: event_id,
1670            recorded_at: metadata.recorded_at.clone(),
1671            kind,
1672        };
1673        self.chatend.apply_transition(&Transition {
1674            recorded_at: metadata.recorded_at.clone(),
1675            events: vec![allocation],
1676        })?;
1677        self.objects.insert(
1678            pending_id.clone(),
1679            ObjectLocation {
1680                metadata,
1681                payload_offset: position.0,
1682                payload_len: bytes.len() as u64,
1683            },
1684        );
1685        Ok(pending_id)
1686    }
1687
1688    pub fn read_object(&mut self, id: &PendingId) -> anyhow::Result<Vec<u8>> {
1689        let location = self
1690            .objects
1691            .get(id)
1692            .with_context(|| format!("staged object {id} does not exist"))?
1693            .clone();
1694        Ok(self
1695            .durable
1696            .read_pending_object(EventPosition(location.payload_offset))?
1697            .bytes)
1698    }
1699
1700    pub fn record(
1701        &mut self,
1702        recorded_at: impl Into<String>,
1703        kind: EventKind,
1704    ) -> anyhow::Result<EventId> {
1705        let id = EventId(self.chatend.next_id);
1706        let recorded_at = recorded_at.into();
1707        self.commit_events(
1708            recorded_at.clone(),
1709            vec![Event {
1710                id,
1711                recorded_at,
1712                kind,
1713            }],
1714        )?;
1715        Ok(id)
1716    }
1717
1718    pub fn commit_events(
1719        &mut self,
1720        recorded_at: impl Into<String>,
1721        events: Vec<Event>,
1722    ) -> anyhow::Result<()> {
1723        let transition = Transition {
1724            recorded_at: recorded_at.into(),
1725            events,
1726        };
1727        ensure!(
1728            !transition.events.is_empty(),
1729            "a transition cannot be empty"
1730        );
1731        ensure!(
1732            !transition.events.iter().any(|event| {
1733                matches!(
1734                    event.kind,
1735                    EventKind::PendingAllocated {
1736                        resource: PendingKind::Object,
1737                        ..
1738                    }
1739                )
1740            }),
1741            "pending objects must be added through stage_object"
1742        );
1743        let mut preview = self.chatend.clone();
1744        preview.apply_transition(&transition)?;
1745        for event in &transition.events {
1746            let expected = self.durable.list().events.len() as u64 + 1;
1747            ensure!(
1748                event.id.0 == expected,
1749                "Kennedy context event {} does not match session-log position {}",
1750                event.id,
1751                expected - 1
1752            );
1753            self.durable.add_event(
1754                context_event_role(&event.kind),
1755                encode_context_event(&event.recorded_at, &event.kind)?,
1756            )?;
1757        }
1758        self.chatend = preview;
1759        Ok(())
1760    }
1761
1762    pub fn apply_tool_slots(
1763        &mut self,
1764        recorded_at: impl Into<String>,
1765        tool_instance: impl Into<String>,
1766        slots: Vec<ToolSlotInput>,
1767    ) -> anyhow::Result<Vec<EventId>> {
1768        self.apply_tool_slots_inner(recorded_at, tool_instance, slots, None)
1769    }
1770
1771    pub fn apply_tool_slots_with_layout(
1772        &mut self,
1773        recorded_at: impl Into<String>,
1774        tool_instance: impl Into<String>,
1775        slots: Vec<ToolSlotInput>,
1776        layout_slots: &[String],
1777    ) -> anyhow::Result<Vec<EventId>> {
1778        self.apply_tool_slots_inner(recorded_at, tool_instance, slots, Some(layout_slots))
1779    }
1780
1781    fn apply_tool_slots_inner(
1782        &mut self,
1783        recorded_at: impl Into<String>,
1784        tool_instance: impl Into<String>,
1785        slots: Vec<ToolSlotInput>,
1786        layout_slots: Option<&[String]>,
1787    ) -> anyhow::Result<Vec<EventId>> {
1788        let recorded_at = recorded_at.into();
1789        let tool_instance = tool_instance.into();
1790        let current = self
1791            .chatend
1792            .tools
1793            .get(&tool_instance)
1794            .cloned()
1795            .unwrap_or_default();
1796        ensure!(
1797            slots.len() >= current.slots.len(),
1798            "stateful tool slot sequence was truncated"
1799        );
1800        for (index, existing) in current.slots.iter().enumerate() {
1801            ensure!(
1802                slots[index].slot == existing.slot,
1803                "stateful tool slot sequence was reordered at index {index}"
1804            );
1805            ensure!(
1806                !existing.retired || slots[index].retired,
1807                "retired tool slot {} cannot be reactivated",
1808                existing.slot
1809            );
1810        }
1811        let mut events = Vec::new();
1812        let mut next = self.chatend.next_id;
1813        let mut next_state = current.clone();
1814        for (index, input) in slots.iter().enumerate() {
1815            if let Some(existing) = current.slots.get(index) {
1816                let state = self
1817                    .chatend
1818                    .boxes
1819                    .get(&existing.box_id)
1820                    .context("tool slot references a missing box")?;
1821                if input.retired && !existing.retired {
1822                    let id = EventId(next);
1823                    next += 1;
1824                    events.push(Event {
1825                        id,
1826                        recorded_at: recorded_at.clone(),
1827                        kind: EventKind::BoxRetired {
1828                            box_id: existing.box_id,
1829                        },
1830                    });
1831                    next_state.slots[index].retired = true;
1832                } else if !input.retired {
1833                    if state.name != input.name {
1834                        let id = EventId(next);
1835                        next += 1;
1836                        events.push(Event {
1837                            id,
1838                            recorded_at: recorded_at.clone(),
1839                            kind: EventKind::BoxRenamed {
1840                                box_id: existing.box_id,
1841                                name: input.name.clone(),
1842                            },
1843                        });
1844                    }
1845                    if state.canonical.content != input.content {
1846                        let id = EventId(next);
1847                        next += 1;
1848                        events.push(Event {
1849                            id,
1850                            recorded_at: recorded_at.clone(),
1851                            kind: EventKind::CanonicalUpdated {
1852                                box_id: existing.box_id,
1853                                content: input.content.clone(),
1854                            },
1855                        });
1856                    }
1857                }
1858            } else {
1859                ensure!(
1860                    !input.retired,
1861                    "a newly appended tool slot cannot start retired"
1862                );
1863                let id = EventId(next);
1864                next += 1;
1865                let box_id = BoxId(id.0);
1866                events.push(Event {
1867                    id,
1868                    recorded_at: recorded_at.clone(),
1869                    kind: EventKind::BoxCreated {
1870                        box_id,
1871                        name: input.name.clone(),
1872                        owner: BoxOwner::Tool {
1873                            tool_instance: tool_instance.clone(),
1874                            slot: input.slot.clone(),
1875                        },
1876                        content: input.content.clone(),
1877                    },
1878                });
1879                next_state.slots.push(ToolSlot {
1880                    slot: input.slot.clone(),
1881                    box_id,
1882                    retired: false,
1883                });
1884            }
1885        }
1886        if let Some(layout_slots) = layout_slots {
1887            let mut unique = std::collections::HashSet::new();
1888            let box_ids = layout_slots
1889                .iter()
1890                .map(|slot_name| {
1891                    ensure!(
1892                        unique.insert(slot_name),
1893                        "tool layout contains duplicate slot {slot_name}"
1894                    );
1895                    let slot = next_state
1896                        .slots
1897                        .iter()
1898                        .find(|slot| &slot.slot == slot_name)
1899                        .with_context(|| {
1900                            format!("tool layout references missing slot {slot_name}")
1901                        })?;
1902                    ensure!(
1903                        !slot.retired,
1904                        "tool layout references retired slot {slot_name}"
1905                    );
1906                    Ok(slot.box_id)
1907                })
1908                .collect::<anyhow::Result<Vec<_>>>()?;
1909            if self.chatend.tool_layouts.get(&tool_instance) != Some(&box_ids) {
1910                let id = EventId(next);
1911                events.push(Event {
1912                    id,
1913                    recorded_at: recorded_at.clone(),
1914                    kind: EventKind::ToolLayoutChanged {
1915                        tool_instance: tool_instance.clone(),
1916                        box_ids,
1917                    },
1918                });
1919            }
1920        }
1921        if events.is_empty() {
1922            return Ok(Vec::new());
1923        }
1924        let ids = events.iter().map(|event| event.id).collect::<Vec<_>>();
1925        self.commit_events(recorded_at, events)?;
1926        Ok(ids)
1927    }
1928
1929    pub fn apply_box_representations(
1930        &mut self,
1931        recorded_at: impl Into<String>,
1932        desired: &BTreeMap<BoxId, BoxRepresentation>,
1933    ) -> anyhow::Result<Vec<EventId>> {
1934        let recorded_at = recorded_at.into();
1935        let events = self
1936            .chatend
1937            .box_representation_events(&recorded_at, desired)?;
1938        if events.is_empty() {
1939            return Ok(Vec::new());
1940        }
1941        let ids = events.iter().map(|event| event.id).collect::<Vec<_>>();
1942        self.commit_events(recorded_at, events)?;
1943        Ok(ids)
1944    }
1945}
1946
1947#[cfg(test)]
1948type SessionJournal = Session;
1949
1950#[cfg(test)]
1951mod tests {
1952    use std::path::PathBuf;
1953    use std::time::{SystemTime, UNIX_EPOCH};
1954
1955    use serde_json::json;
1956
1957    use super::*;
1958
1959    fn path(label: &str) -> PathBuf {
1960        std::env::temp_dir()
1961            .join(format!(
1962                "kennedy-chatend-{label}-{}-{}",
1963                std::process::id(),
1964                SystemTime::now()
1965                    .duration_since(UNIX_EPOCH)
1966                    .unwrap()
1967                    .as_nanos()
1968            ))
1969            .join("session-1.session-log")
1970    }
1971
1972    fn metadata() -> SessionMetadata {
1973        SessionMetadata {
1974            session_id: "session-1".into(),
1975            kind: SessionKind::Conversation,
1976            created_at: "2026-07-23T00:00:00Z".into(),
1977            effective_context_tokens: 1_000,
1978            channel: json!({"kind":"test"}),
1979        }
1980    }
1981
1982    #[test]
1983    fn box_identity_continuations_staleness_and_replay_are_exact() {
1984        let path = path("boxes");
1985        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
1986        let id = journal
1987            .create_box(
1988                "t1",
1989                "message",
1990                BoxOwner::User,
1991                BoxContent::text("original"),
1992            )
1993            .unwrap();
1994        assert_eq!(id, BoxId(1));
1995        journal.summarize_box("t2", id, "summary").unwrap();
1996        journal
1997            .update_box("t3", id, BoxContent::text("changed"))
1998            .unwrap();
1999        let state = journal.state().box_state(id).unwrap().clone();
2000        assert!(state.stale());
2001        assert_eq!(
2002            state.representation,
2003            Representation::Summarized {
2004                based_on: EventId(1),
2005                text: "summary".into()
2006            }
2007        );
2008        let projection = journal.state().projection();
2009        assert_eq!(projection.items[0].text, "[box updated]");
2010        assert_eq!(projection.items[1].text, "[box updated]");
2011        assert!(projection.items[2].text.contains("summary"));
2012        assert!(projection.items[2].stale);
2013        drop(journal);
2014        let reopened = SessionJournal::open_with_metadata(&path, metadata()).unwrap();
2015        assert_eq!(reopened.state().box_state(id), Some(&state));
2016        std::fs::remove_file(path).unwrap();
2017    }
2018
2019    #[test]
2020    fn concise_box_headers_omit_internal_ownership() {
2021        let path = path("concise-box-header");
2022        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
2023        let mut content = BoxContent::text("Node ID: AAAAAAAB");
2024        content.use_concise_header();
2025        journal
2026            .create_box(
2027                "t1",
2028                "Kweb loaded node",
2029                BoxOwner::Tool {
2030                    tool_instance: "kweb".into(),
2031                    slot: "loaded".into(),
2032                },
2033                content,
2034            )
2035            .unwrap();
2036        assert_eq!(
2037            journal.state().projection().items[0].text,
2038            "[box 1 | Kweb loaded node | hydrated]\nNode ID: AAAAAAAB"
2039        );
2040        std::fs::remove_file(path).unwrap();
2041    }
2042
2043    #[test]
2044    fn shared_pending_and_box_identity_space_never_overlaps() {
2045        let path = path("pending");
2046        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
2047        let first = journal.allocate_pending_node("t1").unwrap();
2048        let box_id = journal
2049            .create_box("t2", "box", BoxOwner::Kennedy, BoxContent::text("hello"))
2050            .unwrap();
2051        let object = journal
2052            .stage_object(
2053                "t3",
2054                "application/octet-stream",
2055                None,
2056                Value::Null,
2057                b"\0binary\xff",
2058            )
2059            .unwrap();
2060        assert_eq!(first.to_string(), "pending:1");
2061        assert_eq!(box_id, BoxId(2));
2062        assert_eq!(object.to_string(), "pending:3");
2063        assert_eq!(journal.read_object(&object).unwrap(), b"\0binary\xff");
2064        let stored = journal.session_log();
2065        assert!(!stored.events[0].text.contains("pending_id"));
2066        assert!(!stored.events[1].text.contains("box_id"));
2067        assert!(!stored.events[2].text.contains("pending_id"));
2068        drop(journal);
2069        let mut reopened = SessionJournal::open_with_metadata(&path, metadata()).unwrap();
2070        assert_eq!(reopened.read_object(&object).unwrap(), b"\0binary\xff");
2071        assert_eq!(reopened.state().next_id, 4);
2072        std::fs::remove_file(path).unwrap();
2073    }
2074
2075    #[test]
2076    fn kennedy_tool_completion_is_validated_before_storage_seals() {
2077        let path = path("seal-tool");
2078        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
2079        journal
2080            .record(
2081                "t1",
2082                EventKind::ToolInvoked {
2083                    tool_instance: "CreateNode:1".into(),
2084                    tool_name: "CreateNode".into(),
2085                    arguments: json!({}),
2086                    invocation_id: None,
2087                },
2088            )
2089            .unwrap();
2090        assert!(journal.seal().is_err());
2091        journal
2092            .record(
2093                "t2",
2094                EventKind::ToolCompleted {
2095                    tool_instance: "call_ktool".into(),
2096                    tool_name: "call_ktool".into(),
2097                    outcome: json!({"ok":true}),
2098                    invocation_id: None,
2099                },
2100            )
2101            .unwrap();
2102        journal.seal().unwrap();
2103        assert!(journal.is_sealed());
2104        std::fs::remove_file(path).unwrap();
2105    }
2106
2107    #[test]
2108    fn interrupted_tools_are_recovered_by_identity_without_losing_legacy_journals() {
2109        let path = path("repair-tools");
2110        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
2111        journal
2112            .record(
2113                "t1",
2114                EventKind::ToolInvoked {
2115                    tool_instance: "WebSearch:legacy".into(),
2116                    tool_name: "WebSearch".into(),
2117                    arguments: json!({"question":"legacy"}),
2118                    invocation_id: None,
2119                },
2120            )
2121            .unwrap();
2122        journal
2123            .record(
2124                "t2",
2125                EventKind::ToolInvoked {
2126                    tool_instance: "WebSearch:new".into(),
2127                    tool_name: "WebSearch".into(),
2128                    arguments: json!({"question":"identified"}),
2129                    invocation_id: Some("call-1".into()),
2130                },
2131            )
2132            .unwrap();
2133        assert!(journal.seal().is_err());
2134
2135        let repaired = journal.repair_unfinished_tools("recovery").unwrap();
2136        assert_eq!(repaired.len(), 2);
2137        assert!(
2138            journal
2139                .state()
2140                .events
2141                .iter()
2142                .rev()
2143                .take(2)
2144                .all(|event| matches!(
2145                    &event.kind,
2146                    EventKind::ToolCompleted { outcome, .. }
2147                        if outcome.get("recovered").and_then(Value::as_bool) == Some(true)
2148                ))
2149        );
2150        journal.seal().unwrap();
2151        assert!(journal.is_sealed());
2152        std::fs::remove_file(path).unwrap();
2153    }
2154
2155    #[test]
2156    fn identified_tool_completions_can_arrive_out_of_order() {
2157        let path = path("tool-identity");
2158        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
2159        for id in ["call-1", "call-2"] {
2160            journal
2161                .record(
2162                    id,
2163                    EventKind::ToolInvoked {
2164                        tool_instance: format!("WebSearch:{id}"),
2165                        tool_name: "WebSearch".into(),
2166                        arguments: json!({"question":id}),
2167                        invocation_id: Some(id.into()),
2168                    },
2169                )
2170                .unwrap();
2171        }
2172        for id in ["call-1", "call-2"] {
2173            journal
2174                .record(
2175                    format!("{id}-complete"),
2176                    EventKind::ToolCompleted {
2177                        tool_instance: format!("WebSearch:{id}"),
2178                        tool_name: "WebSearch".into(),
2179                        outcome: json!({"ok":true}),
2180                        invocation_id: Some(id.into()),
2181                    },
2182                )
2183                .unwrap();
2184        }
2185        journal.seal().unwrap();
2186        std::fs::remove_file(path).unwrap();
2187    }
2188
2189    #[test]
2190    fn invalid_box_operations_do_not_poison_the_append_only_journal() {
2191        let path = path("invalid-box-operation");
2192        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
2193        let box_id = journal
2194            .create_box(
2195                "t1",
2196                "valid",
2197                BoxOwner::Controller,
2198                BoxContent::text("canonical"),
2199            )
2200            .unwrap();
2201        let valid_length = std::fs::metadata(&path).unwrap().len();
2202
2203        for result in [
2204            journal.dehydrate_box("t2", BoxId(97)),
2205            journal.summarize_box("t3", BoxId(97), "summary"),
2206            journal.rehydrate_box("t4", BoxId(97)),
2207            journal.retire_box("t5", BoxId(97)),
2208        ] {
2209            assert_eq!(result.unwrap_err().to_string(), "box 97 does not exist");
2210            assert_eq!(std::fs::metadata(&path).unwrap().len(), valid_length);
2211        }
2212
2213        drop(journal);
2214        let reopened = SessionJournal::open_with_metadata(&path, metadata()).unwrap();
2215        assert_eq!(
2216            reopened
2217                .state()
2218                .box_state(box_id)
2219                .unwrap()
2220                .canonical
2221                .content,
2222            BoxContent::text("canonical")
2223        );
2224        std::fs::remove_file(path).unwrap();
2225    }
2226
2227    #[test]
2228    fn provider_measurements_recalibrate_the_matching_manifest_then_track_deltas() {
2229        let path = path("calibration");
2230        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
2231        journal
2232            .create_box(
2233                "t1",
2234                "system",
2235                BoxOwner::System,
2236                BoxContent::text("baseline provider content"),
2237            )
2238            .unwrap();
2239        let submitted = journal.state().projection();
2240        journal
2241            .record(
2242                "t2",
2243                EventKind::InferenceSubmitted {
2244                    manifest_hash: "manifest-1".into(),
2245                    estimated_input_tokens: submitted.estimated_tokens,
2246                    raw_estimated_input_tokens: Some(submitted.raw_estimated_tokens),
2247                },
2248            )
2249            .unwrap();
2250        journal
2251            .create_box(
2252                "t3",
2253                "tool result",
2254                BoxOwner::Controller,
2255                BoxContent::text("x".repeat(3_000)),
2256            )
2257            .unwrap();
2258        let measured_context = journal.state().projection();
2259        journal
2260            .record(
2261                "t4",
2262                EventKind::ProviderReceipt {
2263                    manifest_hash: "manifest-1".into(),
2264                    input_tokens: Some(777),
2265                    output_tokens: Some(3),
2266                    raw_context_tokens: Some(measured_context.raw_estimated_tokens),
2267                    provider_data: Value::Null,
2268                },
2269            )
2270            .unwrap();
2271        assert_eq!(journal.state().projection().estimated_tokens, 777);
2272        assert!(
2273            777 + measured_context
2274                .raw_estimated_tokens
2275                .saturating_sub(submitted.raw_estimated_tokens)
2276                > journal.state().projection().estimated_tokens
2277        );
2278        journal
2279            .create_box(
2280                "t5",
2281                "new material",
2282                BoxOwner::User,
2283                BoxContent::text("x".repeat(300)),
2284            )
2285            .unwrap();
2286        let expanded = journal.state().projection();
2287        assert!(expanded.estimated_tokens > 777);
2288        assert!(expanded.raw_estimated_tokens > submitted.raw_estimated_tokens);
2289        std::fs::remove_file(path).unwrap();
2290    }
2291
2292    #[test]
2293    fn legacy_provider_receipts_without_a_raw_context_anchor_remain_readable() {
2294        let receipt: EventKind = serde_json::from_value(json!({
2295            "type":"provider_receipt",
2296            "manifest_hash":"legacy-manifest",
2297            "input_tokens":123,
2298            "output_tokens":4,
2299            "provider_data":null
2300        }))
2301        .unwrap();
2302        assert!(matches!(
2303            receipt,
2304            EventKind::ProviderReceipt {
2305                raw_context_tokens: None,
2306                ..
2307            }
2308        ));
2309    }
2310
2311    #[test]
2312    fn stateful_tool_slots_are_batched_append_only_and_do_not_see_summaries() {
2313        let path = path("slots");
2314        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
2315        journal
2316            .apply_tool_slots(
2317                "t1",
2318                "rust-1",
2319                vec![
2320                    ToolSlotInput {
2321                        slot: "a.rs".into(),
2322                        name: "a.rs".into(),
2323                        content: BoxContent::text("a"),
2324                        retired: false,
2325                    },
2326                    ToolSlotInput {
2327                        slot: "b.rs".into(),
2328                        name: "b.rs".into(),
2329                        content: BoxContent::text("b"),
2330                        retired: false,
2331                    },
2332                ],
2333            )
2334            .unwrap();
2335        let a = journal.state().tools["rust-1"].slots[0].box_id;
2336        journal.summarize_box("t2", a, "Kennedy summary").unwrap();
2337        let before = journal.state().clone();
2338        assert!(
2339            journal
2340                .apply_tool_slots(
2341                    "t3",
2342                    "rust-1",
2343                    vec![ToolSlotInput {
2344                        slot: "b.rs".into(),
2345                        name: "b.rs".into(),
2346                        content: BoxContent::text("b"),
2347                        retired: false,
2348                    }]
2349                )
2350                .is_err()
2351        );
2352        assert_eq!(journal.state(), &before);
2353        journal
2354            .apply_tool_slots(
2355                "t4",
2356                "rust-1",
2357                vec![
2358                    ToolSlotInput {
2359                        slot: "a.rs".into(),
2360                        name: "a.rs".into(),
2361                        content: BoxContent::text("a2"),
2362                        retired: false,
2363                    },
2364                    ToolSlotInput {
2365                        slot: "b.rs".into(),
2366                        name: "b.rs".into(),
2367                        content: BoxContent::text("b"),
2368                        retired: true,
2369                    },
2370                    ToolSlotInput {
2371                        slot: "c.rs".into(),
2372                        name: "c.rs".into(),
2373                        content: BoxContent::text("c"),
2374                        retired: false,
2375                    },
2376                ],
2377            )
2378            .unwrap();
2379        let a_state = journal.state().box_state(a).unwrap();
2380        assert_eq!(a_state.canonical.content.text, "a2");
2381        assert!(matches!(
2382            a_state.representation,
2383            Representation::Summarized { .. }
2384        ));
2385        drop(journal);
2386        let reopened = SessionJournal::open_with_metadata(&path, metadata()).unwrap();
2387        assert_eq!(reopened.state().tools["rust-1"].slots.len(), 3);
2388        assert!(reopened.state().tools["rust-1"].slots[1].retired);
2389        std::fs::remove_file(path).unwrap();
2390    }
2391
2392    #[test]
2393    fn tool_layout_orders_current_boxes_without_changing_their_identities() {
2394        let path = path("tool-layout");
2395        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
2396        journal
2397            .apply_tool_slots_with_layout(
2398                "t1",
2399                "kweb",
2400                vec![
2401                    ToolSlotInput {
2402                        slot: "active".into(),
2403                        name: "Active node".into(),
2404                        content: BoxContent::text("ACTIVE NODE"),
2405                        retired: false,
2406                    },
2407                    ToolSlotInput {
2408                        slot: "direct".into(),
2409                        name: "Direct node".into(),
2410                        content: BoxContent::text("DIRECT NODE"),
2411                        retired: false,
2412                    },
2413                ],
2414                &["direct".into(), "active".into()],
2415            )
2416            .unwrap();
2417        let tool = &journal.state().tools["kweb"];
2418        let active_id = tool.slots[0].box_id;
2419        let direct_id = tool.slots[1].box_id;
2420        let rendered = journal.state().render();
2421        assert!(rendered.find("DIRECT NODE") < rendered.find("ACTIVE NODE"));
2422        assert_eq!(
2423            journal.state().tool_layouts["kweb"],
2424            vec![direct_id, active_id]
2425        );
2426        drop(journal);
2427        let reopened = SessionJournal::open_with_metadata(&path, metadata()).unwrap();
2428        assert_eq!(reopened.state().render(), rendered);
2429        std::fs::remove_file(path).unwrap();
2430    }
2431
2432    #[test]
2433    fn limits_use_exact_floor_percentages() {
2434        let state = Chatend::opened(SessionMetadata {
2435            effective_context_tokens: 101,
2436            ..metadata()
2437        });
2438        assert_eq!(state.live_context_limit(), 70);
2439        assert_eq!(state.forced_ingress_context_limit(), 75);
2440        assert_eq!(state.ingress_initial_context_limit(), 75);
2441        assert_eq!(state.ingress_context_limit(), 101);
2442        assert_eq!(state.active_context_limit(), 70);
2443        let ingress = Chatend::opened(SessionMetadata {
2444            kind: SessionKind::HistoryIngress,
2445            effective_context_tokens: 101,
2446            ..metadata()
2447        });
2448        assert_eq!(ingress.active_context_limit(), 101);
2449        assert_eq!(estimate_tokens("1234"), 2);
2450    }
2451
2452    #[test]
2453    fn new_box_projection_preview_matches_the_committed_projection() {
2454        let path = path("new-box-preview");
2455        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
2456        let boxes = vec![
2457            (
2458                "User message".into(),
2459                BoxOwner::User,
2460                BoxContent::text("prospective user text"),
2461            ),
2462            (
2463                "attachment".into(),
2464                BoxOwner::User,
2465                BoxContent::text("prospective attachment text"),
2466            ),
2467        ];
2468        let preview = journal.state().projection_with_new_boxes(&boxes).unwrap();
2469        for (name, owner, content) in boxes {
2470            journal.create_box("t1", name, owner, content).unwrap();
2471        }
2472        assert_eq!(journal.state().projection(), preview);
2473        std::fs::remove_file(path).unwrap();
2474    }
2475
2476    #[test]
2477    fn box_representation_preview_matches_the_batched_append() {
2478        let path = path("representation-plan");
2479        let mut journal = SessionJournal::create(&path, metadata()).unwrap();
2480        let hydrated = journal
2481            .create_box(
2482                "t1",
2483                "large result",
2484                BoxOwner::Controller,
2485                BoxContent::text("x".repeat(3_000)),
2486            )
2487            .unwrap();
2488        let summarized = journal
2489            .create_box(
2490                "t2",
2491                "Kennedy summary",
2492                BoxOwner::Kennedy,
2493                BoxContent::text("y".repeat(3_000)),
2494            )
2495            .unwrap();
2496        journal
2497            .summarize_box("t3", summarized, "important points")
2498            .unwrap();
2499        let desired = BTreeMap::from([
2500            (hydrated, BoxRepresentation::Dehydrated),
2501            (
2502                summarized,
2503                BoxRepresentation::Summarized("important points".into()),
2504            ),
2505        ]);
2506        let preview = journal
2507            .state()
2508            .projection_with_box_representations(&desired)
2509            .unwrap();
2510        let next_id = journal.state().next_id;
2511        let ids = journal.apply_box_representations("t4", &desired).unwrap();
2512        assert_eq!(ids, vec![EventId(next_id)]);
2513        assert_eq!(journal.state().projection(), preview);
2514        assert!(matches!(
2515            journal.state().box_state(hydrated).unwrap().representation,
2516            Representation::Dehydrated { .. }
2517        ));
2518        assert_eq!(
2519            journal
2520                .state()
2521                .box_state(summarized)
2522                .unwrap()
2523                .representation,
2524            Representation::Summarized {
2525                based_on: EventId(2),
2526                text: "important points".into(),
2527            }
2528        );
2529        std::fs::remove_file(path).unwrap();
2530    }
2531}