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