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.saturating_mul(90) / 100
785 }
786
787 pub fn active_context_limit(&self) -> u64 {
788 if matches!(
789 self.metadata.kind,
790 SessionKind::SelfTime | 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(
1127 *estimated_tokens,
1128 self.active_context_limit(),
1129 ),
1130 ));
1131 continue;
1132 }
1133 _ => {}
1134 }
1135 }
1136 let Some(box_id) = event_box_id(&event.kind) else {
1137 continue;
1138 };
1139 let Some(state) = self.boxes.get(&box_id) else {
1140 continue;
1141 };
1142 if next_occurrence.contains_key(&event.id) {
1143 items.push(ProjectionItem::marker(
1144 event.id,
1145 box_id,
1146 "[box updated]".into(),
1147 ));
1148 continue;
1149 }
1150 if !state.active || state.occurrence_events.last() != Some(&event.id) {
1151 continue;
1152 }
1153 let (representation, body) = if fully_hydrated {
1154 ("hydrated", state.canonical.content.render())
1155 } else {
1156 match &state.representation {
1157 Representation::Hydrated { .. } => {
1158 ("hydrated", state.canonical.content.render())
1159 }
1160 Representation::Dehydrated { .. } => (
1161 "dehydrated",
1162 format!(
1163 "[contents dehydrated; hydrate box {} to inspect the latest canonical revision]",
1164 box_id
1165 ),
1166 ),
1167 Representation::Summarized { text, .. } => ("summarized", text.clone()),
1168 }
1169 };
1170 let stale = !fully_hydrated && state.stale();
1171 let mut header = vec![format!("box {box_id}"), state.name.clone()];
1172 if matches!(
1173 (&state.owner, state.name.as_str()),
1174 (BoxOwner::User, "User message") | (BoxOwner::Kennedy, "Kennedy message")
1175 ) {
1176 header.push(format!("timestamp={}", event.recorded_at));
1177 }
1178 header.push(representation.into());
1179 if stale {
1180 header.push("stale".into());
1181 }
1182 let mut text = format!("[{}]\n{}", header.join(" | "), body);
1183 if text.ends_with('\n') {
1184 text.pop();
1185 }
1186 items.push(ProjectionItem {
1187 event_id: event.id,
1188 box_id,
1189 marker: false,
1190 stale,
1191 approximate_tokens: estimate_tokens(&text),
1192 text,
1193 });
1194 }
1195 for box_ids in self.tool_layouts.values() {
1196 arrange_tool_projection(&mut items, box_ids);
1197 }
1198 items
1199 }
1200
1201 fn fully_hydrated_context_tokens_at(&self, footer_lines: &[String]) -> u64 {
1202 let items = self.projection_items(true);
1203 let footer = footer_lines.join("\n");
1204 let context_bytes = projected_context_bytes(&items, &footer);
1205 self.calibrated_estimate(context_bytes, estimate_bytes(context_bytes))
1206 }
1207
1208 fn calibrated_estimate(&self, current_bytes: u64, raw_current: u64) -> u64 {
1209 let Some((manifest_hash, measured, bytes_at_receipt, raw_at_receipt)) =
1210 self.events.iter().rev().find_map(|event| {
1211 let EventKind::ProviderReceipt {
1212 manifest_hash,
1213 input_tokens: Some(input_tokens),
1214 context_bytes,
1215 raw_context_tokens,
1216 ..
1217 } = &event.kind
1218 else {
1219 return None;
1220 };
1221 Some((
1222 manifest_hash,
1223 *input_tokens,
1224 *context_bytes,
1225 *raw_context_tokens,
1226 ))
1227 })
1228 else {
1229 return raw_current;
1230 };
1231 if let Some(bytes_at_receipt) = bytes_at_receipt {
1232 let token_delta = current_bytes.abs_diff(bytes_at_receipt) / ESTIMATED_BYTES_PER_TOKEN;
1233 return if current_bytes >= bytes_at_receipt {
1234 measured.saturating_add(token_delta)
1235 } else {
1236 measured.saturating_sub(token_delta)
1237 };
1238 }
1239 let raw_at_measurement = match raw_at_receipt {
1240 Some(raw) => raw,
1241 None => {
1242 let Some(raw) = self.events.iter().rev().find_map(|event| {
1243 let EventKind::InferenceSubmitted {
1244 manifest_hash: submitted,
1245 estimated_input_tokens,
1246 raw_estimated_input_tokens,
1247 } = &event.kind
1248 else {
1249 return None;
1250 };
1251 (submitted == manifest_hash)
1252 .then_some(raw_estimated_input_tokens.unwrap_or(*estimated_input_tokens))
1253 }) else {
1254 return raw_current;
1255 };
1256 raw
1257 }
1258 };
1259 if raw_current >= raw_at_measurement {
1260 measured.saturating_add(raw_current - raw_at_measurement)
1261 } else {
1262 measured.saturating_sub(raw_at_measurement - raw_current)
1263 }
1264 }
1265
1266 fn cumulative_token_usage(&self) -> CumulativeTokenUsage {
1267 let mut total = CumulativeTokenUsage::default();
1268 for event in &self.events {
1269 let EventKind::ProviderReceipt { provider_data, .. } = &event.kind else {
1270 continue;
1271 };
1272 let cached = provider_u64(provider_data, &["cachedInputTokens", "cached_input_tokens"]);
1273 let thinking = provider_u64(
1274 provider_data,
1275 &[
1276 "thinkingTokens",
1277 "thinking_tokens",
1278 "reasoningOutputTokens",
1279 "reasoning_output_tokens",
1280 ],
1281 );
1282 let normalized_delta = provider_data
1283 .get("usageIsDelta")
1284 .and_then(Value::as_bool)
1285 .unwrap_or(false);
1286 let non_cached = if normalized_delta {
1287 provider_u64(
1288 provider_data,
1289 &["nonCachedInputTokens", "non_cached_input_tokens"],
1290 )
1291 } else {
1292 provider_u64(provider_data, &["inputTokens", "input_tokens"]).saturating_sub(cached)
1293 };
1294 let output = if normalized_delta {
1295 provider_u64(provider_data, &["outputTokens", "output_tokens"])
1296 } else {
1297 provider_u64(provider_data, &["outputTokens", "output_tokens"])
1298 .saturating_sub(thinking)
1299 };
1300 total.cached_input_tokens = total.cached_input_tokens.saturating_add(cached);
1301 total.non_cached_input_tokens =
1302 total.non_cached_input_tokens.saturating_add(non_cached);
1303 total.thinking_tokens = total.thinking_tokens.saturating_add(thinking);
1304 total.output_tokens = total.output_tokens.saturating_add(output);
1305 }
1306 total
1307 }
1308
1309 fn cumulative_cost(&self) -> CumulativeCost {
1310 let mut total = CumulativeCost::default();
1311 for event in &self.events {
1312 let EventKind::ProviderReceipt { provider_data, .. } = &event.kind else {
1313 continue;
1314 };
1315 if let Some(cost) = provider_data
1316 .get("estimatedCostUsdNanos")
1317 .and_then(Value::as_u64)
1318 {
1319 total.estimated_cost_usd_nanos =
1320 total.estimated_cost_usd_nanos.saturating_add(cost);
1321 } else {
1322 total.unpriced_provider_calls = total.unpriced_provider_calls.saturating_add(1);
1323 }
1324 }
1325 total
1326 }
1327
1328 pub fn render(&self) -> String {
1329 self.projection().render()
1330 }
1331
1332 fn apply_transition(&mut self, transition: &Transition) -> anyhow::Result<()> {
1333 ensure!(
1334 !transition.events.is_empty(),
1335 "a transition cannot be empty"
1336 );
1337 for event in &transition.events {
1338 self.apply_event(event)?;
1339 }
1340 Ok(())
1341 }
1342
1343 fn apply_event(&mut self, event: &Event) -> anyhow::Result<()> {
1344 ensure!(
1345 event.id.0 >= self.next_id,
1346 "event {} reuses an allocated identity (next is {})",
1347 event.id,
1348 self.next_id
1349 );
1350 self.next_id = event.id.0.checked_add(1).context("event ID overflow")?;
1351 match &event.kind {
1352 EventKind::SessionConfigured {
1353 effective_context_tokens,
1354 kind,
1355 } => {
1356 ensure!(
1357 *effective_context_tokens > 0,
1358 "effective context window must be positive"
1359 );
1360 self.metadata.effective_context_tokens = *effective_context_tokens;
1361 self.metadata.kind = kind.clone();
1362 }
1363 EventKind::BoxCreated {
1364 box_id,
1365 name,
1366 owner,
1367 content,
1368 } => {
1369 ensure!(
1370 box_id.0 == event.id.0,
1371 "BoxId must equal its creation EventId"
1372 );
1373 ensure!(
1374 !self.boxes.contains_key(box_id),
1375 "box {} already exists",
1376 box_id
1377 );
1378 self.boxes.insert(
1379 *box_id,
1380 BoxState {
1381 id: *box_id,
1382 name: name.clone(),
1383 owner: owner.clone(),
1384 created_at: event.id,
1385 canonical: CanonicalRevision {
1386 event_id: event.id,
1387 content: content.clone(),
1388 },
1389 representation: Representation::Hydrated {
1390 canonical_event: event.id,
1391 },
1392 occurrence_events: vec![event.id],
1393 active: true,
1394 },
1395 );
1396 if let BoxOwner::Tool {
1397 tool_instance,
1398 slot,
1399 } = owner
1400 {
1401 self.tools
1402 .entry(tool_instance.clone())
1403 .or_default()
1404 .slots
1405 .push(ToolSlot {
1406 slot: slot.clone(),
1407 box_id: *box_id,
1408 retired: false,
1409 });
1410 }
1411 }
1412 EventKind::CanonicalUpdated { box_id, content } => {
1413 let state = active_box_mut(&mut self.boxes, *box_id)?;
1414 state.canonical = CanonicalRevision {
1415 event_id: event.id,
1416 content: content.clone(),
1417 };
1418 if matches!(state.representation, Representation::Hydrated { .. }) {
1419 state.representation = Representation::Hydrated {
1420 canonical_event: event.id,
1421 };
1422 }
1423 state.occurrence_events.push(event.id);
1424 }
1425 EventKind::BoxRenamed { box_id, name } => {
1426 ensure!(!name.trim().is_empty(), "a box name cannot be empty");
1427 let state = active_box_mut(&mut self.boxes, *box_id)?;
1428 state.name = name.clone();
1429 state.occurrence_events.push(event.id);
1430 }
1431 EventKind::BoxDehydrated { box_id } => {
1432 let state = active_box_mut(&mut self.boxes, *box_id)?;
1433 state.representation = Representation::Dehydrated {
1434 based_on: state.canonical.event_id,
1435 };
1436 state.occurrence_events.push(event.id);
1437 }
1438 EventKind::BoxSummarized { box_id, text } => {
1439 ensure!(!text.trim().is_empty(), "a box summary cannot be empty");
1440 let state = active_box_mut(&mut self.boxes, *box_id)?;
1441 state.representation = Representation::Summarized {
1442 based_on: state.canonical.event_id,
1443 text: text.clone(),
1444 };
1445 state.occurrence_events.push(event.id);
1446 }
1447 EventKind::BoxRehydrated { box_id } => {
1448 let state = active_box_mut(&mut self.boxes, *box_id)?;
1449 state.representation = Representation::Hydrated {
1450 canonical_event: state.canonical.event_id,
1451 };
1452 state.occurrence_events.push(event.id);
1453 }
1454 EventKind::BoxRetired { box_id } => {
1455 let tool_instance = self.boxes.get(box_id).and_then(|state| {
1456 let BoxOwner::Tool { tool_instance, .. } = &state.owner else {
1457 return None;
1458 };
1459 Some(tool_instance.clone())
1460 });
1461 let state = active_box_mut(&mut self.boxes, *box_id)?;
1462 state.active = false;
1463 state.occurrence_events.push(event.id);
1464 if let Some(tool_instance) = tool_instance {
1465 let slot = self
1466 .tools
1467 .get_mut(&tool_instance)
1468 .and_then(|tool| tool.slots.iter_mut().find(|slot| slot.box_id == *box_id))
1469 .with_context(|| {
1470 format!("tool box {box_id} is missing from {tool_instance}")
1471 })?;
1472 slot.retired = true;
1473 }
1474 }
1475 EventKind::ToolLayoutChanged {
1476 tool_instance,
1477 box_ids,
1478 } => {
1479 let mut unique = std::collections::HashSet::new();
1480 for box_id in box_ids {
1481 ensure!(
1482 unique.insert(*box_id),
1483 "tool layout contains duplicate box {box_id}"
1484 );
1485 let state = self
1486 .boxes
1487 .get(box_id)
1488 .with_context(|| format!("tool layout references missing box {box_id}"))?;
1489 ensure!(state.active, "tool layout references retired box {box_id}");
1490 ensure!(
1491 matches!(
1492 &state.owner,
1493 BoxOwner::Tool {
1494 tool_instance: owner,
1495 ..
1496 } if owner == tool_instance
1497 ),
1498 "tool layout box {box_id} belongs to another tool"
1499 );
1500 }
1501 self.tool_layouts
1502 .insert(tool_instance.clone(), box_ids.clone());
1503 }
1504 EventKind::PendingAllocated {
1505 pending_id,
1506 resource,
1507 } => {
1508 ensure!(
1509 pending_id.number() == event.id.0,
1510 "pending identity must equal its allocation EventId"
1511 );
1512 ensure!(
1513 self.pending
1514 .insert(pending_id.clone(), resource.clone())
1515 .is_none(),
1516 "pending identity {} already exists",
1517 pending_id
1518 );
1519 }
1520 EventKind::SourceTerminated { .. } => self.source_terminated = true,
1521 EventKind::HistoryIngressStarted => {
1522 ensure!(
1523 self.source_terminated,
1524 "history ingress requires source termination"
1525 );
1526 self.history_ingress_started = true;
1527 }
1528 EventKind::Note { label, .. } if label == HISTORY_INGRESS_ATTEMPT_RESET_NOTE => {
1529 self.restore_history_ingress_baseline()?;
1530 }
1531 EventKind::SessionCompleted { session_object_id } => {
1532 self.completed_session_object = Some(session_object_id.clone());
1533 }
1534 EventKind::ToolInvoked { .. }
1535 | EventKind::ToolCompleted { .. }
1536 | EventKind::InferenceSubmitted { .. }
1537 | EventKind::ProviderInputSubmitted { .. }
1538 | EventKind::ProjectionMarkersReset
1539 | EventKind::StaleBoxesMarked { .. }
1540 | EventKind::ContextSizeMarked { .. }
1541 | EventKind::ProviderReceipt { .. }
1542 | EventKind::CapacityError { .. }
1543 | EventKind::HistoryEventInspected { .. }
1544 | EventKind::HistoryEventReleased { .. }
1545 | EventKind::KwebPlanChanged { .. }
1546 | EventKind::KwebCommitted { .. }
1547 | EventKind::Note { .. } => {}
1548 }
1549 self.events.push(event.clone());
1550 Ok(())
1551 }
1552}
1553
1554fn active_box_mut(
1555 boxes: &mut BTreeMap<BoxId, BoxState>,
1556 box_id: BoxId,
1557) -> anyhow::Result<&mut BoxState> {
1558 let state = boxes
1559 .get_mut(&box_id)
1560 .with_context(|| format!("box {box_id} does not exist"))?;
1561 ensure!(state.active, "box {box_id} is retired");
1562 Ok(state)
1563}
1564
1565fn arrange_tool_projection(items: &mut Vec<ProjectionItem>, box_ids: &[BoxId]) {
1566 if box_ids.is_empty() {
1567 return;
1568 }
1569 let ranks = box_ids
1570 .iter()
1571 .enumerate()
1572 .map(|(rank, box_id)| (*box_id, rank))
1573 .collect::<HashMap<_, _>>();
1574 let insertion = items
1575 .iter()
1576 .position(|item| !item.marker && ranks.contains_key(&item.box_id));
1577 let Some(insertion) = insertion else {
1578 return;
1579 };
1580 let mut arranged = Vec::with_capacity(box_ids.len());
1581 let mut retained = Vec::with_capacity(items.len());
1582 for item in std::mem::take(items) {
1583 if !item.marker && ranks.contains_key(&item.box_id) {
1584 arranged.push(item);
1585 } else {
1586 retained.push(item);
1587 }
1588 }
1589 arranged.sort_by_key(|item| ranks[&item.box_id]);
1590 let insertion = insertion.min(retained.len());
1591 retained.splice(insertion..insertion, arranged);
1592 *items = retained;
1593}
1594
1595fn event_box_id(kind: &EventKind) -> Option<BoxId> {
1596 match kind {
1597 EventKind::BoxCreated { box_id, .. }
1598 | EventKind::CanonicalUpdated { box_id, .. }
1599 | EventKind::BoxRenamed { box_id, .. }
1600 | EventKind::BoxDehydrated { box_id }
1601 | EventKind::BoxSummarized { box_id, .. }
1602 | EventKind::BoxRehydrated { box_id }
1603 | EventKind::BoxRetired { box_id } => Some(*box_id),
1604 _ => None,
1605 }
1606}
1607
1608fn ingress_time_marker_text(value: &Value) -> Option<String> {
1609 let remaining_seconds = value.get("remainingSeconds")?.as_u64()?;
1610 let previous_attempt_timed_out = value
1611 .get("previousAttemptTimedOut")
1612 .and_then(Value::as_bool)
1613 .unwrap_or(false);
1614 let previous = if previous_attempt_timed_out {
1615 "Previous attempt ran out of time; please budget your ingress time carefully. "
1616 } else {
1617 ""
1618 };
1619 let label = if previous_attempt_timed_out {
1620 "Ingress"
1621 } else {
1622 "ingress"
1623 };
1624 let warning = if remaining_seconds <= 10 * 60 {
1625 ". If you do not call EndSession before time expires, the ingress will fail and all Kmap updates will be lost."
1626 } else {
1627 ""
1628 };
1629 Some(format!(
1630 "[{previous}{label} time remaining: {remaining_seconds} seconds{warning}]"
1631 ))
1632}
1633
1634fn context_size_marker_text(estimated_tokens: u64, context_limit: u64) -> String {
1635 format!(
1636 "[current context size: approximately {estimated_tokens} tokens | session limit: {context_limit} tokens]"
1637 )
1638}
1639
1640#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1641#[serde(rename_all = "camelCase")]
1642pub struct ProjectionItem {
1643 pub event_id: EventId,
1644 pub box_id: BoxId,
1645 pub marker: bool,
1646 pub stale: bool,
1647 pub approximate_tokens: u64,
1648 pub text: String,
1649}
1650
1651impl ProjectionItem {
1652 fn marker(event_id: EventId, box_id: BoxId, text: String) -> Self {
1653 Self {
1654 event_id,
1655 box_id,
1656 marker: true,
1657 stale: false,
1658 approximate_tokens: estimate_tokens(&text),
1659 text,
1660 }
1661 }
1662
1663 fn projection_marker(event_id: EventId, text: String) -> Self {
1664 Self::marker(event_id, BoxId(0), text)
1665 }
1666}
1667
1668#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1669#[serde(rename_all = "camelCase")]
1670pub struct ContextProjection {
1671 pub items: Vec<ProjectionItem>,
1672 pub stale_boxes: Vec<BoxId>,
1673 pub footer: String,
1674 pub estimated_tokens: u64,
1675 pub raw_estimated_tokens: u64,
1676 pub context_bytes: u64,
1677 pub status: SessionStatus,
1678}
1679
1680impl ContextProjection {
1681 pub fn render(&self) -> String {
1682 let mut blocks = self
1683 .items
1684 .iter()
1685 .map(|item| item.text.as_str())
1686 .collect::<Vec<_>>();
1687 if !self.footer.is_empty() {
1688 blocks.push(&self.footer);
1689 }
1690 blocks.join("\n\n")
1691 }
1692}
1693
1694#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
1695#[serde(rename_all = "camelCase")]
1696pub struct SessionStatus {
1697 pub current_context_tokens: u64,
1698 #[serde(default)]
1699 pub fully_hydrated_context_tokens: u64,
1700 pub context_limit_tokens: u64,
1701 pub current_context_bytes: u64,
1702 pub cached_input_tokens: u64,
1703 pub non_cached_input_tokens: u64,
1704 pub thinking_tokens: u64,
1705 pub output_tokens: u64,
1706 #[serde(default)]
1707 pub estimated_cost_usd_nanos: u64,
1708 #[serde(default)]
1709 pub unpriced_provider_calls: u64,
1710}
1711
1712#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1713struct CumulativeTokenUsage {
1714 cached_input_tokens: u64,
1715 non_cached_input_tokens: u64,
1716 thinking_tokens: u64,
1717 output_tokens: u64,
1718}
1719
1720#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1721struct CumulativeCost {
1722 estimated_cost_usd_nanos: u64,
1723 unpriced_provider_calls: u64,
1724}
1725
1726pub fn estimate_tokens(text: &str) -> u64 {
1727 estimate_bytes(text.len() as u64)
1728}
1729
1730fn estimate_bytes(bytes: u64) -> u64 {
1731 bytes.div_ceil(ESTIMATED_BYTES_PER_TOKEN)
1732}
1733
1734fn projected_context_bytes(items: &[ProjectionItem], footer: &str) -> u64 {
1735 let blocks = items.len() as u64 + u64::from(!footer.is_empty());
1736 items
1737 .iter()
1738 .fold(footer.len() as u64, |total, item| {
1739 total.saturating_add(item.text.len() as u64)
1740 })
1741 .saturating_add(blocks.saturating_sub(1).saturating_mul(2))
1742}
1743
1744fn provider_u64(value: &Value, keys: &[&str]) -> u64 {
1745 keys.iter()
1746 .find_map(|key| value.get(*key).and_then(Value::as_u64))
1747 .unwrap_or_default()
1748}
1749
1750struct ActiveToolModel {
1751 invocation_id: Option<String>,
1752 tool_instance: String,
1753 tool_name: String,
1754 model: Option<String>,
1755}
1756
1757fn normalize_requested_model(model: &str) -> String {
1758 model.strip_prefix("codex/").unwrap_or(model).to_owned()
1759}
1760
1761fn provider_metering(provider_data: &Value) -> ProviderMetering {
1762 let metering = provider_data.get("metering");
1763 if metering
1764 .and_then(Value::as_str)
1765 .is_some_and(|kind| kind == "unavailable")
1766 {
1767 return ProviderMetering::Unavailable;
1768 }
1769 if let Some(metering_value) = metering
1770 && let Some(metering) = metering_value.as_object()
1771 {
1772 match metering.get("kind").and_then(Value::as_str) {
1773 Some("duration_seconds") => {
1774 return metering
1775 .get("seconds")
1776 .and_then(Value::as_f64)
1777 .map(|seconds| ProviderMetering::DurationSeconds { seconds })
1778 .unwrap_or(ProviderMetering::Unavailable);
1779 }
1780 Some("unavailable") => return ProviderMetering::Unavailable,
1781 Some("tokens") => {
1782 return ProviderMetering::Tokens(token_metering(metering_value));
1783 }
1784 _ => {}
1785 }
1786 }
1787 let has_tokens = metering.and_then(Value::as_str) == Some("tokens")
1788 || [
1789 "inputTokens",
1790 "input_tokens",
1791 "nonCachedInputTokens",
1792 "non_cached_input_tokens",
1793 "cachedInputTokens",
1794 "cached_input_tokens",
1795 "thinkingTokens",
1796 "thinking_tokens",
1797 "outputTokens",
1798 "output_tokens",
1799 ]
1800 .iter()
1801 .any(|key| provider_data.get(*key).and_then(Value::as_u64).is_some());
1802 if has_tokens {
1803 ProviderMetering::Tokens(token_metering(provider_data))
1804 } else {
1805 ProviderMetering::Unavailable
1806 }
1807}
1808
1809fn token_metering(value: &Value) -> ProviderTokenUsage {
1810 let cached_input_tokens = provider_u64(value, &["cachedInputTokens", "cached_input_tokens"]);
1811 let thinking_tokens = provider_u64(
1812 value,
1813 &[
1814 "thinkingTokens",
1815 "thinking_tokens",
1816 "reasoningOutputTokens",
1817 "reasoning_output_tokens",
1818 ],
1819 );
1820 let normalized_delta = value
1821 .get("usageIsDelta")
1822 .or_else(|| value.get("usage_is_delta"))
1823 .and_then(Value::as_bool)
1824 .unwrap_or(false);
1825 let input_tokens = if normalized_delta {
1826 provider_u64(value, &["nonCachedInputTokens", "non_cached_input_tokens"])
1827 } else {
1828 provider_u64(value, &["inputTokens", "input_tokens"]).saturating_sub(cached_input_tokens)
1829 };
1830 let output_tokens = if normalized_delta {
1831 provider_u64(value, &["outputTokens", "output_tokens"])
1832 } else {
1833 provider_u64(value, &["outputTokens", "output_tokens"]).saturating_sub(thinking_tokens)
1834 };
1835 ProviderTokenUsage {
1836 input_tokens,
1837 cached_input_tokens,
1838 thinking_tokens,
1839 output_tokens,
1840 }
1841}
1842
1843fn context_event_role(kind: &EventKind) -> Role {
1844 match kind {
1845 EventKind::BoxCreated { owner, content, .. } => match owner {
1846 BoxOwner::System | BoxOwner::Controller => {
1847 if content
1848 .metadata
1849 .get("capacityError")
1850 .and_then(Value::as_bool)
1851 .unwrap_or(false)
1852 {
1853 Role::SystemError
1854 } else {
1855 Role::SystemMessage
1856 }
1857 }
1858 BoxOwner::User => Role::UserMessage,
1859 BoxOwner::Kennedy => Role::KennedyMessage,
1860 BoxOwner::Tool { .. } => Role::ToolResult,
1861 },
1862 EventKind::ToolInvoked { .. } => Role::KennedyToolCall,
1863 EventKind::ToolCompleted { outcome, .. } => {
1864 if outcome.get("ok").and_then(Value::as_bool).unwrap_or(true) {
1865 Role::ToolResult
1866 } else {
1867 Role::ToolError
1868 }
1869 }
1870 EventKind::CapacityError { .. } => Role::SystemError,
1871 EventKind::PendingAllocated {
1872 resource: PendingKind::Object,
1873 ..
1874 } => Role::PendingObject,
1875 EventKind::BoxDehydrated { .. }
1876 | EventKind::BoxSummarized { .. }
1877 | EventKind::BoxRehydrated { .. }
1878 | EventKind::BoxRetired { .. } => Role::KennedyToolCall,
1879 _ => Role::SystemMessage,
1880 }
1881}
1882
1883fn encode_context_event(recorded_at: &str, kind: &EventKind) -> anyhow::Result<String> {
1884 let mut kind = serde_json::to_value(kind)?;
1885 let kind_object = kind
1886 .as_object_mut()
1887 .context("Kennedy context event kind must encode as an object")?;
1888 match kind_object.get("type").and_then(Value::as_str) {
1889 Some("box_created") => {
1890 kind_object.remove("box_id");
1891 }
1892 Some("pending_allocated") => {
1893 kind_object.remove("pending_id");
1894 }
1895 _ => {}
1896 }
1897 Ok(serde_json::to_string(&PersistedContextEventWire {
1898 context_event_version: FORMAT_VERSION,
1899 recorded_at: recorded_at.into(),
1900 kind,
1901 })?)
1902}
1903
1904fn decode_context_event(
1905 event: &kcode_session_log::SessionEvent,
1906) -> anyhow::Result<PersistedContextEvent> {
1907 let wire: PersistedContextEventWire = match serde_json::from_str(&event.text) {
1908 Ok(persisted) => persisted,
1909 Err(_) if event.role == Role::PendingObject => {
1910 return Ok(PersistedContextEvent {
1911 context_event_version: FORMAT_VERSION,
1912 recorded_at: String::new(),
1913 kind: EventKind::PendingAllocated {
1914 pending_id: PendingId::from_event(EventId(1)),
1915 resource: PendingKind::Object,
1916 },
1917 });
1918 }
1919 Err(error) => {
1920 return Err(error).context("decoding Kennedy context event from session log");
1921 }
1922 };
1923 ensure!(
1924 wire.context_event_version == FORMAT_VERSION,
1925 "unsupported Kennedy context event version {}",
1926 wire.context_event_version
1927 );
1928 let mut kind = wire.kind;
1929 let kind_object = kind
1930 .as_object_mut()
1931 .context("Kennedy context event kind must be an object")?;
1932 match kind_object.get("type").and_then(Value::as_str) {
1933 Some("box_created") if !kind_object.contains_key("box_id") => {
1934 kind_object.insert("box_id".into(), Value::from(0));
1935 }
1936 Some("pending_allocated") if !kind_object.contains_key("pending_id") => {
1937 kind_object.insert("pending_id".into(), Value::String("pending:1".into()));
1938 }
1939 _ => {}
1940 }
1941 Ok(PersistedContextEvent {
1942 context_event_version: wire.context_event_version,
1943 recorded_at: wire.recorded_at,
1944 kind: serde_json::from_value(kind).context("decoding Kennedy context event kind")?,
1945 })
1946}
1947
1948fn normalize_derived_identity(kind: &mut EventKind, id: EventId) -> anyhow::Result<()> {
1949 match kind {
1950 EventKind::BoxCreated { box_id, .. } => *box_id = BoxId(id.0),
1951 EventKind::PendingAllocated { pending_id, .. } => {
1952 *pending_id = PendingId::from_event(id);
1953 }
1954 _ => {}
1955 }
1956 Ok(())
1957}
1958
1959pub struct Session {
1960 durable: DurableSession,
1961 chatend: Chatend,
1962 objects: BTreeMap<PendingId, ObjectLocation>,
1963}
1964
1965struct UnfinishedToolInvocation {
1966 invocation_id: Option<String>,
1967 tool_instance: String,
1968 tool_name: String,
1969}
1970
1971impl Session {
1972 pub(crate) fn create(
1973 path: impl AsRef<Path>,
1974 metadata: SessionMetadata,
1975 ) -> anyhow::Result<Self> {
1976 ensure!(
1977 metadata.effective_context_tokens > 0,
1978 "effective context window must be positive"
1979 );
1980 let requested = path.as_ref();
1981 let directory = requested
1982 .parent()
1983 .filter(|path| !path.as_os_str().is_empty())
1984 .unwrap_or_else(|| Path::new("."));
1985 let durable = DurableSessionStore::new(directory)
1986 .create_session(&metadata.session_id, &metadata.created_at)?;
1987 Ok(Self {
1988 durable,
1989 chatend: Chatend::opened(metadata),
1990 objects: BTreeMap::new(),
1991 })
1992 }
1993
1994 pub(crate) fn open_with_metadata(
1995 path: impl AsRef<Path>,
1996 metadata: SessionMetadata,
1997 ) -> anyhow::Result<Self> {
1998 Self::open_with_metadata_and_provider_costs(path, metadata, None, None)
1999 }
2000
2001 pub(crate) fn open_with_metadata_and_provider_costs(
2002 path: impl AsRef<Path>,
2003 metadata: SessionMetadata,
2004 default_provider_model: Option<&str>,
2005 estimator: Option<ProviderCostEstimator>,
2006 ) -> anyhow::Result<Self> {
2007 let requested = path.as_ref();
2008 ensure!(
2009 requested.extension().and_then(|value| value.to_str()) == Some("session-log"),
2010 "{} is not a session-log path",
2011 requested.display()
2012 );
2013 let directory = requested
2014 .parent()
2015 .filter(|path| !path.as_os_str().is_empty())
2016 .unwrap_or_else(|| Path::new("."));
2017 let session_id = requested
2018 .file_stem()
2019 .and_then(|value| value.to_str())
2020 .context("session-log filename is not valid UTF-8")?;
2021 let durable = DurableSessionStore::new(directory).open_session(session_id)?;
2022 let log = durable.list();
2023 let chatend =
2024 Chatend::replay_with_provider_costs(metadata, &log, default_provider_model, estimator)?;
2025 let mut objects = BTreeMap::new();
2026 for (position, stored) in log.events.iter().enumerate() {
2027 let persisted = decode_context_event(stored)?;
2028 let id = EventId(position as u64 + 1);
2029 let mut kind = persisted.kind;
2030 normalize_derived_identity(&mut kind, id)?;
2031 if let EventKind::PendingAllocated {
2032 pending_id,
2033 resource: PendingKind::Object,
2034 } = kind
2035 {
2036 let object = durable.read_pending_object(EventPosition(position as u64))?;
2037 let metadata = ObjectMetadata {
2038 pending_id: pending_id.clone(),
2039 event_id: id,
2040 recorded_at: chatend
2041 .event(id)
2042 .map(|event| event.recorded_at.clone())
2043 .unwrap_or_default(),
2044 media_type: object.media_type,
2045 file_name: Some(object.file_name),
2046 transport: Value::Null,
2047 };
2048 objects.insert(
2049 pending_id,
2050 ObjectLocation {
2051 metadata,
2052 payload_offset: position as u64,
2053 payload_len: object.bytes.len() as u64,
2054 },
2055 );
2056 }
2057 }
2058 Ok(Self {
2059 durable,
2060 chatend,
2061 objects,
2062 })
2063 }
2064
2065 pub fn id(&self) -> &str {
2066 &self.chatend.metadata.session_id
2067 }
2068
2069 pub fn state(&self) -> &Chatend {
2070 &self.chatend
2071 }
2072
2073 pub fn objects(&self) -> &BTreeMap<PendingId, ObjectLocation> {
2074 &self.objects
2075 }
2076
2077 #[cfg(test)]
2078 fn session_log(&self) -> kcode_session_log::SessionLog {
2079 self.durable.list()
2080 }
2081
2082 pub fn archive_bytes(&self) -> anyhow::Result<Vec<u8>> {
2083 let mut archive =
2084 serde_json::to_value(self.durable.list()).context("serializing the session log")?;
2085 let object = archive
2086 .as_object_mut()
2087 .context("serialized session log is not an object")?;
2088 object.insert(
2089 "metadata".into(),
2090 serde_json::to_value(&self.chatend.metadata)?,
2091 );
2092 object.insert("boxes".into(), serde_json::to_value(&self.chatend.boxes)?);
2093 let projection = self.chatend.projection();
2094 let submitted = self
2095 .chatend
2096 .current_ingress_attempt_events()
2097 .iter()
2098 .rev()
2099 .find_map(|event| {
2100 let EventKind::ProviderInputSubmitted { round, context, .. } = &event.kind else {
2101 return None;
2102 };
2103 Some((event.recorded_at.as_str(), *round, context))
2104 });
2105 if let Some((submitted_at, round, submitted)) = submitted {
2106 object.insert("chatendText".into(), Value::String(submitted.input.clone()));
2107 object.insert(
2108 "chatendTextSource".into(),
2109 Value::String("submitted".into()),
2110 );
2111 object.insert(
2112 "structuredMaterial".into(),
2113 json!({
2114 "provider":submitted.provider,
2115 "model":submitted.model,
2116 "reasoningEffort":submitted.reasoning_effort,
2117 "baseInstructions":submitted.base_instructions,
2118 "developerInstructions":submitted.developer_instructions,
2119 "tools":submitted.tools,
2120 "round":round,
2121 "submittedAt":submitted_at,
2122 }),
2123 );
2124 } else {
2125 object.insert("chatendText".into(), Value::String(projection.render()));
2126 object.insert(
2127 "chatendTextSource".into(),
2128 Value::String("reconstructed".into()),
2129 );
2130 object.insert("structuredMaterial".into(), Value::Null);
2131 }
2132 object.insert("context".into(), serde_json::to_value(projection)?);
2133 serde_json::to_vec(&archive).context("serializing the session archive")
2134 }
2135
2136 pub fn is_sealed(&self) -> bool {
2137 self.durable.is_sealed()
2138 }
2139
2140 pub fn seal(&mut self) -> anyhow::Result<()> {
2141 let unfinished_tools = self.unfinished_tool_invocations()?;
2142 ensure!(
2143 unfinished_tools.is_empty(),
2144 "session ends with unfinished tools {}",
2145 unfinished_tools
2146 .iter()
2147 .map(|tool| tool.tool_name.as_str())
2148 .collect::<Vec<_>>()
2149 .join(", ")
2150 );
2151 self.durable.seal()?;
2152 Ok(())
2153 }
2154
2155 pub fn repair_unfinished_tools(
2156 &mut self,
2157 recorded_at: impl Into<String>,
2158 ) -> anyhow::Result<Vec<EventId>> {
2159 let unfinished = self.unfinished_tool_invocations()?;
2160 if unfinished.is_empty() {
2161 return Ok(Vec::new());
2162 }
2163 let recorded_at = recorded_at.into();
2164 let mut repaired = Vec::with_capacity(unfinished.len());
2165 for tool in unfinished.iter().rev() {
2166 let message = format!(
2167 "{} was interrupted before a durable completion was recorded; the abandoned invocation was closed during session recovery.",
2168 tool.tool_name
2169 );
2170 let kind = if let Some(invocation_id) = &tool.invocation_id {
2171 EventKind::ToolCompleted {
2172 tool_instance: tool.tool_instance.clone(),
2173 tool_name: tool.tool_name.clone(),
2174 outcome: serde_json::json!({"ok":false,"recovered":true,"result":message}),
2175 invocation_id: Some(invocation_id.clone()),
2176 }
2177 } else {
2178 EventKind::ToolCompleted {
2181 tool_instance: "call_ktool".into(),
2182 tool_name: "call_ktool".into(),
2183 outcome: serde_json::json!({"ok":false,"recovered":true,"result":message}),
2184 invocation_id: None,
2185 }
2186 };
2187 repaired.push(self.record(recorded_at.clone(), kind)?);
2188 }
2189 ensure!(
2190 self.unfinished_tool_invocations()?.is_empty(),
2191 "session tool recovery left unfinished invocations"
2192 );
2193 Ok(repaired)
2194 }
2195
2196 fn unfinished_tool_invocations(&self) -> anyhow::Result<Vec<UnfinishedToolInvocation>> {
2197 let mut identified = BTreeMap::<String, UnfinishedToolInvocation>::new();
2198 let mut legacy = Vec::<UnfinishedToolInvocation>::new();
2199 for event in self.chatend.current_ingress_attempt_events() {
2200 match &event.kind {
2201 EventKind::ToolInvoked {
2202 tool_instance,
2203 tool_name,
2204 invocation_id,
2205 ..
2206 } => {
2207 let pending = UnfinishedToolInvocation {
2208 invocation_id: invocation_id.clone(),
2209 tool_instance: tool_instance.clone(),
2210 tool_name: tool_name.clone(),
2211 };
2212 if let Some(invocation_id) = invocation_id {
2213 ensure!(
2214 identified.insert(invocation_id.clone(), pending).is_none(),
2215 "duplicate tool invocation identity {invocation_id}"
2216 );
2217 } else {
2218 legacy.push(pending);
2219 }
2220 }
2221 EventKind::ToolCompleted {
2222 tool_instance,
2223 tool_name,
2224 invocation_id,
2225 ..
2226 } => {
2227 if let Some(invocation_id) = invocation_id {
2228 let invoked = identified.remove(invocation_id).with_context(|| {
2229 format!(
2230 "tool {tool_name} completed without matching invocation {invocation_id}"
2231 )
2232 })?;
2233 ensure!(
2234 invoked.tool_instance.as_str() == tool_instance
2235 && invoked.tool_name.as_str() == tool_name,
2236 "tool completion {invocation_id} does not match its invocation"
2237 );
2238 } else if tool_name == "call_ktool" {
2239 legacy.pop();
2242 } else {
2243 let before = legacy.len();
2244 legacy.retain(|unfinished| unfinished.tool_name.as_str() != tool_name);
2245 ensure!(
2246 legacy.len() != before,
2247 "tool {tool_name} completed without a matching invocation"
2248 );
2249 }
2250 }
2251 _ => {}
2252 }
2253 }
2254 legacy.extend(identified.into_values());
2255 Ok(legacy)
2256 }
2257
2258 pub fn mark_completed(&mut self, session_object_id: String) {
2259 self.chatend.completed_session_object = Some(session_object_id);
2260 }
2261
2262 pub fn configure_context(&mut self, kind: SessionKind, effective_context_tokens: u64) {
2263 self.chatend.metadata.kind = kind;
2264 self.chatend.metadata.effective_context_tokens = effective_context_tokens;
2265 }
2266
2267 pub fn create_box(
2268 &mut self,
2269 recorded_at: impl Into<String>,
2270 name: impl Into<String>,
2271 owner: BoxOwner,
2272 content: BoxContent,
2273 ) -> anyhow::Result<BoxId> {
2274 let recorded_at = recorded_at.into();
2275 let id = EventId(self.chatend.next_id);
2276 let box_id = BoxId(id.0);
2277 self.commit_events(
2278 recorded_at.clone(),
2279 vec![Event {
2280 id,
2281 recorded_at,
2282 kind: EventKind::BoxCreated {
2283 box_id,
2284 name: name.into(),
2285 owner,
2286 content,
2287 },
2288 }],
2289 )?;
2290 Ok(box_id)
2291 }
2292
2293 pub fn update_box(
2294 &mut self,
2295 recorded_at: impl Into<String>,
2296 box_id: BoxId,
2297 content: BoxContent,
2298 ) -> anyhow::Result<Option<EventId>> {
2299 let state = self
2300 .chatend
2301 .boxes
2302 .get(&box_id)
2303 .with_context(|| format!("box {box_id} does not exist"))?;
2304 ensure!(state.active, "box {box_id} is retired");
2305 if state.canonical.content == content {
2306 return Ok(None);
2307 }
2308 let id = EventId(self.chatend.next_id);
2309 let recorded_at = recorded_at.into();
2310 self.commit_events(
2311 recorded_at.clone(),
2312 vec![Event {
2313 id,
2314 recorded_at,
2315 kind: EventKind::CanonicalUpdated { box_id, content },
2316 }],
2317 )?;
2318 Ok(Some(id))
2319 }
2320
2321 pub fn dehydrate_boxes(
2322 &mut self,
2323 recorded_at: impl Into<String>,
2324 box_ids: &[BoxId],
2325 ) -> anyhow::Result<Vec<EventId>> {
2326 ensure!(
2327 !box_ids.is_empty(),
2328 "at least one box must be selected for dehydration"
2329 );
2330 ensure!(
2331 box_ids.iter().copied().collect::<HashSet<_>>().len() == box_ids.len(),
2332 "box dehydration cannot contain duplicate box IDs"
2333 );
2334 let recorded_at = recorded_at.into();
2335 let events = box_ids
2336 .iter()
2337 .enumerate()
2338 .map(|(offset, box_id)| {
2339 let offset = u64::try_from(offset).context("box dehydration batch is too large")?;
2340 let id = self
2341 .chatend
2342 .next_id
2343 .checked_add(offset)
2344 .context("event ID overflow")?;
2345 Ok(Event {
2346 id: EventId(id),
2347 recorded_at: recorded_at.clone(),
2348 kind: EventKind::BoxDehydrated { box_id: *box_id },
2349 })
2350 })
2351 .collect::<anyhow::Result<Vec<_>>>()?;
2352 let ids = events.iter().map(|event| event.id).collect();
2353 self.commit_events(recorded_at, events)?;
2354 Ok(ids)
2355 }
2356
2357 pub fn summarize_box(
2358 &mut self,
2359 recorded_at: impl Into<String>,
2360 box_id: BoxId,
2361 text: impl Into<String>,
2362 ) -> anyhow::Result<EventId> {
2363 self.box_operation(
2364 recorded_at,
2365 EventKind::BoxSummarized {
2366 box_id,
2367 text: text.into(),
2368 },
2369 )
2370 }
2371
2372 pub fn rehydrate_box(
2373 &mut self,
2374 recorded_at: impl Into<String>,
2375 box_id: BoxId,
2376 ) -> anyhow::Result<EventId> {
2377 self.box_operation(recorded_at, EventKind::BoxRehydrated { box_id })
2378 }
2379
2380 pub fn retire_box(
2381 &mut self,
2382 recorded_at: impl Into<String>,
2383 box_id: BoxId,
2384 ) -> anyhow::Result<EventId> {
2385 self.box_operation(recorded_at, EventKind::BoxRetired { box_id })
2386 }
2387
2388 fn box_operation(
2389 &mut self,
2390 recorded_at: impl Into<String>,
2391 kind: EventKind,
2392 ) -> anyhow::Result<EventId> {
2393 let box_id = event_box_id(&kind).context("box operation has no box identity")?;
2394 let state = self
2395 .chatend
2396 .box_state(box_id)
2397 .with_context(|| format!("box {box_id} does not exist"))?;
2398 ensure!(state.active, "box {box_id} is retired");
2399 if let EventKind::BoxSummarized { text, .. } = &kind {
2400 ensure!(!text.trim().is_empty(), "a box summary cannot be empty");
2401 }
2402 if matches!(kind, EventKind::BoxRetired { .. })
2403 && let BoxOwner::Tool { tool_instance, .. } = &state.owner
2404 {
2405 ensure!(
2406 self.chatend
2407 .tools
2408 .get(tool_instance)
2409 .is_some_and(|tool| tool.slots.iter().any(|slot| slot.box_id == box_id)),
2410 "tool box {box_id} is missing from {tool_instance}"
2411 );
2412 }
2413 let id = EventId(self.chatend.next_id);
2414 let recorded_at = recorded_at.into();
2415 self.commit_events(
2416 recorded_at.clone(),
2417 vec![Event {
2418 id,
2419 recorded_at,
2420 kind,
2421 }],
2422 )?;
2423 Ok(id)
2424 }
2425
2426 pub fn allocate_pending_node(
2427 &mut self,
2428 recorded_at: impl Into<String>,
2429 ) -> anyhow::Result<PendingId> {
2430 let id = EventId(self.chatend.next_id);
2431 let pending_id = PendingId::from_event(id);
2432 let recorded_at = recorded_at.into();
2433 self.commit_events(
2434 recorded_at.clone(),
2435 vec![Event {
2436 id,
2437 recorded_at,
2438 kind: EventKind::PendingAllocated {
2439 pending_id: pending_id.clone(),
2440 resource: PendingKind::Node,
2441 },
2442 }],
2443 )?;
2444 Ok(pending_id)
2445 }
2446
2447 pub fn stage_object(
2448 &mut self,
2449 recorded_at: impl Into<String>,
2450 media_type: impl Into<String>,
2451 file_name: Option<String>,
2452 transport: Value,
2453 bytes: &[u8],
2454 ) -> anyhow::Result<PendingId> {
2455 ensure!(
2456 bytes.len() as u64 <= MAX_OBJECT_BYTES,
2457 "object exceeds the 32 GiB V1 limit"
2458 );
2459 let aggregate = self
2460 .objects
2461 .values()
2462 .try_fold(bytes.len() as u64, |total, object| {
2463 total.checked_add(object.payload_len)
2464 })
2465 .context("staged object aggregate length overflow")?;
2466 ensure!(
2467 aggregate <= MAX_OBJECT_BYTES,
2468 "session object payload total exceeds the 32 GiB V1 limit"
2469 );
2470 let event_id = EventId(self.chatend.next_id);
2471 let pending_id = PendingId::from_event(event_id);
2472 let metadata = ObjectMetadata {
2473 pending_id: pending_id.clone(),
2474 event_id,
2475 recorded_at: recorded_at.into(),
2476 media_type: media_type.into(),
2477 file_name,
2478 transport,
2479 };
2480 let kind = EventKind::PendingAllocated {
2481 pending_id: pending_id.clone(),
2482 resource: PendingKind::Object,
2483 };
2484 let text = encode_context_event(&metadata.recorded_at, &kind)?;
2485 let object_file_name = metadata
2486 .file_name
2487 .clone()
2488 .unwrap_or_else(|| format!("object-{}", event_id.0));
2489 let position = self.durable.add_pending_object(
2490 text,
2491 object_file_name,
2492 metadata.media_type.clone(),
2493 bytes,
2494 )?;
2495 ensure!(
2496 position.0 + 1 == event_id.0,
2497 "session-log event position diverged from Kennedy context identity"
2498 );
2499 let allocation = Event {
2500 id: event_id,
2501 recorded_at: metadata.recorded_at.clone(),
2502 kind,
2503 };
2504 self.chatend.apply_transition(&Transition {
2505 recorded_at: metadata.recorded_at.clone(),
2506 events: vec![allocation],
2507 })?;
2508 self.objects.insert(
2509 pending_id.clone(),
2510 ObjectLocation {
2511 metadata,
2512 payload_offset: position.0,
2513 payload_len: bytes.len() as u64,
2514 },
2515 );
2516 Ok(pending_id)
2517 }
2518
2519 pub fn read_object(&mut self, id: &PendingId) -> anyhow::Result<Vec<u8>> {
2520 let location = self
2521 .objects
2522 .get(id)
2523 .with_context(|| format!("staged object {id} does not exist"))?
2524 .clone();
2525 Ok(self
2526 .durable
2527 .read_pending_object(EventPosition(location.payload_offset))?
2528 .bytes)
2529 }
2530
2531 pub fn reset_history_ingress_attempt(
2533 &mut self,
2534 recorded_at: impl Into<String>,
2535 ) -> anyhow::Result<EventId> {
2536 ensure!(
2537 !self.is_sealed(),
2538 "a sealed session cannot start another ingress attempt"
2539 );
2540 ensure!(
2541 self.chatend.history_ingress_started,
2542 "history ingress must have started before an attempt can be reset"
2543 );
2544 let recorded_at = recorded_at.into();
2545 self.record(
2546 recorded_at,
2547 EventKind::Note {
2548 label: HISTORY_INGRESS_ATTEMPT_RESET_NOTE.into(),
2549 value: json!({}),
2550 },
2551 )
2552 }
2553
2554 pub fn prepare_provider_projection(
2557 &mut self,
2558 recorded_at: impl Into<String>,
2559 footer_lines: &[String],
2560 material_fingerprint: &str,
2561 resume_after: Option<EventId>,
2562 ) -> anyhow::Result<PreparedProviderProjection> {
2563 self.prepare_provider_projection_at(
2564 recorded_at,
2565 footer_lines,
2566 material_fingerprint,
2567 resume_after,
2568 None,
2569 )
2570 }
2571
2572 pub fn prepare_provider_projection_with_ingress_time(
2574 &mut self,
2575 recorded_at: impl Into<String>,
2576 footer_lines: &[String],
2577 material_fingerprint: &str,
2578 resume_after: Option<EventId>,
2579 remaining_seconds: u64,
2580 previous_attempt_timed_out: bool,
2581 ) -> anyhow::Result<PreparedProviderProjection> {
2582 self.prepare_provider_projection_at(
2583 recorded_at,
2584 footer_lines,
2585 material_fingerprint,
2586 resume_after,
2587 Some((remaining_seconds, previous_attempt_timed_out)),
2588 )
2589 }
2590
2591 pub fn prepare_provider_resume_markers(
2593 &mut self,
2594 recorded_at: impl Into<String>,
2595 synchronized_after: EventId,
2596 ingress_time: Option<(u64, bool)>,
2597 ) -> anyhow::Result<Vec<String>> {
2598 let rewrite_reason = self
2599 .chatend
2600 .projection_rewrite_reason_after(Some(synchronized_after));
2601 let expectation = if rewrite_reason.is_empty() {
2602 CacheExpectation::ExpectedWarm
2603 } else {
2604 CacheExpectation::PlannedInvalidation {
2605 reason: rewrite_reason,
2606 }
2607 };
2608 self.append_provider_markers(recorded_at.into(), expectation, ingress_time)
2609 }
2610
2611 fn prepare_provider_projection_at(
2612 &mut self,
2613 recorded_at: impl Into<String>,
2614 footer_lines: &[String],
2615 material_fingerprint: &str,
2616 resume_after: Option<EventId>,
2617 ingress_time: Option<(u64, bool)>,
2618 ) -> anyhow::Result<PreparedProviderProjection> {
2619 let cacheable_projection = self.chatend.projection().render();
2620 let previous = self.chatend.latest_provider_input();
2621 let previous_projection = previous
2622 .map(|(_, context, prefix_bytes, fingerprint)| {
2623 let prefix_bytes = usize::try_from(prefix_bytes)
2624 .context("cacheable provider-prefix length does not fit usize")?;
2625 ensure!(
2626 prefix_bytes <= context.input.len()
2627 && context.input.is_char_boundary(prefix_bytes),
2628 "cacheable provider-prefix length is outside the exact input"
2629 );
2630 Ok(PreviousProjection {
2631 text: &context.input[..prefix_bytes],
2632 material_fingerprint: fingerprint,
2633 })
2634 })
2635 .transpose()?;
2636 let expectation = classify(
2637 previous_projection,
2638 &cacheable_projection,
2639 material_fingerprint,
2640 &self
2641 .chatend
2642 .projection_rewrite_reason_after(previous.map(|(id, ..)| id)),
2643 );
2644 self.append_provider_markers(recorded_at.into(), expectation.clone(), ingress_time)?;
2645 let cacheable_projection = self.chatend.projection().render();
2646 let projection = self.chatend.projection_with_footer_lines(footer_lines);
2647 let rewrite_reason = self.chatend.projection_rewrite_reason_after(resume_after);
2648 let thread_reset_reason = resume_after
2649 .is_some()
2650 .then_some(rewrite_reason)
2651 .filter(|reason| !reason.is_empty());
2652 let provider_input = if let Some(resume_after) = resume_after
2653 && thread_reset_reason.is_none()
2654 {
2655 let mut blocks = projection
2656 .items
2657 .iter()
2658 .filter(|item| item.event_id > resume_after)
2659 .map(|item| item.text.as_str())
2660 .collect::<Vec<_>>();
2661 if !projection.footer.is_empty() {
2662 blocks.push(&projection.footer);
2663 }
2664 blocks.join("\n\n")
2665 } else {
2666 projection.render()
2667 };
2668 Ok(PreparedProviderProjection {
2669 projection,
2670 provider_input,
2671 thread_reset_reason,
2672 cacheable_prefix_bytes: cacheable_projection.len() as u64,
2673 expectation,
2674 })
2675 }
2676
2677 fn append_provider_markers(
2678 &mut self,
2679 recorded_at: String,
2680 expectation: CacheExpectation,
2681 ingress_time: Option<(u64, bool)>,
2682 ) -> anyhow::Result<Vec<String>> {
2683 let projection = if expectation.expects_cache_hit() {
2684 self.chatend.projection()
2685 } else {
2686 let mut preview = self.chatend.clone();
2687 preview.apply_transition(&Transition {
2688 recorded_at: "marker-preview".into(),
2689 events: vec![Event {
2690 id: EventId(preview.next_id),
2691 recorded_at: "marker-preview".into(),
2692 kind: EventKind::ProjectionMarkersReset,
2693 }],
2694 })?;
2695 preview.projection()
2696 };
2697 let decision = decide_markers(
2698 &self.chatend.marker_state(),
2699 MarkerObservation {
2700 stale_boxes: projection.stale_boxes.iter().map(|id| id.0).collect(),
2701 current_context_tokens: projection.estimated_tokens,
2702 context_limit_tokens: projection.status.context_limit_tokens,
2703 expectation: expectation.clone(),
2704 },
2705 );
2706 let mut next = self.chatend.next_id;
2707 let mut events = Vec::new();
2708 let mut marker_lines = Vec::new();
2709 let ingress_time_marker_state = self.chatend.ingress_time_marker_state();
2710 let append_ingress_time = ingress_time.is_some_and(|(remaining_seconds, _)| {
2711 should_append_ingress_time_marker(
2712 ingress_time_marker_state,
2713 remaining_seconds,
2714 decision.context_size_tokens.is_some(),
2715 )
2716 });
2717 {
2718 let mut push = |kind| -> anyhow::Result<()> {
2719 events.push(Event {
2720 id: EventId(next),
2721 recorded_at: recorded_at.clone(),
2722 kind,
2723 });
2724 next = next.checked_add(1).context("event ID overflow")?;
2725 Ok(())
2726 };
2727 if decision.reset_epoch {
2728 push(EventKind::ProjectionMarkersReset)?;
2729 }
2730 if let Some(stale) = decision.stale {
2731 marker_lines.push(stale.render());
2732 let (box_ids, consolidated) = match stale {
2733 StaleMarker::New(ids) => (ids, false),
2734 StaleMarker::Consolidated(ids) => (ids, true),
2735 };
2736 push(EventKind::StaleBoxesMarked {
2737 box_ids: box_ids.into_iter().map(BoxId).collect(),
2738 consolidated,
2739 })?;
2740 }
2741 if let Some(estimated_tokens) = decision.context_size_tokens {
2742 marker_lines.push(context_size_marker_text(
2743 estimated_tokens,
2744 projection.status.context_limit_tokens,
2745 ));
2746 push(EventKind::ContextSizeMarked { estimated_tokens })?;
2747 }
2748 if append_ingress_time
2749 && let Some((remaining_seconds, previous_attempt_timed_out)) = ingress_time
2750 {
2751 let value = json!({
2752 "remainingSeconds":remaining_seconds,
2753 "previousAttemptTimedOut":previous_attempt_timed_out
2754 && !ingress_time_marker_state.any,
2755 });
2756 let marker_line = ingress_time_marker_text(&value)
2757 .expect("constructed ingress marker value is valid");
2758 push(EventKind::Note {
2759 label: INGRESS_TIME_MARKER_NOTE.into(),
2760 value,
2761 })?;
2762 marker_lines.push(marker_line);
2763 }
2764 }
2765 if !events.is_empty() {
2766 self.commit_events(recorded_at, events)?;
2767 }
2768 Ok(marker_lines)
2769 }
2770
2771 pub fn record(
2772 &mut self,
2773 recorded_at: impl Into<String>,
2774 kind: EventKind,
2775 ) -> anyhow::Result<EventId> {
2776 let id = EventId(self.chatend.next_id);
2777 let recorded_at = recorded_at.into();
2778 self.commit_events(
2779 recorded_at.clone(),
2780 vec![Event {
2781 id,
2782 recorded_at,
2783 kind,
2784 }],
2785 )?;
2786 Ok(id)
2787 }
2788
2789 pub fn commit_events(
2790 &mut self,
2791 recorded_at: impl Into<String>,
2792 events: Vec<Event>,
2793 ) -> anyhow::Result<()> {
2794 let transition = Transition {
2795 recorded_at: recorded_at.into(),
2796 events,
2797 };
2798 ensure!(
2799 !transition.events.is_empty(),
2800 "a transition cannot be empty"
2801 );
2802 ensure!(
2803 !transition.events.iter().any(|event| {
2804 matches!(
2805 event.kind,
2806 EventKind::PendingAllocated {
2807 resource: PendingKind::Object,
2808 ..
2809 }
2810 )
2811 }),
2812 "pending objects must be added through stage_object"
2813 );
2814 let mut preview = self.chatend.clone();
2815 preview.apply_transition(&transition)?;
2816 for event in &transition.events {
2817 let expected = self.durable.list().events.len() as u64 + 1;
2818 ensure!(
2819 event.id.0 == expected,
2820 "Kennedy context event {} does not match session-log position {}",
2821 event.id,
2822 expected - 1
2823 );
2824 self.durable.add_event(
2825 context_event_role(&event.kind),
2826 encode_context_event(&event.recorded_at, &event.kind)?,
2827 )?;
2828 }
2829 self.chatend = preview;
2830 Ok(())
2831 }
2832
2833 pub fn apply_tool_slots(
2834 &mut self,
2835 recorded_at: impl Into<String>,
2836 tool_instance: impl Into<String>,
2837 slots: Vec<ToolSlotInput>,
2838 ) -> anyhow::Result<Vec<EventId>> {
2839 self.apply_tool_slots_inner(recorded_at, tool_instance, slots, None)
2840 }
2841
2842 pub fn apply_tool_slots_with_layout(
2843 &mut self,
2844 recorded_at: impl Into<String>,
2845 tool_instance: impl Into<String>,
2846 slots: Vec<ToolSlotInput>,
2847 layout_slots: &[String],
2848 ) -> anyhow::Result<Vec<EventId>> {
2849 self.apply_tool_slots_inner(recorded_at, tool_instance, slots, Some(layout_slots))
2850 }
2851
2852 fn apply_tool_slots_inner(
2853 &mut self,
2854 recorded_at: impl Into<String>,
2855 tool_instance: impl Into<String>,
2856 slots: Vec<ToolSlotInput>,
2857 layout_slots: Option<&[String]>,
2858 ) -> anyhow::Result<Vec<EventId>> {
2859 let recorded_at = recorded_at.into();
2860 let tool_instance = tool_instance.into();
2861 let current = self
2862 .chatend
2863 .tools
2864 .get(&tool_instance)
2865 .cloned()
2866 .unwrap_or_default();
2867 ensure!(
2868 slots.len() >= current.slots.len(),
2869 "stateful tool slot sequence was truncated"
2870 );
2871 for (index, existing) in current.slots.iter().enumerate() {
2872 ensure!(
2873 slots[index].slot == existing.slot,
2874 "stateful tool slot sequence was reordered at index {index}"
2875 );
2876 ensure!(
2877 !existing.retired || slots[index].retired,
2878 "retired tool slot {} cannot be reactivated",
2879 existing.slot
2880 );
2881 }
2882 let mut events = Vec::new();
2883 let mut next = self.chatend.next_id;
2884 let mut next_state = current.clone();
2885 for (index, input) in slots.iter().enumerate() {
2886 if let Some(existing) = current.slots.get(index) {
2887 let state = self
2888 .chatend
2889 .boxes
2890 .get(&existing.box_id)
2891 .context("tool slot references a missing box")?;
2892 if input.retired && !existing.retired {
2893 let id = EventId(next);
2894 next += 1;
2895 events.push(Event {
2896 id,
2897 recorded_at: recorded_at.clone(),
2898 kind: EventKind::BoxRetired {
2899 box_id: existing.box_id,
2900 },
2901 });
2902 next_state.slots[index].retired = true;
2903 } else if !input.retired {
2904 if state.name != input.name {
2905 let id = EventId(next);
2906 next += 1;
2907 events.push(Event {
2908 id,
2909 recorded_at: recorded_at.clone(),
2910 kind: EventKind::BoxRenamed {
2911 box_id: existing.box_id,
2912 name: input.name.clone(),
2913 },
2914 });
2915 }
2916 if state.canonical.content != input.content {
2917 let id = EventId(next);
2918 next += 1;
2919 events.push(Event {
2920 id,
2921 recorded_at: recorded_at.clone(),
2922 kind: EventKind::CanonicalUpdated {
2923 box_id: existing.box_id,
2924 content: input.content.clone(),
2925 },
2926 });
2927 }
2928 }
2929 } else {
2930 ensure!(
2931 !input.retired,
2932 "a newly appended tool slot cannot start retired"
2933 );
2934 let id = EventId(next);
2935 next += 1;
2936 let box_id = BoxId(id.0);
2937 events.push(Event {
2938 id,
2939 recorded_at: recorded_at.clone(),
2940 kind: EventKind::BoxCreated {
2941 box_id,
2942 name: input.name.clone(),
2943 owner: BoxOwner::Tool {
2944 tool_instance: tool_instance.clone(),
2945 slot: input.slot.clone(),
2946 },
2947 content: input.content.clone(),
2948 },
2949 });
2950 next_state.slots.push(ToolSlot {
2951 slot: input.slot.clone(),
2952 box_id,
2953 retired: false,
2954 });
2955 }
2956 }
2957 if let Some(layout_slots) = layout_slots {
2958 let mut unique = std::collections::HashSet::new();
2959 let box_ids = layout_slots
2960 .iter()
2961 .map(|slot_name| {
2962 ensure!(
2963 unique.insert(slot_name),
2964 "tool layout contains duplicate slot {slot_name}"
2965 );
2966 let slot = next_state
2967 .slots
2968 .iter()
2969 .find(|slot| &slot.slot == slot_name)
2970 .with_context(|| {
2971 format!("tool layout references missing slot {slot_name}")
2972 })?;
2973 ensure!(
2974 !slot.retired,
2975 "tool layout references retired slot {slot_name}"
2976 );
2977 Ok(slot.box_id)
2978 })
2979 .collect::<anyhow::Result<Vec<_>>>()?;
2980 if self.chatend.tool_layouts.get(&tool_instance) != Some(&box_ids) {
2981 let id = EventId(next);
2982 events.push(Event {
2983 id,
2984 recorded_at: recorded_at.clone(),
2985 kind: EventKind::ToolLayoutChanged {
2986 tool_instance: tool_instance.clone(),
2987 box_ids,
2988 },
2989 });
2990 }
2991 }
2992 if events.is_empty() {
2993 return Ok(Vec::new());
2994 }
2995 let ids = events.iter().map(|event| event.id).collect::<Vec<_>>();
2996 self.commit_events(recorded_at, events)?;
2997 Ok(ids)
2998 }
2999
3000 pub fn apply_box_representations(
3001 &mut self,
3002 recorded_at: impl Into<String>,
3003 desired: &BTreeMap<BoxId, BoxRepresentation>,
3004 ) -> anyhow::Result<Vec<EventId>> {
3005 let recorded_at = recorded_at.into();
3006 let events = self
3007 .chatend
3008 .box_representation_events(&recorded_at, desired)?;
3009 if events.is_empty() {
3010 return Ok(Vec::new());
3011 }
3012 let ids = events.iter().map(|event| event.id).collect::<Vec<_>>();
3013 self.commit_events(recorded_at, events)?;
3014 Ok(ids)
3015 }
3016}
3017
3018pub struct SessionHistoryIntegration;
3025
3026impl SessionHistoryIntegration {
3027 pub fn create_session(
3029 path: impl AsRef<Path>,
3030 metadata: SessionMetadata,
3031 ) -> anyhow::Result<Session> {
3032 Session::create(path, metadata)
3033 }
3034
3035 pub fn open_session(
3041 path: impl AsRef<Path>,
3042 metadata: SessionMetadata,
3043 default_provider_model: Option<&str>,
3044 estimator: Option<ProviderCostEstimator>,
3045 ) -> anyhow::Result<Session> {
3046 match (default_provider_model, estimator) {
3047 (None, None) => Session::open_with_metadata(path, metadata),
3048 (default_provider_model, estimator) => Session::open_with_metadata_and_provider_costs(
3049 path,
3050 metadata,
3051 default_provider_model,
3052 estimator,
3053 ),
3054 }
3055 }
3056
3057 pub fn replay(
3062 metadata: SessionMetadata,
3063 log: &kcode_session_log::SessionLog,
3064 default_provider_model: Option<&str>,
3065 estimator: Option<ProviderCostEstimator>,
3066 ) -> anyhow::Result<Chatend> {
3067 match (default_provider_model, estimator) {
3068 (None, None) => Chatend::replay(metadata, log),
3069 (default_provider_model, estimator) => Chatend::replay_with_provider_costs(
3070 metadata,
3071 log,
3072 default_provider_model,
3073 estimator,
3074 ),
3075 }
3076 }
3077
3078 pub fn legacy_provider_cost_summary_for_archive(
3082 archive: &Value,
3083 default_provider_model: Option<&str>,
3084 estimator: ProviderCostEstimator,
3085 ) -> anyhow::Result<ProviderCostSummary> {
3086 legacy_provider_cost_summary_for_archive(archive, default_provider_model, estimator)
3087 }
3088}
3089
3090#[cfg(test)]
3091type SessionJournal = Session;
3092
3093#[cfg(test)]
3094mod tests {
3095 use std::path::PathBuf;
3096 use std::time::{SystemTime, UNIX_EPOCH};
3097
3098 use serde_json::json;
3099
3100 use super::*;
3101
3102 fn path(label: &str) -> PathBuf {
3103 std::env::temp_dir()
3104 .join(format!(
3105 "kennedy-chatend-{label}-{}-{}",
3106 std::process::id(),
3107 SystemTime::now()
3108 .duration_since(UNIX_EPOCH)
3109 .unwrap()
3110 .as_nanos()
3111 ))
3112 .join("session-1.session-log")
3113 }
3114
3115 fn metadata() -> SessionMetadata {
3116 SessionMetadata {
3117 session_id: "session-1".into(),
3118 kind: SessionKind::Conversation,
3119 created_at: "2026-07-23T00:00:00Z".into(),
3120 effective_context_tokens: 1_000,
3121 channel: json!({"kind":"test"}),
3122 }
3123 }
3124
3125 #[test]
3126 fn box_identity_continuations_staleness_and_replay_are_exact() {
3127 let path = path("boxes");
3128 let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3129 let id = journal
3130 .create_box(
3131 "t1",
3132 "message",
3133 BoxOwner::User,
3134 BoxContent::text("original"),
3135 )
3136 .unwrap();
3137 assert_eq!(id, BoxId(1));
3138 journal.summarize_box("t2", id, "summary").unwrap();
3139 journal
3140 .update_box("t3", id, BoxContent::text("changed"))
3141 .unwrap();
3142 let state = journal.state().box_state(id).unwrap().clone();
3143 assert!(state.stale());
3144 assert_eq!(
3145 state.representation,
3146 Representation::Summarized {
3147 based_on: EventId(1),
3148 text: "summary".into()
3149 }
3150 );
3151 let projection = journal.state().projection();
3152 assert_eq!(projection.items[0].text, "[box updated]");
3153 assert_eq!(projection.items[1].text, "[box updated]");
3154 assert!(projection.items[2].text.contains("summary"));
3155 assert!(projection.items[2].stale);
3156 assert!(projection.footer.is_empty());
3157 assert!(projection.render().ends_with("summary"));
3158
3159 let transient = vec![
3160 "[provider calls remaining after this call: 249 | max provider calls: 250]".into(),
3161 "[provider call time remaining: 3h 45m]".into(),
3162 ];
3163 let with_limits = journal.state().projection_with_footer_lines(&transient);
3164 assert_eq!(with_limits.footer, transient.join("\n"));
3165 assert!(with_limits.render().ends_with(&transient.join("\n")));
3166 assert!(with_limits.context_bytes > projection.context_bytes);
3167 drop(journal);
3168 let reopened = SessionJournal::open_with_metadata(&path, metadata()).unwrap();
3169 assert_eq!(reopened.state().box_state(id), Some(&state));
3170 std::fs::remove_file(path).unwrap();
3171 }
3172
3173 #[test]
3174 fn box_headers_hide_internal_ownership_and_timestamp_messages() {
3175 let path = path("box-headers");
3176 let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3177 journal
3178 .create_box(
3179 "2026-07-23T00:00:01Z",
3180 "User message",
3181 BoxOwner::User,
3182 BoxContent::text("Hello"),
3183 )
3184 .unwrap();
3185 journal
3186 .create_box(
3187 "2026-07-23T00:00:02Z",
3188 "Kennedy message",
3189 BoxOwner::Kennedy,
3190 BoxContent::text("Hi"),
3191 )
3192 .unwrap();
3193 let mut content = BoxContent::text("Node ID: AAAAAAAB");
3194 content.use_concise_header();
3195 journal
3196 .create_box(
3197 "2026-07-23T00:00:03Z",
3198 "Kweb loaded node",
3199 BoxOwner::Tool {
3200 tool_instance: "kweb".into(),
3201 slot: "loaded".into(),
3202 },
3203 content,
3204 )
3205 .unwrap();
3206
3207 let projection = journal.state().projection();
3208 assert_eq!(
3209 projection.items[0].text,
3210 "[box 1 | User message | timestamp=2026-07-23T00:00:01Z | hydrated]\nHello"
3211 );
3212 assert_eq!(
3213 projection.items[1].text,
3214 "[box 2 | Kennedy message | timestamp=2026-07-23T00:00:02Z | hydrated]\nHi"
3215 );
3216 assert_eq!(
3217 projection.items[2].text,
3218 "[box 3 | Kweb loaded node | hydrated]\nNode ID: AAAAAAAB"
3219 );
3220 assert!(
3221 projection
3222 .items
3223 .iter()
3224 .all(|item| !item.text.contains("owner="))
3225 );
3226 assert!(projection.footer.is_empty());
3227 std::fs::remove_file(path).unwrap();
3228 }
3229
3230 #[test]
3231 fn provider_preparation_anchors_sparse_markers_and_resets_rewritten_epochs() {
3232 let path = path("provider-markers");
3233 let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3234 let first_box = journal
3235 .create_box(
3236 "t1",
3237 "System prompt",
3238 BoxOwner::System,
3239 BoxContent::text("x".repeat(1_000)),
3240 )
3241 .unwrap();
3242 let first = journal
3243 .prepare_provider_projection(
3244 "t2",
3245 &["[provider calls remaining: 9]".into()],
3246 "m1",
3247 None,
3248 )
3249 .unwrap();
3250 assert_eq!(first.expectation, CacheExpectation::ColdStart);
3251 assert!(first.projection.render().contains("[current context size:"));
3252 assert!(
3253 first
3254 .projection
3255 .render()
3256 .ends_with("[provider calls remaining: 9]")
3257 );
3258 let first_input = first.projection.render();
3259 let synchronized_event_id = journal
3260 .record(
3261 "t3",
3262 EventKind::ProviderInputSubmitted {
3263 round: 1,
3264 context: provider_context(first_input.clone()),
3265 transport_input_hash: None,
3266 transport_input_bytes: None,
3267 thread_action: None,
3268 thread_reset_reason: None,
3269 cacheable_prefix_bytes: first.cacheable_prefix_bytes,
3270 material_fingerprint: "m1".into(),
3271 cache_expectation: first.expectation.label().into(),
3272 planned_invalidation_reason: None,
3273 },
3274 )
3275 .unwrap();
3276 let archive: Value = serde_json::from_slice(&journal.archive_bytes().unwrap()).unwrap();
3277 assert_eq!(archive["chatendText"], first_input);
3278 assert_eq!(archive["chatendTextSource"], "submitted");
3279 assert_eq!(archive["structuredMaterial"]["provider"], "codex");
3280 assert_eq!(archive["structuredMaterial"]["model"], "test");
3281 journal
3282 .create_box(
3283 "t4",
3284 "User message",
3285 BoxOwner::User,
3286 BoxContent::text("next"),
3287 )
3288 .unwrap();
3289 let warm = journal
3290 .prepare_provider_projection("t5", &[], "m1", Some(synchronized_event_id))
3291 .unwrap();
3292 assert_eq!(warm.expectation, CacheExpectation::ExpectedWarm);
3293 assert!(warm.provider_input.contains("next"));
3294 assert!(!warm.provider_input.contains(&"x".repeat(1_000)));
3295 assert!(warm.thread_reset_reason.is_none());
3296
3297 journal.summarize_box("t6", first_box, "short").unwrap();
3298 let reset = journal
3299 .prepare_provider_projection("t7", &[], "m1", Some(synchronized_event_id))
3300 .unwrap();
3301 assert_eq!(
3302 reset.expectation,
3303 CacheExpectation::PlannedInvalidation {
3304 reason: "summarization".into()
3305 }
3306 );
3307 assert_eq!(reset.thread_reset_reason.as_deref(), Some("summarization"));
3308 assert_eq!(reset.provider_input, reset.projection.render());
3309 assert!(!reset.projection.render().contains("[current context size:"));
3310 assert_eq!(
3311 journal
3312 .state()
3313 .events
3314 .iter()
3315 .filter(|event| matches!(event.kind, EventKind::ProjectionMarkersReset))
3316 .count(),
3317 2
3318 );
3319 std::fs::remove_file(path).unwrap();
3320 }
3321
3322 #[test]
3323 fn provider_resume_markers_follow_growth_and_rewritten_epochs() {
3324 let path = path("provider-resume-markers");
3325 let mut journal = SessionJournal::create(
3326 &path,
3327 SessionMetadata {
3328 effective_context_tokens: 100_000,
3329 ..metadata()
3330 },
3331 )
3332 .unwrap();
3333 let first_box = journal
3334 .create_box(
3335 "t1",
3336 "System prompt",
3337 BoxOwner::System,
3338 BoxContent::text("small"),
3339 )
3340 .unwrap();
3341 let initial = journal
3342 .prepare_provider_projection("t2", &[], "m1", None)
3343 .unwrap();
3344 assert!(
3345 !initial
3346 .projection
3347 .render()
3348 .contains("[current context size:")
3349 );
3350 let mut synchronized_after = journal
3351 .record(
3352 "t3",
3353 EventKind::ProviderInputSubmitted {
3354 round: 1,
3355 context: provider_context(initial.projection.render()),
3356 transport_input_hash: None,
3357 transport_input_bytes: None,
3358 thread_action: None,
3359 thread_reset_reason: None,
3360 cacheable_prefix_bytes: initial.cacheable_prefix_bytes,
3361 material_fingerprint: "m1".into(),
3362 cache_expectation: initial.expectation.label().into(),
3363 planned_invalidation_reason: None,
3364 },
3365 )
3366 .unwrap();
3367
3368 journal
3369 .create_box(
3370 "t4",
3371 "large result",
3372 BoxOwner::Controller,
3373 BoxContent::text("x".repeat(130_000)),
3374 )
3375 .unwrap();
3376 let first = journal
3377 .prepare_provider_resume_markers("t5", synchronized_after, None)
3378 .unwrap();
3379 let (marker_event, marked_tokens) = journal
3380 .state()
3381 .events
3382 .iter()
3383 .rev()
3384 .find_map(|event| match &event.kind {
3385 EventKind::ContextSizeMarked { estimated_tokens } => {
3386 Some((event.id, *estimated_tokens))
3387 }
3388 _ => None,
3389 })
3390 .unwrap();
3391 let expected = format!(
3392 "[current context size: approximately {marked_tokens} tokens | session limit: 70000 tokens]"
3393 );
3394 assert_eq!(first.as_slice(), std::slice::from_ref(&expected));
3395 assert_eq!(
3396 journal
3397 .state()
3398 .projection()
3399 .items
3400 .into_iter()
3401 .find(|item| item.event_id == marker_event)
3402 .unwrap()
3403 .text,
3404 expected
3405 );
3406
3407 synchronized_after = journal.state().events.last().unwrap().id;
3408 journal
3409 .create_box(
3410 "t6",
3411 "small growth",
3412 BoxOwner::Controller,
3413 BoxContent::text("y".repeat(20_000)),
3414 )
3415 .unwrap();
3416 assert!(
3417 journal
3418 .prepare_provider_resume_markers("t7", synchronized_after, None)
3419 .unwrap()
3420 .is_empty()
3421 );
3422
3423 synchronized_after = journal.state().events.last().unwrap().id;
3424 journal
3425 .create_box(
3426 "t8",
3427 "large growth",
3428 BoxOwner::Controller,
3429 BoxContent::text("z".repeat(40_000)),
3430 )
3431 .unwrap();
3432 let second = journal
3433 .prepare_provider_resume_markers("t9", synchronized_after, None)
3434 .unwrap();
3435 assert_eq!(second.len(), 1);
3436 assert!(second[0].starts_with("[current context size: approximately "));
3437
3438 synchronized_after = journal.state().events.last().unwrap().id;
3439 journal
3440 .update_box(
3441 "t10",
3442 first_box,
3443 BoxContent::text("rewritten system prompt"),
3444 )
3445 .unwrap();
3446 let reset = journal
3447 .prepare_provider_resume_markers("t11", synchronized_after, None)
3448 .unwrap();
3449 assert_eq!(reset.len(), 1);
3450 assert!(reset[0].starts_with("[current context size: approximately "));
3451 assert_eq!(
3452 journal
3453 .state()
3454 .events
3455 .iter()
3456 .filter(|event| matches!(event.kind, EventKind::ProjectionMarkersReset))
3457 .count(),
3458 2,
3459 );
3460 std::fs::remove_file(path).unwrap();
3461 }
3462
3463 fn provider_context(input: String) -> ProviderContext {
3464 ProviderContext {
3465 input,
3466 provider: "codex".into(),
3467 model: "test".into(),
3468 reasoning_effort: "xhigh".into(),
3469 base_instructions: Some("base".into()),
3470 developer_instructions: Some(String::new()),
3471 tools: Vec::new(),
3472 }
3473 }
3474
3475 #[test]
3476 fn shared_pending_and_box_identity_space_never_overlaps() {
3477 let path = path("pending");
3478 let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3479 let first = journal.allocate_pending_node("t1").unwrap();
3480 let box_id = journal
3481 .create_box("t2", "box", BoxOwner::Kennedy, BoxContent::text("hello"))
3482 .unwrap();
3483 let object = journal
3484 .stage_object(
3485 "t3",
3486 "application/octet-stream",
3487 None,
3488 Value::Null,
3489 b"\0binary\xff",
3490 )
3491 .unwrap();
3492 assert_eq!(first.to_string(), "pending:1");
3493 assert_eq!(box_id, BoxId(2));
3494 assert_eq!(object.to_string(), "pending:3");
3495 assert_eq!(journal.read_object(&object).unwrap(), b"\0binary\xff");
3496 let stored = journal.session_log();
3497 assert!(!stored.events[0].text.contains("pending_id"));
3498 assert!(!stored.events[1].text.contains("box_id"));
3499 assert!(!stored.events[2].text.contains("pending_id"));
3500 drop(journal);
3501 let mut reopened = SessionJournal::open_with_metadata(&path, metadata()).unwrap();
3502 assert_eq!(reopened.read_object(&object).unwrap(), b"\0binary\xff");
3503 assert_eq!(reopened.state().next_id, 4);
3504 std::fs::remove_file(path).unwrap();
3505 }
3506
3507 #[test]
3508 fn kennedy_tool_completion_is_validated_before_storage_seals() {
3509 let path = path("seal-tool");
3510 let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3511 journal
3512 .record(
3513 "t1",
3514 EventKind::ToolInvoked {
3515 tool_instance: "CreateNode:1".into(),
3516 tool_name: "CreateNode".into(),
3517 arguments: json!({}),
3518 invocation_id: None,
3519 },
3520 )
3521 .unwrap();
3522 assert!(journal.seal().is_err());
3523 journal
3524 .record(
3525 "t2",
3526 EventKind::ToolCompleted {
3527 tool_instance: "call_ktool".into(),
3528 tool_name: "call_ktool".into(),
3529 outcome: json!({"ok":true}),
3530 invocation_id: None,
3531 },
3532 )
3533 .unwrap();
3534 journal.seal().unwrap();
3535 assert!(journal.is_sealed());
3536 std::fs::remove_file(path).unwrap();
3537 }
3538
3539 #[test]
3540 fn interrupted_tools_are_recovered_by_identity_without_losing_legacy_journals() {
3541 let path = path("repair-tools");
3542 let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3543 journal
3544 .record(
3545 "t1",
3546 EventKind::ToolInvoked {
3547 tool_instance: "WebSearch:legacy".into(),
3548 tool_name: "WebSearch".into(),
3549 arguments: json!({"question":"legacy"}),
3550 invocation_id: None,
3551 },
3552 )
3553 .unwrap();
3554 journal
3555 .record(
3556 "t2",
3557 EventKind::ToolInvoked {
3558 tool_instance: "WebSearch:new".into(),
3559 tool_name: "WebSearch".into(),
3560 arguments: json!({"question":"identified"}),
3561 invocation_id: Some("call-1".into()),
3562 },
3563 )
3564 .unwrap();
3565 assert!(journal.seal().is_err());
3566
3567 let repaired = journal.repair_unfinished_tools("recovery").unwrap();
3568 assert_eq!(repaired.len(), 2);
3569 assert!(
3570 journal
3571 .state()
3572 .events
3573 .iter()
3574 .rev()
3575 .take(2)
3576 .all(|event| matches!(
3577 &event.kind,
3578 EventKind::ToolCompleted { outcome, .. }
3579 if outcome.get("recovered").and_then(Value::as_bool) == Some(true)
3580 ))
3581 );
3582 journal.seal().unwrap();
3583 assert!(journal.is_sealed());
3584 std::fs::remove_file(path).unwrap();
3585 }
3586
3587 #[test]
3588 fn identified_tool_completions_can_arrive_out_of_order() {
3589 let path = path("tool-identity");
3590 let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3591 for id in ["call-1", "call-2"] {
3592 journal
3593 .record(
3594 id,
3595 EventKind::ToolInvoked {
3596 tool_instance: format!("WebSearch:{id}"),
3597 tool_name: "WebSearch".into(),
3598 arguments: json!({"question":id}),
3599 invocation_id: Some(id.into()),
3600 },
3601 )
3602 .unwrap();
3603 }
3604 for id in ["call-1", "call-2"] {
3605 journal
3606 .record(
3607 format!("{id}-complete"),
3608 EventKind::ToolCompleted {
3609 tool_instance: format!("WebSearch:{id}"),
3610 tool_name: "WebSearch".into(),
3611 outcome: json!({"ok":true}),
3612 invocation_id: Some(id.into()),
3613 },
3614 )
3615 .unwrap();
3616 }
3617 journal.seal().unwrap();
3618 std::fs::remove_file(path).unwrap();
3619 }
3620
3621 #[test]
3622 fn invalid_box_operations_do_not_poison_the_append_only_journal() {
3623 let path = path("invalid-box-operation");
3624 let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3625 let box_id = journal
3626 .create_box(
3627 "t1",
3628 "valid",
3629 BoxOwner::Controller,
3630 BoxContent::text("canonical"),
3631 )
3632 .unwrap();
3633 let valid_length = std::fs::metadata(&path).unwrap().len();
3634
3635 let result = journal.dehydrate_boxes("t2", &[box_id, BoxId(97)]);
3636 assert_eq!(result.unwrap_err().to_string(), "box 97 does not exist");
3637 assert_eq!(std::fs::metadata(&path).unwrap().len(), valid_length);
3638 assert!(matches!(
3639 journal.state().box_state(box_id).unwrap().representation,
3640 Representation::Hydrated { .. }
3641 ));
3642
3643 for result in [
3644 journal.summarize_box("t3", BoxId(97), "summary"),
3645 journal.rehydrate_box("t4", BoxId(97)),
3646 journal.retire_box("t5", BoxId(97)),
3647 ] {
3648 assert_eq!(result.unwrap_err().to_string(), "box 97 does not exist");
3649 assert_eq!(std::fs::metadata(&path).unwrap().len(), valid_length);
3650 }
3651
3652 drop(journal);
3653 let reopened = SessionJournal::open_with_metadata(&path, metadata()).unwrap();
3654 assert_eq!(
3655 reopened
3656 .state()
3657 .box_state(box_id)
3658 .unwrap()
3659 .canonical
3660 .content,
3661 BoxContent::text("canonical")
3662 );
3663 std::fs::remove_file(path).unwrap();
3664 }
3665
3666 #[test]
3667 fn multiple_boxes_dehydrate_in_one_replayable_batch() {
3668 let path = path("dehydrate-boxes");
3669 let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3670 let boxes = ["first", "second", "third"]
3671 .into_iter()
3672 .map(|text| {
3673 journal
3674 .create_box("t1", text, BoxOwner::Controller, BoxContent::text(text))
3675 .unwrap()
3676 })
3677 .collect::<Vec<_>>();
3678
3679 let event_ids = journal
3680 .dehydrate_boxes("t2", &[boxes[0], boxes[2]])
3681 .unwrap();
3682 assert_eq!(event_ids, [EventId(4), EventId(5)]);
3683 assert!(matches!(
3684 journal.state().box_state(boxes[0]).unwrap().representation,
3685 Representation::Dehydrated { .. }
3686 ));
3687 assert!(matches!(
3688 journal.state().box_state(boxes[1]).unwrap().representation,
3689 Representation::Hydrated { .. }
3690 ));
3691 assert!(matches!(
3692 journal.state().box_state(boxes[2]).unwrap().representation,
3693 Representation::Dehydrated { .. }
3694 ));
3695
3696 let valid_length = std::fs::metadata(&path).unwrap().len();
3697 assert_eq!(
3698 journal.dehydrate_boxes("t3", &[]).unwrap_err().to_string(),
3699 "at least one box must be selected for dehydration"
3700 );
3701 assert_eq!(
3702 journal
3703 .dehydrate_boxes("t3", &[boxes[1], boxes[1]])
3704 .unwrap_err()
3705 .to_string(),
3706 "box dehydration cannot contain duplicate box IDs"
3707 );
3708 assert_eq!(std::fs::metadata(&path).unwrap().len(), valid_length);
3709
3710 drop(journal);
3711 let reopened = SessionJournal::open_with_metadata(&path, metadata()).unwrap();
3712 assert!(matches!(
3713 reopened.state().box_state(boxes[0]).unwrap().representation,
3714 Representation::Dehydrated { .. }
3715 ));
3716 assert!(matches!(
3717 reopened.state().box_state(boxes[2]).unwrap().representation,
3718 Representation::Dehydrated { .. }
3719 ));
3720 std::fs::remove_file(path).unwrap();
3721 }
3722
3723 #[test]
3724 fn status_estimates_the_context_with_every_active_box_hydrated() {
3725 let path = path("fully-hydrated-estimate");
3726 let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3727 let box_id = journal
3728 .create_box(
3729 "t1",
3730 "large",
3731 BoxOwner::Controller,
3732 BoxContent::text("canonical material ".repeat(1_000)),
3733 )
3734 .unwrap();
3735 journal.dehydrate_boxes("t2", &[box_id]).unwrap();
3736
3737 let status = journal.state().projection().status;
3738 assert!(
3739 status.fully_hydrated_context_tokens > status.current_context_tokens,
3740 "the hydrated estimate should include the canonical body"
3741 );
3742 std::fs::remove_file(path).unwrap();
3743 }
3744
3745 #[test]
3746 fn provider_measurements_recalibrate_the_matching_manifest_then_track_deltas() {
3747 let path = path("calibration");
3748 let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3749 journal
3750 .create_box(
3751 "t1",
3752 "system",
3753 BoxOwner::System,
3754 BoxContent::text("baseline provider content"),
3755 )
3756 .unwrap();
3757 let submitted = journal.state().projection();
3758 journal
3759 .record(
3760 "t2",
3761 EventKind::InferenceSubmitted {
3762 manifest_hash: "manifest-1".into(),
3763 estimated_input_tokens: submitted.estimated_tokens,
3764 raw_estimated_input_tokens: Some(submitted.raw_estimated_tokens),
3765 },
3766 )
3767 .unwrap();
3768 let tool_box = journal
3769 .create_box(
3770 "t3",
3771 "tool result",
3772 BoxOwner::Controller,
3773 BoxContent::text("x".repeat(3_000)),
3774 )
3775 .unwrap();
3776 let measured_context = journal.state().projection();
3777 journal
3778 .record(
3779 "t4",
3780 EventKind::ProviderReceipt {
3781 manifest_hash: "manifest-1".into(),
3782 input_tokens: Some(777),
3783 output_tokens: Some(3),
3784 context_bytes: Some(measured_context.context_bytes),
3785 raw_context_tokens: Some(measured_context.raw_estimated_tokens),
3786 provider_data: Value::Null,
3787 },
3788 )
3789 .unwrap();
3790 assert_eq!(journal.state().projection().estimated_tokens, 777);
3791 journal
3792 .create_box(
3793 "t5",
3794 "new material",
3795 BoxOwner::User,
3796 BoxContent::text("x".repeat(300)),
3797 )
3798 .unwrap();
3799 let expanded = journal.state().projection();
3800 assert_eq!(
3801 expanded.estimated_tokens,
3802 777 + expanded
3803 .context_bytes
3804 .abs_diff(measured_context.context_bytes)
3805 / ESTIMATED_BYTES_PER_TOKEN
3806 );
3807 assert!(expanded.raw_estimated_tokens > submitted.raw_estimated_tokens);
3808 journal.dehydrate_boxes("t6", &[tool_box]).unwrap();
3809 let shrunken = journal.state().projection();
3810 assert_eq!(
3811 shrunken.estimated_tokens,
3812 777_u64.saturating_sub(
3813 measured_context
3814 .context_bytes
3815 .abs_diff(shrunken.context_bytes)
3816 / ESTIMATED_BYTES_PER_TOKEN
3817 )
3818 );
3819 std::fs::remove_file(path).unwrap();
3820 }
3821
3822 #[test]
3823 fn session_status_keeps_provider_usage_categories_exact_and_exclusive() {
3824 let path = path("session-status");
3825 let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3826 journal
3827 .create_box("t1", "system", BoxOwner::System, BoxContent::text("é"))
3828 .unwrap();
3829 let before_usage = journal.state().projection();
3830 assert_eq!(
3831 before_usage.raw_estimated_tokens,
3832 before_usage
3833 .context_bytes
3834 .div_ceil(ESTIMATED_BYTES_PER_TOKEN)
3835 );
3836 journal
3837 .record(
3838 "t2",
3839 EventKind::ProviderReceipt {
3840 manifest_hash: "manifest-1".into(),
3841 input_tokens: Some(100),
3842 output_tokens: Some(20),
3843 context_bytes: Some(before_usage.context_bytes),
3844 raw_context_tokens: Some(before_usage.raw_estimated_tokens),
3845 provider_data: json!({
3846 "usageIsDelta":true,
3847 "cachedInputTokens":40,
3848 "nonCachedInputTokens":60,
3849 "thinkingTokens":8,
3850 "outputTokens":12,
3851 "estimatedCostUsdNanos":12345
3852 }),
3853 },
3854 )
3855 .unwrap();
3856 let second_anchor = journal.state().projection();
3857 journal
3858 .record(
3859 "t3",
3860 EventKind::ProviderReceipt {
3861 manifest_hash: "manifest-1".into(),
3862 input_tokens: Some(120),
3863 output_tokens: Some(5),
3864 context_bytes: Some(second_anchor.context_bytes),
3865 raw_context_tokens: Some(second_anchor.raw_estimated_tokens),
3866 provider_data: json!({
3867 "usageIsDelta":true,
3868 "cachedInputTokens":10,
3869 "nonCachedInputTokens":10,
3870 "thinkingTokens":2,
3871 "outputTokens":3,
3872 "estimatedCostUsdNanos":6789
3873 }),
3874 },
3875 )
3876 .unwrap();
3877 let projection = journal.state().projection();
3878 assert_eq!(
3879 projection.status,
3880 SessionStatus {
3881 current_context_tokens: 120,
3882 fully_hydrated_context_tokens: 120,
3883 context_limit_tokens: 700,
3884 current_context_bytes: projection.context_bytes,
3885 cached_input_tokens: 50,
3886 non_cached_input_tokens: 70,
3887 thinking_tokens: 10,
3888 output_tokens: 15,
3889 estimated_cost_usd_nanos: 19_134,
3890 unpriced_provider_calls: 0,
3891 }
3892 );
3893 let archive: Value = serde_json::from_slice(&journal.archive_bytes().unwrap()).unwrap();
3894 assert_eq!(archive["context"]["status"]["cachedInputTokens"], 50);
3895 assert!(
3896 archive["chatendText"]
3897 .as_str()
3898 .unwrap()
3899 .ends_with(archive["context"]["footer"].as_str().unwrap())
3900 );
3901 std::fs::remove_file(path).unwrap();
3902 }
3903
3904 fn compatibility_cost(
3905 model: &str,
3906 metering: &ProviderMetering,
3907 ) -> Option<ProviderCostEstimate> {
3908 let ProviderMetering::Tokens(usage) = metering else {
3909 return None;
3910 };
3911 let base = match model {
3912 "gpt-5.6-sol" => 1_000,
3913 "gemini-3.1-pro-preview" => 2_000,
3914 _ => return None,
3915 };
3916 Some(ProviderCostEstimate {
3917 usd_nanos: base + usage.input_tokens,
3918 accuracy: json!("exact"),
3919 pricing_version: "test-prices".into(),
3920 })
3921 }
3922
3923 #[test]
3924 fn legacy_provider_costs_are_reconstructed_without_rewriting_history() {
3925 let path = path("legacy-provider-costs");
3926 let mut journal = SessionJournal::create(&path, metadata()).unwrap();
3927 journal
3928 .record(
3929 "t1",
3930 EventKind::ProviderReceipt {
3931 manifest_hash: "top".into(),
3932 input_tokens: Some(10),
3933 output_tokens: Some(1),
3934 context_bytes: None,
3935 raw_context_tokens: None,
3936 provider_data: json!({
3937 "usageIsDelta":true,
3938 "nonCachedInputTokens":10,
3939 "cachedInputTokens":0,
3940 "thinkingTokens":0,
3941 "outputTokens":1
3942 }),
3943 },
3944 )
3945 .unwrap();
3946 journal
3947 .record(
3948 "t2",
3949 EventKind::ToolInvoked {
3950 tool_instance: "RunSubagent:1".into(),
3951 tool_name: "RunSubagent".into(),
3952 arguments: json!({"model":"codex/gpt-5.6-sol"}),
3953 invocation_id: Some("subagent-1".into()),
3954 },
3955 )
3956 .unwrap();
3957 journal
3958 .record(
3959 "t3",
3960 EventKind::Note {
3961 label: "subagent_started".into(),
3962 value: json!({"providerModel":"gpt-5.6-sol"}),
3963 },
3964 )
3965 .unwrap();
3966 journal
3967 .record(
3968 "t4",
3969 EventKind::ProviderReceipt {
3970 manifest_hash: "subagent".into(),
3971 input_tokens: None,
3972 output_tokens: None,
3973 context_bytes: None,
3974 raw_context_tokens: None,
3975 provider_data: json!({
3976 "source":"subagent",
3977 "usageIsDelta":true,
3978 "nonCachedInputTokens":20,
3979 "cachedInputTokens":0,
3980 "thinkingTokens":0,
3981 "outputTokens":1
3982 }),
3983 },
3984 )
3985 .unwrap();
3986 journal
3987 .record(
3988 "t5",
3989 EventKind::ToolInvoked {
3990 tool_instance: "AnnotateMedia:1".into(),
3991 tool_name: "AnnotateMedia".into(),
3992 arguments: json!({"model":"gemini-3.1-pro-preview"}),
3993 invocation_id: Some("media-1".into()),
3994 },
3995 )
3996 .unwrap();
3997 journal
3998 .record(
3999 "t6",
4000 EventKind::ProviderReceipt {
4001 manifest_hash: "media".into(),
4002 input_tokens: None,
4003 output_tokens: None,
4004 context_bytes: None,
4005 raw_context_tokens: None,
4006 provider_data: json!({
4007 "source":"media_annotation",
4008 "usageIsDelta":true,
4009 "nonCachedInputTokens":30,
4010 "cachedInputTokens":0,
4011 "thinkingTokens":0,
4012 "outputTokens":1
4013 }),
4014 },
4015 )
4016 .unwrap();
4017 journal
4018 .record(
4019 "t7",
4020 EventKind::ToolCompleted {
4021 tool_instance: "AnnotateMedia:1".into(),
4022 tool_name: "AnnotateMedia".into(),
4023 outcome: json!({"ok":true}),
4024 invocation_id: Some("media-1".into()),
4025 },
4026 )
4027 .unwrap();
4028 journal
4029 .record(
4030 "t8",
4031 EventKind::ToolCompleted {
4032 tool_instance: "RunSubagent:1".into(),
4033 tool_name: "RunSubagent".into(),
4034 outcome: json!({"ok":true}),
4035 invocation_id: Some("subagent-1".into()),
4036 },
4037 )
4038 .unwrap();
4039 journal
4040 .record(
4041 "t9",
4042 EventKind::ProviderReceipt {
4043 manifest_hash: "unknown".into(),
4044 input_tokens: None,
4045 output_tokens: None,
4046 context_bytes: None,
4047 raw_context_tokens: None,
4048 provider_data: json!({
4049 "source":"web_search",
4050 "usageIsDelta":true,
4051 "nonCachedInputTokens":40,
4052 "cachedInputTokens":0,
4053 "thinkingTokens":0,
4054 "outputTokens":1
4055 }),
4056 },
4057 )
4058 .unwrap();
4059 drop(journal);
4060
4061 let compatible = SessionJournal::open_with_metadata_and_provider_costs(
4062 &path,
4063 metadata(),
4064 Some("gpt-5.6-sol"),
4065 Some(compatibility_cost),
4066 )
4067 .unwrap();
4068 let status = &compatible.state().projection().status;
4069 assert_eq!(status.estimated_cost_usd_nanos, 4_060);
4070 assert_eq!(status.unpriced_provider_calls, 1);
4071 let inferred_models = compatible
4072 .state()
4073 .events
4074 .iter()
4075 .filter_map(|event| match &event.kind {
4076 EventKind::ProviderReceipt { provider_data, .. } => provider_data
4077 .get("providerModel")
4078 .and_then(Value::as_str)
4079 .map(str::to_owned),
4080 _ => None,
4081 })
4082 .collect::<Vec<_>>();
4083 assert_eq!(
4084 inferred_models,
4085 vec!["gpt-5.6-sol", "gpt-5.6-sol", "gemini-3.1-pro-preview"]
4086 );
4087 drop(compatible);
4088
4089 let unchanged = SessionJournal::open_with_metadata(&path, metadata()).unwrap();
4090 assert_eq!(
4091 unchanged
4092 .state()
4093 .projection()
4094 .status
4095 .estimated_cost_usd_nanos,
4096 0
4097 );
4098 assert_eq!(
4099 unchanged
4100 .state()
4101 .projection()
4102 .status
4103 .unpriced_provider_calls,
4104 4
4105 );
4106 let archive: Value = serde_json::from_slice(&unchanged.archive_bytes().unwrap()).unwrap();
4107 let archive_summary = legacy_provider_cost_summary_for_archive(
4108 &archive,
4109 Some("gpt-5.6-sol"),
4110 compatibility_cost,
4111 )
4112 .unwrap();
4113 assert_eq!(archive_summary.estimated_cost_usd_nanos, 4_060);
4114 assert_eq!(archive_summary.unpriced_provider_calls, 1);
4115 assert_eq!(archive["context"]["status"]["estimatedCostUsdNanos"], 0);
4116 assert_eq!(archive["context"]["status"]["unpricedProviderCalls"], 4);
4117 drop(unchanged);
4118 std::fs::remove_file(path).unwrap();
4119 }
4120
4121 #[test]
4122 fn legacy_provider_receipts_without_a_raw_context_anchor_remain_readable() {
4123 let receipt: EventKind = serde_json::from_value(json!({
4124 "type":"provider_receipt",
4125 "manifest_hash":"legacy-manifest",
4126 "input_tokens":123,
4127 "output_tokens":4,
4128 "provider_data":null
4129 }))
4130 .unwrap();
4131 assert!(matches!(
4132 receipt,
4133 EventKind::ProviderReceipt {
4134 context_bytes: None,
4135 raw_context_tokens: None,
4136 ..
4137 }
4138 ));
4139 }
4140
4141 #[test]
4142 fn stateful_tool_slots_are_batched_append_only_and_do_not_see_summaries() {
4143 let path = path("slots");
4144 let mut journal = SessionJournal::create(&path, metadata()).unwrap();
4145 journal
4146 .apply_tool_slots(
4147 "t1",
4148 "rust-1",
4149 vec![
4150 ToolSlotInput {
4151 slot: "a.rs".into(),
4152 name: "a.rs".into(),
4153 content: BoxContent::text("a"),
4154 retired: false,
4155 },
4156 ToolSlotInput {
4157 slot: "b.rs".into(),
4158 name: "b.rs".into(),
4159 content: BoxContent::text("b"),
4160 retired: false,
4161 },
4162 ],
4163 )
4164 .unwrap();
4165 let a = journal.state().tools["rust-1"].slots[0].box_id;
4166 journal.summarize_box("t2", a, "Kennedy summary").unwrap();
4167 let before = journal.state().clone();
4168 assert!(
4169 journal
4170 .apply_tool_slots(
4171 "t3",
4172 "rust-1",
4173 vec![ToolSlotInput {
4174 slot: "b.rs".into(),
4175 name: "b.rs".into(),
4176 content: BoxContent::text("b"),
4177 retired: false,
4178 }]
4179 )
4180 .is_err()
4181 );
4182 assert_eq!(journal.state(), &before);
4183 journal
4184 .apply_tool_slots(
4185 "t4",
4186 "rust-1",
4187 vec![
4188 ToolSlotInput {
4189 slot: "a.rs".into(),
4190 name: "a.rs".into(),
4191 content: BoxContent::text("a2"),
4192 retired: false,
4193 },
4194 ToolSlotInput {
4195 slot: "b.rs".into(),
4196 name: "b.rs".into(),
4197 content: BoxContent::text("b"),
4198 retired: true,
4199 },
4200 ToolSlotInput {
4201 slot: "c.rs".into(),
4202 name: "c.rs".into(),
4203 content: BoxContent::text("c"),
4204 retired: false,
4205 },
4206 ],
4207 )
4208 .unwrap();
4209 let a_state = journal.state().box_state(a).unwrap();
4210 assert_eq!(a_state.canonical.content.text, "a2");
4211 assert!(matches!(
4212 a_state.representation,
4213 Representation::Summarized { .. }
4214 ));
4215 drop(journal);
4216 let reopened = SessionJournal::open_with_metadata(&path, metadata()).unwrap();
4217 assert_eq!(reopened.state().tools["rust-1"].slots.len(), 3);
4218 assert!(reopened.state().tools["rust-1"].slots[1].retired);
4219 std::fs::remove_file(path).unwrap();
4220 }
4221
4222 #[test]
4223 fn tool_layout_orders_current_boxes_without_changing_their_identities() {
4224 let path = path("tool-layout");
4225 let mut journal = SessionJournal::create(&path, metadata()).unwrap();
4226 journal
4227 .apply_tool_slots_with_layout(
4228 "t1",
4229 "kweb",
4230 vec![
4231 ToolSlotInput {
4232 slot: "active".into(),
4233 name: "Active node".into(),
4234 content: BoxContent::text("ACTIVE NODE"),
4235 retired: false,
4236 },
4237 ToolSlotInput {
4238 slot: "direct".into(),
4239 name: "Direct node".into(),
4240 content: BoxContent::text("DIRECT NODE"),
4241 retired: false,
4242 },
4243 ],
4244 &["direct".into(), "active".into()],
4245 )
4246 .unwrap();
4247 let tool = &journal.state().tools["kweb"];
4248 let active_id = tool.slots[0].box_id;
4249 let direct_id = tool.slots[1].box_id;
4250 let projected_items = journal.state().projection().items;
4251 let rendered = projected_items
4252 .iter()
4253 .map(|item| item.text.as_str())
4254 .collect::<Vec<_>>()
4255 .join("\n\n");
4256 assert!(rendered.find("DIRECT NODE") < rendered.find("ACTIVE NODE"));
4257 assert_eq!(
4258 journal.state().tool_layouts["kweb"],
4259 vec![direct_id, active_id]
4260 );
4261 drop(journal);
4262 let reopened = SessionJournal::open_with_metadata(&path, metadata()).unwrap();
4263 assert_eq!(reopened.state().projection().items, projected_items);
4264 std::fs::remove_file(path).unwrap();
4265 }
4266
4267 #[test]
4268 fn ingress_attempt_reset_restores_the_initial_provider_visible_state() {
4269 let path = path("ingress-attempt-reset");
4270 let mut journal = SessionJournal::create(&path, metadata()).unwrap();
4271 let baseline_box = journal
4272 .create_box(
4273 "t1",
4274 "source",
4275 BoxOwner::User,
4276 BoxContent::text("original source"),
4277 )
4278 .unwrap();
4279 journal
4280 .record(
4281 "t2",
4282 EventKind::SourceTerminated {
4283 reason: "history_ingress".into(),
4284 },
4285 )
4286 .unwrap();
4287 journal
4288 .record("t3", EventKind::HistoryIngressStarted)
4289 .unwrap();
4290 let baseline = journal.state().projection().render();
4291
4292 journal
4293 .update_box(
4294 "t4",
4295 baseline_box,
4296 BoxContent::text("failed attempt update"),
4297 )
4298 .unwrap();
4299 journal
4300 .create_box(
4301 "t5",
4302 "failed attempt",
4303 BoxOwner::Kennedy,
4304 BoxContent::text("discard me"),
4305 )
4306 .unwrap();
4307 journal.reset_history_ingress_attempt("t6").unwrap();
4308
4309 assert_eq!(journal.state().projection().render(), baseline);
4310 assert!(journal.state().current_ingress_attempt_events().is_empty());
4311 drop(journal);
4312 let reopened = SessionJournal::open_with_metadata(&path, metadata()).unwrap();
4313 assert_eq!(reopened.state().projection().render(), baseline);
4314 std::fs::remove_file(path).unwrap();
4315 }
4316
4317 #[test]
4318 fn ingress_time_markers_are_sparse_resettable_and_system_bracketed() {
4319 let path = path("ingress-time-markers");
4320 let mut journal = SessionJournal::create(&path, metadata()).unwrap();
4321 journal
4322 .create_box(
4323 "t1",
4324 "source",
4325 BoxOwner::User,
4326 BoxContent::text("short source"),
4327 )
4328 .unwrap();
4329 journal
4330 .record(
4331 "t2",
4332 EventKind::SourceTerminated {
4333 reason: "history_ingress".into(),
4334 },
4335 )
4336 .unwrap();
4337 journal
4338 .record("t3", EventKind::HistoryIngressStarted)
4339 .unwrap();
4340
4341 journal
4342 .prepare_provider_projection_with_ingress_time("t4", &[], "m1", None, 2_700, false)
4343 .unwrap();
4344 let mut synchronized_after = journal.state().events.last().unwrap().id;
4345 let twenty = journal
4346 .prepare_provider_resume_markers("t5", synchronized_after, Some((1_200, false)))
4347 .unwrap();
4348 assert_eq!(twenty, ["[ingress time remaining: 1200 seconds]"]);
4349 synchronized_after = journal.state().events.last().unwrap().id;
4350 assert!(
4351 journal
4352 .prepare_provider_resume_markers("t6", synchronized_after, Some((1_199, false)),)
4353 .unwrap()
4354 .is_empty()
4355 );
4356 let ten = journal
4357 .prepare_provider_resume_markers("t7", synchronized_after, Some((600, false)))
4358 .unwrap();
4359 assert_eq!(
4360 ten,
4361 [
4362 "[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.]"
4363 ],
4364 );
4365 let markers = journal
4366 .state()
4367 .projection()
4368 .items
4369 .into_iter()
4370 .filter(|item| item.text.contains("ingress time remaining"))
4371 .map(|item| item.text)
4372 .collect::<Vec<_>>();
4373 assert_eq!(markers.len(), 3);
4374 assert_eq!(markers[0], "[ingress time remaining: 2700 seconds]");
4375 assert_eq!(markers[1], "[ingress time remaining: 1200 seconds]");
4376 assert_eq!(
4377 markers[2],
4378 "[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.]"
4379 );
4380 assert!(should_append_ingress_time_marker(
4381 IngressTimeMarkerState {
4382 any: true,
4383 at_or_below_thirty_minutes: true,
4384 at_or_below_ten_minutes: true,
4385 },
4386 500,
4387 true,
4388 ));
4389
4390 journal.reset_history_ingress_attempt("t8").unwrap();
4391 let retry = journal
4392 .prepare_provider_projection_with_ingress_time("t9", &[], "m1", None, 2_700, true)
4393 .unwrap();
4394 assert!(retry.projection.render().contains(
4395 "[Previous attempt ran out of time; please budget your ingress time carefully. Ingress time remaining: 2700 seconds]"
4396 ));
4397 assert!(
4398 !retry
4399 .projection
4400 .render()
4401 .contains("[ingress time remaining: 600 seconds. If you do not call EndSession")
4402 );
4403 std::fs::remove_file(path).unwrap();
4404 }
4405
4406 #[test]
4407 fn limits_use_exact_floor_percentages() {
4408 let state = Chatend::opened(SessionMetadata {
4409 effective_context_tokens: 101,
4410 ..metadata()
4411 });
4412 assert_eq!(state.live_context_limit(), 70);
4413 assert_eq!(state.forced_ingress_context_limit(), 75);
4414 assert_eq!(state.ingress_initial_context_limit(), 75);
4415 assert_eq!(state.ingress_context_limit(), 90);
4416 assert_eq!(state.active_context_limit(), 70);
4417 for kind in [
4418 SessionKind::SelfTime,
4419 SessionKind::HistoryIngress,
4420 SessionKind::AudioIngress,
4421 ] {
4422 let writer = Chatend::opened(SessionMetadata {
4423 kind,
4424 effective_context_tokens: 101,
4425 ..metadata()
4426 });
4427 assert_eq!(writer.active_context_limit(), 90);
4428 }
4429 for kind in [SessionKind::Telegram, SessionKind::Other("test".into())] {
4430 let ordinary = Chatend::opened(SessionMetadata {
4431 kind,
4432 effective_context_tokens: 101,
4433 ..metadata()
4434 });
4435 assert_eq!(ordinary.active_context_limit(), 70);
4436 }
4437 assert_eq!(estimate_tokens("1234"), 1);
4438 }
4439
4440 #[test]
4441 fn new_box_projection_preview_matches_the_committed_projection() {
4442 let path = path("new-box-preview");
4443 let mut journal = SessionJournal::create(&path, metadata()).unwrap();
4444 let boxes = vec![
4445 (
4446 "User message".into(),
4447 BoxOwner::User,
4448 BoxContent::text("prospective user text"),
4449 ),
4450 (
4451 "attachment".into(),
4452 BoxOwner::User,
4453 BoxContent::text("prospective attachment text"),
4454 ),
4455 ];
4456 let preview = journal
4457 .state()
4458 .projection_with_new_boxes_at("t1", &boxes)
4459 .unwrap();
4460 for (name, owner, content) in boxes {
4461 journal.create_box("t1", name, owner, content).unwrap();
4462 }
4463 assert_eq!(journal.state().projection(), preview);
4464 std::fs::remove_file(path).unwrap();
4465 }
4466
4467 #[test]
4468 fn box_representation_preview_matches_the_batched_append() {
4469 let path = path("representation-plan");
4470 let mut journal = SessionJournal::create(&path, metadata()).unwrap();
4471 let hydrated = journal
4472 .create_box(
4473 "t1",
4474 "large result",
4475 BoxOwner::Controller,
4476 BoxContent::text("x".repeat(3_000)),
4477 )
4478 .unwrap();
4479 let summarized = journal
4480 .create_box(
4481 "t2",
4482 "Kennedy summary",
4483 BoxOwner::Kennedy,
4484 BoxContent::text("y".repeat(3_000)),
4485 )
4486 .unwrap();
4487 journal
4488 .summarize_box("t3", summarized, "important points")
4489 .unwrap();
4490 let desired = BTreeMap::from([
4491 (hydrated, BoxRepresentation::Dehydrated),
4492 (
4493 summarized,
4494 BoxRepresentation::Summarized("important points".into()),
4495 ),
4496 ]);
4497 let preview = journal
4498 .state()
4499 .projection_with_box_representations(&desired)
4500 .unwrap();
4501 let next_id = journal.state().next_id;
4502 let ids = journal.apply_box_representations("t4", &desired).unwrap();
4503 assert_eq!(ids, vec![EventId(next_id)]);
4504 assert_eq!(journal.state().projection(), preview);
4505 assert!(matches!(
4506 journal.state().box_state(hydrated).unwrap().representation,
4507 Representation::Dehydrated { .. }
4508 ));
4509 assert_eq!(
4510 journal
4511 .state()
4512 .box_state(summarized)
4513 .unwrap()
4514 .representation,
4515 Representation::Summarized {
4516 based_on: EventId(2),
4517 text: "important points".into(),
4518 }
4519 );
4520 std::fs::remove_file(path).unwrap();
4521 }
4522}