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