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