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