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