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