1#![forbid(unsafe_code)]
2
3use std::collections::{BTreeMap, HashMap, HashSet};
4
5pub use kcode_kweb_context_node::{
6 Connection, Error, Node, NodeDraft, Result, StagedCreate, format_node,
7};
8use kcode_kweb_db::NodeId;
9use kcode_session_history::{
10 Session as HistorySession,
11 chatend::{BoxContent, BoxId, BoxRepresentation, BoxState, EventId, PendingId, ToolSlotInput},
12};
13use serde::Serialize;
14use serde_json::{Value, json};
15use sha2::{Digest, Sha256};
16
17const KWEB_TOOL_INSTANCE: &str = "kweb";
18const CONNECTION_SUMMARIES_PER_BOX: usize = 8;
19const CONNECTION_SUMMARIES_LOGICAL_SLOT: &str = "connection-summaries";
20const CONNECTION_SUMMARY_IDS_METADATA: &str = "kwebConnectionSummaryIds";
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23enum BoxKind {
24 Loaded,
25 Fixed,
26 Staged,
27 Connections,
28}
29
30impl BoxKind {
31 pub const fn name(self) -> &'static str {
32 match self {
33 Self::Loaded => "Kweb loaded node",
34 Self::Fixed => "Kweb fixed connection",
35 Self::Staged => "Kweb staged node",
36 Self::Connections => "Kweb connection map markers",
37 }
38 }
39
40 pub const fn metadata_name(self) -> &'static str {
41 match self {
42 Self::Loaded => "loaded",
43 Self::Fixed => "fixed",
44 Self::Staged => "staged",
45 Self::Connections => "connection-summary",
46 }
47 }
48}
49
50#[derive(Clone, Debug, Eq, PartialEq)]
51struct BoxSpec {
52 logical_slot: String,
53 kind: BoxKind,
54 text: String,
55 stored_node: Option<Node>,
56 staged_node: Option<NodeDraft>,
57}
58
59#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
60#[serde(rename_all = "camelCase")]
61pub struct LoadReport {
62 pub requested_id: String,
63 pub newly_loaded: bool,
64 pub promoted_from_fixed: bool,
65 pub new_fixed_ids: Vec<String>,
66}
67
68#[derive(Clone, Debug, Eq, PartialEq)]
70pub struct ProjectionItem {
71 pub key: String,
73 pub name: String,
75 pub text: String,
77}
78
79#[derive(Clone, Debug)]
80pub struct Context {
81 root_node_ids: Vec<String>,
82 load_fixed_connections: bool,
83 loaded_node_ids: Vec<String>,
84 fixed_node_ids: Vec<String>,
85 nodes_by_id: BTreeMap<String, Node>,
86}
87
88impl Context {
89 pub fn new(root_node_ids: Vec<String>) -> Result<Self> {
90 Self::with_fixed_connections(root_node_ids, false)
91 }
92
93 pub fn with_fixed_connections(
95 root_node_ids: Vec<String>,
96 load_fixed_connections: bool,
97 ) -> Result<Self> {
98 if root_node_ids.is_empty() {
99 return Err(Error::new("Kweb context requires at least one root node"));
100 }
101 let mut seen = HashSet::new();
102 for id in &root_node_ids {
103 canonical_node_id(id)?;
104 if !seen.insert(id.clone()) {
105 return Err(Error::new("Kweb root node IDs must be distinct"));
106 }
107 }
108 Ok(Self {
109 root_node_ids,
110 load_fixed_connections,
111 loaded_node_ids: Vec::new(),
112 fixed_node_ids: Vec::new(),
113 nodes_by_id: BTreeMap::new(),
114 })
115 }
116
117 pub fn root_node_ids(&self) -> &[String] {
118 &self.root_node_ids
119 }
120
121 pub fn loaded_node_ids(&self) -> &[String] {
122 &self.loaded_node_ids
123 }
124
125 pub fn fixed_node_ids(&self) -> &[String] {
126 &self.fixed_node_ids
127 }
128
129 pub const fn loads_fixed_connections(&self) -> bool {
130 self.load_fixed_connections
131 }
132
133 pub fn full_node_ids(&self) -> Vec<&str> {
134 self.loaded_node_ids
135 .iter()
136 .chain(&self.fixed_node_ids)
137 .map(String::as_str)
138 .collect()
139 }
140
141 pub fn contains_full_node(&self, id: &str) -> bool {
142 self.loaded_node_ids.iter().any(|candidate| candidate == id)
143 || self.fixed_node_ids.iter().any(|candidate| candidate == id)
144 }
145
146 pub fn node(&self, id: &str) -> Option<&Node> {
147 self.nodes_by_id.get(id)
148 }
149
150 pub fn apply_load(&mut self, requested: Node, fixed: Vec<Node>) -> Result<LoadReport> {
151 let requested_id = requested.id.clone();
152 let was_loaded = self
153 .loaded_node_ids
154 .iter()
155 .any(|candidate| candidate == &requested_id);
156 let was_fixed = self
157 .fixed_node_ids
158 .iter()
159 .any(|candidate| candidate == &requested_id);
160 let previous_full = self
161 .full_node_ids()
162 .into_iter()
163 .map(str::to_owned)
164 .collect::<HashSet<_>>();
165 let expected_fixed = requested
166 .fixed_connections
167 .iter()
168 .map(|connection| connection.id.as_str())
169 .filter(|id| *id != requested_id)
170 .collect::<HashSet<_>>();
171 let provided_fixed = fixed
172 .iter()
173 .map(|node| node.id.as_str())
174 .collect::<HashSet<_>>();
175 if self.load_fixed_connections && expected_fixed != provided_fixed {
176 return Err(Error::new(format!(
177 "load for {requested_id} did not provide exactly its fixed connections"
178 )));
179 }
180 if !self.load_fixed_connections && !fixed.is_empty() {
181 return Err(Error::new(format!(
182 "load for {requested_id} provided full fixed connections while compatibility mode is disabled"
183 )));
184 }
185 self.nodes_by_id.insert(requested_id.clone(), requested);
186 if self.load_fixed_connections {
187 for node in fixed {
188 self.nodes_by_id.insert(node.id.clone(), node);
189 }
190 }
191 if !was_loaded {
192 self.loaded_node_ids.push(requested_id.clone());
193 }
194 self.rebuild_fixed();
195 let new_fixed_ids = self
196 .fixed_node_ids
197 .iter()
198 .filter(|id| !previous_full.contains(*id))
199 .cloned()
200 .collect();
201 Ok(LoadReport {
202 requested_id,
203 newly_loaded: !was_loaded && !was_fixed,
204 promoted_from_fixed: !was_loaded && was_fixed,
205 new_fixed_ids,
206 })
207 }
208
209 pub fn refresh(&mut self, nodes: impl IntoIterator<Item = Node>) -> Result<()> {
210 for node in nodes {
211 if !self.contains_full_node(&node.id) {
212 return Err(Error::new(format!(
213 "cannot refresh unloaded Kweb node {}",
214 node.id
215 )));
216 }
217 self.nodes_by_id.insert(node.id.clone(), node);
218 }
219 self.rebuild_fixed();
220 Ok(())
221 }
222
223 pub fn restore(
224 &mut self,
225 nodes: impl IntoIterator<Item = Node>,
226 directly_loaded: Vec<String>,
227 ) -> Result<()> {
228 self.nodes_by_id.clear();
229 for node in nodes {
230 self.nodes_by_id.insert(node.id.clone(), node);
231 }
232 let mut seen = HashSet::new();
233 self.loaded_node_ids = directly_loaded
234 .into_iter()
235 .filter(|id| self.nodes_by_id.contains_key(id) && seen.insert(id.clone()))
236 .collect();
237 if !self.nodes_by_id.is_empty() && self.loaded_node_ids.is_empty() {
238 return Err(Error::new(
239 "restored Kweb context contains nodes but no loaded node",
240 ));
241 }
242 self.rebuild_fixed();
243 Ok(())
244 }
245
246 fn box_specs(
247 &self,
248 updates: &BTreeMap<String, NodeDraft>,
249 creates: &[StagedCreate],
250 ) -> Result<Vec<BoxSpec>> {
251 let mut specs = self.node_box_specs(updates, creates)?;
252 let connections = self.projected_connection_summaries(updates, creates)?;
253 specs.push(BoxSpec {
254 logical_slot: CONNECTION_SUMMARIES_LOGICAL_SLOT.into(),
255 kind: BoxKind::Connections,
256 text: format_connection_summary_entries(&connections),
257 stored_node: None,
258 staged_node: None,
259 });
260 Ok(specs)
261 }
262
263 fn node_box_specs(
264 &self,
265 updates: &BTreeMap<String, NodeDraft>,
266 creates: &[StagedCreate],
267 ) -> Result<Vec<BoxSpec>> {
268 for id in updates.keys() {
269 if !self.contains_full_node(id) {
270 return Err(Error::new(format!(
271 "staged update targets unloaded Kweb node {id}"
272 )));
273 }
274 }
275 let mut specs = Vec::new();
276 for id in &self.loaded_node_ids {
277 specs.push(self.full_box(id, BoxKind::Loaded, updates.get(id))?);
278 }
279 for id in &self.fixed_node_ids {
280 specs.push(self.full_box(id, BoxKind::Fixed, updates.get(id))?);
281 }
282 for create in creates {
283 specs.push(BoxSpec {
284 logical_slot: create.pending_id.clone(),
285 kind: BoxKind::Staged,
286 text: format_node(&create.pending_id, &create.data),
287 stored_node: None,
288 staged_node: Some(create.data.clone()),
289 });
290 }
291 Ok(specs)
292 }
293
294 pub fn projection(
300 &self,
301 updates: &BTreeMap<String, NodeDraft>,
302 creates: &[StagedCreate],
303 ) -> Result<Vec<ProjectionItem>> {
304 let mut projected = self
305 .node_box_specs(updates, creates)?
306 .into_iter()
307 .map(|spec| {
308 Ok(ProjectionItem {
309 key: spec.logical_slot,
310 name: spec.kind.name().to_owned(),
311 text: spec.text,
312 })
313 })
314 .collect::<Result<Vec<_>>>()?;
315 projected.extend(
316 self.projected_connection_summaries(updates, creates)?
317 .into_iter()
318 .map(|entry| ProjectionItem {
319 key: format!("{CONNECTION_SUMMARIES_LOGICAL_SLOT}:{}", entry.id),
320 name: "Kweb connection map marker".into(),
321 text: entry.text,
322 }),
323 );
324 Ok(projected)
325 }
326
327 pub fn sync_chatend(
333 &self,
334 journal: &mut HistorySession,
335 recorded_at: impl Into<String>,
336 updates: &BTreeMap<String, NodeDraft>,
337 creates: &[StagedCreate],
338 ) -> Result<Vec<BoxId>> {
339 let recorded_at = recorded_at.into();
340 let previous = kweb_box_versions(journal);
341 let (mut desired, connections) = desired_kweb_boxes(self.box_specs(updates, creates)?)?;
342 desired.extend(desired_connection_summary_boxes(journal, connections)?);
343 reconcile_kweb_slots(journal, &recorded_at, desired)?;
344 let representations = changed_existing_kweb_box_ids(journal, &previous)
345 .into_iter()
346 .map(|box_id| (box_id, BoxRepresentation::Hydrated))
347 .collect::<BTreeMap<_, _>>();
348 if !representations.is_empty() {
349 journal
350 .apply_box_representations(&recorded_at, &representations)
351 .map_err(|error| {
352 Error::new(format!("hydrating changed existing Kweb boxes: {error}"))
353 })?;
354 }
355 Ok(changed_kweb_box_ids(journal, &previous))
356 }
357
358 pub fn sync_load_chatend(
367 &self,
368 journal: &mut HistorySession,
369 recorded_at: impl Into<String>,
370 updates: &BTreeMap<String, NodeDraft>,
371 creates: &[StagedCreate],
372 ) -> Result<Vec<BoxId>> {
373 let recorded_at = recorded_at.into();
374 let (desired_nodes, connections) = desired_kweb_boxes(self.box_specs(updates, creates)?)?;
375 let plan = plan_cache_safe_kweb_slots(journal, desired_nodes, connections)?;
376 journal
377 .apply_tool_slots(&recorded_at, KWEB_TOOL_INSTANCE, plan.slots)
378 .map_err(|error| Error::new(format!("applying cache-safe Kweb projection: {error}")))?;
379 Ok(plan.advanced_existing)
380 }
381
382 fn full_box(&self, id: &str, kind: BoxKind, update: Option<&NodeDraft>) -> Result<BoxSpec> {
383 let node = self
384 .nodes_by_id
385 .get(id)
386 .ok_or_else(|| Error::new(format!("missing full Kweb node {id}")))?;
387 let data = update.cloned().unwrap_or_else(|| node.draft());
388 Ok(BoxSpec {
389 logical_slot: id.to_owned(),
390 kind,
391 text: format_node(id, &data),
392 stored_node: Some(node.clone()),
393 staged_node: update.cloned(),
394 })
395 }
396
397 fn projected_connection_summaries(
398 &self,
399 updates: &BTreeMap<String, NodeDraft>,
400 creates: &[StagedCreate],
401 ) -> Result<Vec<ConnectionSummaryEntry>> {
402 let creates_by_id = creates
403 .iter()
404 .map(|create| (create.pending_id.as_str(), &create.data))
405 .collect::<HashMap<_, _>>();
406 let mut summaries = HashMap::new();
407 for node in self.nodes_by_id.values() {
408 summaries.insert(
409 node.id.as_str(),
410 (node.short_name.as_str(), node.short_description.as_str()),
411 );
412 for connection in node
413 .fixed_connections
414 .iter()
415 .chain(&node.recent_connections)
416 {
417 summaries.entry(connection.id.as_str()).or_insert((
418 connection.short_name.as_str(),
419 connection.short_description.as_str(),
420 ));
421 }
422 }
423 let mut connection_ids = Vec::new();
424 let mut seen = HashSet::new();
425 for id in self.full_node_ids() {
426 let node = self
427 .nodes_by_id
428 .get(id)
429 .ok_or_else(|| Error::new(format!("missing full Kweb node {id}")))?;
430 if let Some(draft) = updates.get(id) {
431 for connection_id in draft
432 .fixed_connections
433 .iter()
434 .chain(&draft.recent_connections)
435 {
436 if seen.insert(connection_id.clone()) {
437 connection_ids.push(connection_id.clone());
438 }
439 }
440 } else {
441 for connection in node
442 .fixed_connections
443 .iter()
444 .chain(&node.recent_connections)
445 {
446 if seen.insert(connection.id.clone()) {
447 connection_ids.push(connection.id.clone());
448 }
449 }
450 }
451 }
452 for create in creates {
453 for connection_id in create
454 .data
455 .fixed_connections
456 .iter()
457 .chain(&create.data.recent_connections)
458 {
459 if seen.insert(connection_id.clone()) {
460 connection_ids.push(connection_id.clone());
461 }
462 }
463 }
464 let mut entries = Vec::with_capacity(connection_ids.len());
465 for id in connection_ids {
466 let staged_summary = updates
467 .get(&id)
468 .or_else(|| creates_by_id.get(id.as_str()).copied())
469 .map(|node| (node.short_name.as_str(), node.short_description.as_str()));
470 let (name, description) = staged_summary
471 .or_else(|| summaries.get(id.as_str()).copied())
472 .ok_or_else(|| {
473 Error::new(format!(
474 "connection map marker {id} must resolve to a nonempty node name and map marker"
475 ))
476 })?;
477 if name.trim().is_empty() || description.trim().is_empty() {
478 return Err(Error::new(format!(
479 "connection map marker {id} must resolve to a nonempty node name and map marker"
480 )));
481 }
482 let text = format!("{id} · {name}: {description}");
483 entries.push(ConnectionSummaryEntry { id, text });
484 }
485 Ok(entries)
486 }
487
488 fn rebuild_fixed(&mut self) {
489 let loaded = self.loaded_node_ids.iter().cloned().collect::<HashSet<_>>();
490 if !self.load_fixed_connections {
491 self.fixed_node_ids.clear();
492 self.nodes_by_id.retain(|id, _| loaded.contains(id));
493 return;
494 }
495 let mut seen = loaded.clone();
496 let mut fixed = Vec::new();
497 for id in &self.loaded_node_ids {
498 let Some(node) = self.nodes_by_id.get(id) else {
499 continue;
500 };
501 for connection in &node.fixed_connections {
502 if self.nodes_by_id.contains_key(&connection.id)
503 && seen.insert(connection.id.clone())
504 {
505 fixed.push(connection.id.clone());
506 }
507 }
508 }
509 self.fixed_node_ids = fixed;
510 self.nodes_by_id
511 .retain(|id, _| loaded.contains(id) || seen.contains(id));
512 }
513}
514
515struct DesiredKwebBox {
516 logical_slot: String,
517 name: String,
518 content: BoxContent,
519}
520
521#[derive(Clone, Debug, Eq, PartialEq)]
522struct ConnectionSummaryEntry {
523 id: String,
524 text: String,
525}
526
527fn desired_kweb_boxes(specs: Vec<BoxSpec>) -> Result<(Vec<DesiredKwebBox>, DesiredKwebBox)> {
528 let mut desired = Vec::with_capacity(specs.len());
529 let mut connections = None;
530 for spec in specs {
531 let kind = spec.kind;
532 let entry = desired_kweb_box(spec)?;
533 if kind == BoxKind::Connections {
534 if connections.replace(entry).is_some() {
535 return Err(Error::new(
536 "Kweb context produced more than one connection-summary candidate",
537 ));
538 }
539 } else {
540 desired.push(entry);
541 }
542 }
543 let connections = connections
544 .ok_or_else(|| Error::new("Kweb context produced no connection-summary candidate"))?;
545 Ok((desired, connections))
546}
547
548fn desired_kweb_box(spec: BoxSpec) -> Result<DesiredKwebBox> {
549 let mut metadata = json!({
550 "revisionHash": revision_hash(&spec.text),
551 });
552 if let Some(node) = spec.stored_node {
553 metadata["canonicalNodeId"] = json!(node.id);
554 metadata["storedNode"] = serde_json::to_value(node)
555 .map_err(|error| Error::new(format!("serializing stored Kweb node: {error}")))?;
556 }
557 if let Some(node) = spec.staged_node {
558 metadata["staged"] = json!(true);
559 metadata["nodeData"] = serde_json::to_value(node)
560 .map_err(|error| Error::new(format!("serializing staged Kweb node: {error}")))?;
561 }
562 let mut content = BoxContent {
563 text: spec.text,
564 objects: Vec::new(),
565 metadata,
566 };
567 content.use_concise_header();
568 mark_kweb_content(&mut content, &spec.logical_slot, spec.kind.metadata_name());
569 Ok(DesiredKwebBox {
570 logical_slot: spec.logical_slot,
571 name: spec.kind.name().into(),
572 content,
573 })
574}
575
576fn mark_kweb_content(content: &mut BoxContent, logical_slot: &str, role: &str) {
577 if !content.metadata.is_object() {
578 content.metadata = json!({});
579 }
580 content.metadata["kwebLogicalSlot"] = json!(logical_slot);
581 content.metadata["kwebRole"] = json!(role);
582}
583
584fn kweb_logical_slot(state: &BoxState, actual_slot: &str) -> String {
585 state
586 .canonical
587 .content
588 .metadata
589 .get("kwebLogicalSlot")
590 .and_then(Value::as_str)
591 .unwrap_or(actual_slot)
592 .to_owned()
593}
594
595fn kweb_role(content: &BoxContent) -> Option<&str> {
596 content.metadata.get("kwebRole").and_then(Value::as_str)
597}
598
599fn is_full_node_role(role: Option<&str>) -> bool {
600 matches!(role, Some("loaded" | "fixed" | "staged"))
601}
602
603fn connection_summary_heading(content: &BoxContent) -> Result<&'static str> {
604 ["Connection map markers", "Connection summaries"]
605 .into_iter()
606 .find(|heading| {
607 content
608 .text
609 .strip_prefix(heading)
610 .is_some_and(|body| body.is_empty() || body.starts_with('\n'))
611 })
612 .ok_or_else(|| Error::new("Kweb connection-summary box has an invalid heading"))
613}
614
615fn connection_summary_entries(content: &BoxContent) -> Result<Vec<ConnectionSummaryEntry>> {
616 let heading = connection_summary_heading(content)?;
617 let body = content
618 .text
619 .strip_prefix(heading)
620 .expect("validated connection-summary heading");
621 let body = body
622 .strip_prefix('\n')
623 .ok_or_else(|| Error::new("Kweb connection-summary box has no body"))?;
624 if body == "None." {
625 return Ok(Vec::new());
626 }
627
628 let mut entries: Vec<ConnectionSummaryEntry> = Vec::new();
629 for line in body.split('\n') {
630 let identifier = line.split_once(" · ").and_then(|(identifier, _)| {
631 let canonical = identifier.parse::<NodeId>().is_ok();
632 let pending = PendingId::parse(identifier.to_owned()).is_ok();
633 (canonical || pending).then_some(identifier)
634 });
635 if let Some(identifier) = identifier {
636 entries.push(ConnectionSummaryEntry {
637 id: identifier.to_owned(),
638 text: line.to_owned(),
639 });
640 } else {
641 let entry = entries.last_mut().ok_or_else(|| {
642 Error::new("Kweb connection-summary box starts with invalid entry text")
643 })?;
644 entry.text.push('\n');
645 entry.text.push_str(line);
646 }
647 }
648
649 if let Some(expected) = content
650 .metadata
651 .get(CONNECTION_SUMMARY_IDS_METADATA)
652 .and_then(Value::as_array)
653 {
654 let expected = expected
655 .iter()
656 .map(|value| {
657 value.as_str().ok_or_else(|| {
658 Error::new("Kweb connection-summary IDs metadata contains a non-string value")
659 })
660 })
661 .collect::<Result<Vec<_>>>()?;
662 if expected
663 != entries
664 .iter()
665 .map(|entry| entry.id.as_str())
666 .collect::<Vec<_>>()
667 {
668 return Err(Error::new(
669 "Kweb connection-summary IDs metadata does not match its canonical text",
670 ));
671 }
672 }
673 Ok(entries)
674}
675
676fn format_connection_summary_entries(entries: &[ConnectionSummaryEntry]) -> String {
677 format_connection_summary_entries_with_heading("Connection map markers", entries)
678}
679
680fn format_connection_summary_entries_with_heading(
681 heading: &str,
682 entries: &[ConnectionSummaryEntry],
683) -> String {
684 if entries.is_empty() {
685 return format!("{heading}\nNone.");
686 }
687 format!(
688 "{heading}\n{}",
689 entries
690 .iter()
691 .map(|entry| entry.text.as_str())
692 .collect::<Vec<_>>()
693 .join("\n")
694 )
695}
696
697fn revision_hash(text: &str) -> String {
698 hex::encode(Sha256::digest(text.as_bytes()))
699}
700
701fn update_connection_summary_content(
702 content: &mut BoxContent,
703 logical_slot: &str,
704 entries: &[ConnectionSummaryEntry],
705) {
706 content.text = format_connection_summary_entries(entries);
707 mark_kweb_content(content, logical_slot, "connection-summary");
708 content.metadata["revisionHash"] = json!(revision_hash(&content.text));
709 content.metadata[CONNECTION_SUMMARY_IDS_METADATA] = json!(
710 entries
711 .iter()
712 .map(|entry| entry.id.as_str())
713 .collect::<Vec<_>>()
714 );
715}
716
717fn desired_connection_summary_boxes(
718 journal: &HistorySession,
719 fresh: DesiredKwebBox,
720) -> Result<Vec<DesiredKwebBox>> {
721 let mut boxes = Vec::new();
722 let mut seen = HashSet::new();
723 let mut used_logical_slots = HashSet::new();
724 let fresh_entries = connection_summary_entries(&fresh.content)?;
725 let fresh_by_id = fresh_entries
726 .iter()
727 .map(|entry| (entry.id.as_str(), entry.text.as_str()))
728 .collect::<HashMap<_, _>>();
729
730 if let Some(tool) = journal.state().tools.get(KWEB_TOOL_INSTANCE) {
731 for slot in &tool.slots {
732 let state = journal
733 .state()
734 .box_state(slot.box_id)
735 .ok_or_else(|| Error::new("Kweb tool slot box is missing"))?;
736 let logical_slot = kweb_logical_slot(state, &slot.slot);
737 used_logical_slots.insert(logical_slot.clone());
738 if slot.retired
739 || state
740 .canonical
741 .content
742 .metadata
743 .get("kwebRole")
744 .and_then(Value::as_str)
745 != Some("connection-summary")
746 {
747 continue;
748 }
749 let mut entries = connection_summary_entries(&state.canonical.content)?;
750 for entry in &mut entries {
751 if let Some(text) = fresh_by_id.get(entry.id.as_str()) {
752 entry.text = (*text).to_owned();
753 }
754 }
755 for entry in &entries {
756 seen.insert(entry.id.clone());
757 }
758 let mut content = state.canonical.content.clone();
759 update_connection_summary_content(&mut content, &logical_slot, &entries);
760 boxes.push(DesiredKwebBox {
761 logical_slot,
762 name: fresh.name.clone(),
763 content,
764 });
765 }
766 }
767
768 let additions = fresh_entries
769 .iter()
770 .filter(|entry| seen.insert(entry.id.clone()))
771 .cloned()
772 .collect::<Vec<_>>();
773 let mut next_addition = 0;
774
775 while next_addition < additions.len() || boxes.is_empty() {
776 let end = (next_addition + CONNECTION_SUMMARIES_PER_BOX).min(additions.len());
777 let entries = additions[next_addition..end].to_vec();
778 let mut sequence = boxes.len() + 1;
779 let logical_slot = loop {
780 let candidate = if sequence == 1 {
781 CONNECTION_SUMMARIES_LOGICAL_SLOT.to_owned()
782 } else {
783 format!("{CONNECTION_SUMMARIES_LOGICAL_SLOT}:{sequence}")
784 };
785 if used_logical_slots.insert(candidate.clone()) {
786 break candidate;
787 }
788 sequence += 1;
789 };
790 let mut content = fresh.content.clone();
791 update_connection_summary_content(&mut content, &logical_slot, &entries);
792 boxes.push(DesiredKwebBox {
793 logical_slot,
794 name: fresh.name.clone(),
795 content,
796 });
797 next_addition = end;
798 }
799
800 Ok(boxes)
801}
802
803struct ExistingKwebSlot {
804 box_id: BoxId,
805 actual_slot: String,
806 logical_slot: String,
807 retired: bool,
808 active: bool,
809 name: String,
810 content: BoxContent,
811}
812
813struct ExistingConnectionBox {
814 heading: &'static str,
815 entries: Vec<ConnectionSummaryEntry>,
816}
817
818struct CacheSafeLoadPlan {
819 slots: Vec<ToolSlotInput>,
820 advanced_existing: Vec<BoxId>,
821}
822
823fn plan_cache_safe_kweb_slots(
824 journal: &HistorySession,
825 desired_nodes: Vec<DesiredKwebBox>,
826 fresh_connections: DesiredKwebBox,
827) -> Result<CacheSafeLoadPlan> {
828 let current = journal
829 .state()
830 .tools
831 .get(KWEB_TOOL_INSTANCE)
832 .cloned()
833 .unwrap_or_default();
834 let mut existing = Vec::with_capacity(current.slots.len());
835 for slot in ¤t.slots {
836 let state = journal
837 .state()
838 .box_state(slot.box_id)
839 .ok_or_else(|| Error::new("Kweb tool slot box is missing"))?;
840 existing.push(ExistingKwebSlot {
841 box_id: slot.box_id,
842 actual_slot: slot.slot.clone(),
843 logical_slot: kweb_logical_slot(state, &slot.slot),
844 retired: slot.retired,
845 active: state.active,
846 name: state.name.clone(),
847 content: state.canonical.content.clone(),
848 });
849 }
850
851 let mut desired_by_logical = BTreeMap::new();
852 for (index, desired) in desired_nodes.iter().enumerate() {
853 if desired_by_logical
854 .insert(desired.logical_slot.clone(), index)
855 .is_some()
856 {
857 return Err(Error::new(
858 "Kweb box layout contains duplicate logical slots",
859 ));
860 }
861 }
862
863 let fresh_entries = connection_summary_entries(&fresh_connections.content)?;
864 let mut fresh_by_id = HashMap::new();
865 for entry in &fresh_entries {
866 if fresh_by_id
867 .insert(entry.id.clone(), entry.text.clone())
868 .is_some()
869 {
870 return Err(Error::new(
871 "Kweb connection projection contains duplicate IDs",
872 ));
873 }
874 }
875
876 let mut connection_boxes = BTreeMap::new();
877 let mut represented_connection_ids = HashSet::new();
878 let mut duplicate_connection_ids = HashSet::new();
879 for (index, slot) in existing.iter().enumerate() {
880 if kweb_role(&slot.content) != Some("connection-summary") {
881 continue;
882 }
883 let heading = connection_summary_heading(&slot.content)?;
884 let entries = connection_summary_entries(&slot.content)?;
885 for entry in &entries {
886 if !represented_connection_ids.insert(entry.id.clone()) {
887 duplicate_connection_ids.insert(entry.id.clone());
888 }
889 }
890 connection_boxes.insert(index, ExistingConnectionBox { heading, entries });
891 }
892
893 let mut slots = existing
894 .iter()
895 .map(|slot| ToolSlotInput {
896 slot: slot.actual_slot.clone(),
897 name: slot.name.clone(),
898 content: slot.content.clone(),
899 retired: slot.retired,
900 })
901 .collect::<Vec<_>>();
902 let mut represented_full_nodes = HashSet::new();
903 let mut selected_full_nodes = HashSet::new();
904 let mut advanced_existing = Vec::new();
905
906 for (index, old) in existing.iter().enumerate() {
907 let mut advanced = false;
908 if is_full_node_role(kweb_role(&old.content)) {
909 represented_full_nodes.insert(old.logical_slot.clone());
910 if old.active
911 && !old.retired
912 && desired_by_logical.contains_key(&old.logical_slot)
913 && selected_full_nodes.insert(old.logical_slot.clone())
914 {
915 let desired = &desired_nodes[desired_by_logical[&old.logical_slot]];
916 let mut content = desired.content.clone();
917 preserve_metadata_key(&mut content, &old.content, "kwebLogicalSlot");
918 preserve_metadata_key(&mut content, &old.content, "kwebRole");
919 if content != old.content {
920 slots[index].content = content;
921 advanced = true;
922 }
923 }
924 }
925
926 if let Some(connection_box) = connection_boxes.get(&index) {
927 let mut contains_duplicate = false;
928 for entry in &connection_box.entries {
929 if duplicate_connection_ids.contains(&entry.id) {
930 contains_duplicate = true;
931 }
932 }
933 if old.active && !old.retired && !contains_duplicate {
934 let mut entries = connection_box.entries.clone();
935 let mut changed = false;
936 for entry in &mut entries {
937 if let Some(text) = fresh_by_id.get(&entry.id)
938 && entry.text != *text
939 {
940 entry.text.clone_from(text);
941 changed = true;
942 }
943 }
944 if changed {
945 let mut content = old.content.clone();
946 content.text = format_connection_summary_entries_with_heading(
947 connection_box.heading,
948 &entries,
949 );
950 content.metadata["revisionHash"] = json!(revision_hash(&content.text));
951 if content != old.content {
952 slots[index].content = content;
953 advanced = true;
954 }
955 }
956 }
957 }
958
959 if advanced {
960 advanced_existing.push(old.box_id);
961 }
962 }
963
964 let mut used_actual_slots = existing
965 .iter()
966 .map(|slot| slot.actual_slot.clone())
967 .collect::<HashSet<_>>();
968 let mut used_logical_slots = existing
969 .iter()
970 .map(|slot| slot.logical_slot.clone())
971 .collect::<HashSet<_>>();
972
973 for desired in desired_nodes {
974 if represented_full_nodes.contains(&desired.logical_slot) {
975 continue;
976 }
977 let actual_slot = unique_slot(&desired.logical_slot, &mut used_actual_slots);
978 used_logical_slots.insert(desired.logical_slot.clone());
979 slots.push(ToolSlotInput {
980 slot: actual_slot,
981 name: desired.name,
982 content: desired.content,
983 retired: false,
984 });
985 }
986
987 let additions = fresh_entries
988 .iter()
989 .filter(|entry| !represented_connection_ids.contains(&entry.id))
990 .cloned()
991 .collect::<Vec<_>>();
992 let had_connection_box = !connection_boxes.is_empty();
993 let mut next_addition = 0;
994 while next_addition < additions.len()
995 || (!had_connection_box && additions.is_empty() && next_addition == 0)
996 {
997 let end = (next_addition + CONNECTION_SUMMARIES_PER_BOX).min(additions.len());
998 let entries = additions[next_addition..end].to_vec();
999 let logical_slot = unique_connection_summary_logical_slot(&mut used_logical_slots);
1000 let actual_slot = unique_slot(&logical_slot, &mut used_actual_slots);
1001 let mut content = fresh_connections.content.clone();
1002 update_connection_summary_content(&mut content, &logical_slot, &entries);
1003 slots.push(ToolSlotInput {
1004 slot: actual_slot,
1005 name: fresh_connections.name.clone(),
1006 content,
1007 retired: false,
1008 });
1009 if additions.is_empty() {
1010 break;
1011 }
1012 next_addition = end;
1013 }
1014
1015 Ok(CacheSafeLoadPlan {
1016 slots,
1017 advanced_existing,
1018 })
1019}
1020
1021fn preserve_metadata_key(target: &mut BoxContent, source: &BoxContent, key: &str) {
1022 if let Some(value) = source.metadata.get(key) {
1023 target.metadata[key] = value.clone();
1024 } else if let Some(metadata) = target.metadata.as_object_mut() {
1025 metadata.remove(key);
1026 }
1027}
1028
1029fn unique_connection_summary_logical_slot(used: &mut HashSet<String>) -> String {
1030 let mut sequence = 1_u64;
1031 loop {
1032 let candidate = if sequence == 1 {
1033 CONNECTION_SUMMARIES_LOGICAL_SLOT.to_owned()
1034 } else {
1035 format!("{CONNECTION_SUMMARIES_LOGICAL_SLOT}:{sequence}")
1036 };
1037 if used.insert(candidate.clone()) {
1038 return candidate;
1039 }
1040 sequence += 1;
1041 }
1042}
1043
1044type KwebBoxVersions = BTreeMap<BoxId, (String, EventId)>;
1045
1046fn kweb_box_versions(journal: &HistorySession) -> KwebBoxVersions {
1047 journal
1048 .state()
1049 .tools
1050 .get(KWEB_TOOL_INSTANCE)
1051 .into_iter()
1052 .flat_map(|tool| &tool.slots)
1053 .filter(|slot| !slot.retired)
1054 .filter_map(|slot| {
1055 let state = journal.state().box_state(slot.box_id)?;
1056 state
1057 .active
1058 .then(|| (slot.box_id, (state.name.clone(), state.canonical.event_id)))
1059 })
1060 .collect()
1061}
1062
1063fn changed_existing_kweb_box_ids(
1064 journal: &HistorySession,
1065 previous: &KwebBoxVersions,
1066) -> Vec<BoxId> {
1067 journal
1068 .state()
1069 .tools
1070 .get(KWEB_TOOL_INSTANCE)
1071 .into_iter()
1072 .flat_map(|tool| &tool.slots)
1073 .filter(|slot| !slot.retired)
1074 .filter_map(|slot| {
1075 let state = journal.state().box_state(slot.box_id)?;
1076 let changed = previous
1077 .get(&slot.box_id)
1078 .is_some_and(|(_, event_id)| *event_id != state.canonical.event_id);
1079 (state.active && changed).then_some(slot.box_id)
1080 })
1081 .collect()
1082}
1083
1084fn changed_kweb_box_ids(journal: &HistorySession, previous: &KwebBoxVersions) -> Vec<BoxId> {
1085 journal
1086 .state()
1087 .tool_layouts
1088 .get(KWEB_TOOL_INSTANCE)
1089 .into_iter()
1090 .flatten()
1091 .filter_map(|box_id| {
1092 let state = journal.state().box_state(*box_id)?;
1093 let current = (state.name.as_str(), state.canonical.event_id);
1094 let changed = previous
1095 .get(box_id)
1096 .map(|(name, revision)| (name.as_str(), *revision) != current)
1097 .unwrap_or(true);
1098 (state.active && changed).then_some(*box_id)
1099 })
1100 .collect()
1101}
1102
1103fn unique_slot(logical: &str, used: &mut HashSet<String>) -> String {
1104 if used.insert(logical.to_owned()) {
1105 return logical.to_owned();
1106 }
1107 let mut generation = 2_u64;
1108 loop {
1109 let candidate = format!("{logical}#generation-{generation}");
1110 if used.insert(candidate.clone()) {
1111 return candidate;
1112 }
1113 generation += 1;
1114 }
1115}
1116
1117fn reconcile_kweb_slots(
1118 journal: &mut HistorySession,
1119 recorded_at: &str,
1120 desired: Vec<DesiredKwebBox>,
1121) -> Result<()> {
1122 let current = journal
1123 .state()
1124 .tools
1125 .get(KWEB_TOOL_INSTANCE)
1126 .cloned()
1127 .unwrap_or_default();
1128 let desired_by_logical = desired
1129 .iter()
1130 .enumerate()
1131 .map(|(index, entry)| (entry.logical_slot.as_str(), index))
1132 .collect::<BTreeMap<_, _>>();
1133 if desired_by_logical.len() != desired.len() {
1134 return Err(Error::new(
1135 "Kweb box layout contains duplicate logical slots",
1136 ));
1137 }
1138 let mut claimed = HashSet::new();
1139 let mut actual_by_desired = BTreeMap::new();
1140 let mut slots = Vec::with_capacity(current.slots.len() + desired.len());
1141 let mut used_actual = current
1142 .slots
1143 .iter()
1144 .map(|slot| slot.slot.clone())
1145 .collect::<HashSet<_>>();
1146 for slot in ¤t.slots {
1147 let state = journal
1148 .state()
1149 .box_state(slot.box_id)
1150 .ok_or_else(|| Error::new("Kweb tool slot box is missing"))?;
1151 let logical = kweb_logical_slot(state, &slot.slot);
1152 let selected = !slot.retired
1153 && desired_by_logical.contains_key(logical.as_str())
1154 && claimed.insert(logical.clone());
1155 if selected {
1156 let entry = &desired[desired_by_logical[logical.as_str()]];
1157 slots.push(ToolSlotInput {
1158 slot: slot.slot.clone(),
1159 name: entry.name.clone(),
1160 content: entry.content.clone(),
1161 retired: false,
1162 });
1163 actual_by_desired.insert(entry.logical_slot.clone(), slot.slot.clone());
1164 } else {
1165 slots.push(ToolSlotInput {
1166 slot: slot.slot.clone(),
1167 name: state.name.clone(),
1168 content: state.canonical.content.clone(),
1169 retired: slot.retired || !selected,
1170 });
1171 }
1172 }
1173 for entry in &desired {
1174 if actual_by_desired.contains_key(&entry.logical_slot) {
1175 continue;
1176 }
1177 let actual = unique_slot(&entry.logical_slot, &mut used_actual);
1178 slots.push(ToolSlotInput {
1179 slot: actual.clone(),
1180 name: entry.name.clone(),
1181 content: entry.content.clone(),
1182 retired: false,
1183 });
1184 actual_by_desired.insert(entry.logical_slot.clone(), actual);
1185 }
1186 let layout_slots = desired
1187 .iter()
1188 .map(|entry| actual_by_desired[&entry.logical_slot].clone())
1189 .collect::<Vec<_>>();
1190 journal
1191 .apply_tool_slots_with_layout(recorded_at, KWEB_TOOL_INSTANCE, slots, &layout_slots)
1192 .map_err(|error| Error::new(format!("applying Kweb projection: {error}")))?;
1193 Ok(())
1194}
1195
1196fn canonical_node_id(value: &str) -> Result<()> {
1197 value
1198 .parse::<NodeId>()
1199 .map(|_| ())
1200 .map_err(|_| Error::new(format!("{value:?} is not a canonical Kweb node ID")))
1201}
1202
1203#[cfg(test)]
1204mod tests {
1205 use std::path::PathBuf;
1206 use std::time::{SystemTime, UNIX_EPOCH};
1207
1208 use super::*;
1209 use kcode_session_history::{
1210 Config as HistoryConfig, NewSession, SessionHistory,
1211 chatend::{Representation, SessionKind},
1212 };
1213 use serde_json::json;
1214
1215 fn id(index: u8) -> String {
1216 NodeId::from_bytes([0, 0, 0, 0, 0, index])
1217 .unwrap()
1218 .to_string()
1219 }
1220
1221 fn connection(index: u8) -> Connection {
1222 Connection {
1223 id: id(index),
1224 short_name: format!("Node {index}"),
1225 short_description: format!("Summary {index}"),
1226 }
1227 }
1228
1229 fn node(index: u8, fixed: &[u8], recent: &[u8]) -> Node {
1230 Node {
1231 id: id(index),
1232 short_name: format!("Node {index}"),
1233 short_description: format!("Summary {index}"),
1234 long_description: format!("Long description {index}"),
1235 owner: id(1),
1236 fixed_connections: fixed.iter().copied().map(connection).collect(),
1237 recent_connections: recent.iter().copied().map(connection).collect(),
1238 objects: vec![],
1239 last_modified_by: "test-model-high".into(),
1240 last_modified_at: Some("2026-07-28T00:00:00Z".into()),
1241 }
1242 }
1243
1244 fn node_with_recent_description(
1245 index: u8,
1246 fixed: &[u8],
1247 recent: &[u8],
1248 description: &str,
1249 ) -> Node {
1250 let mut node = node(index, fixed, recent);
1251 for (connection, connection_index) in node.recent_connections.iter_mut().zip(recent) {
1252 connection.short_description = format!("{description} {connection_index}");
1253 }
1254 node
1255 }
1256
1257 fn draft(index: u8, recent: &[u8]) -> NodeDraft {
1258 NodeDraft {
1259 short_name: format!("Node {index}"),
1260 short_description: format!("Summary {index}"),
1261 long_description: format!("Long description {index}"),
1262 owner: id(1),
1263 fixed_connections: Vec::new(),
1264 recent_connections: recent.iter().map(|value| id(*value)).collect(),
1265 objects: Vec::new(),
1266 }
1267 }
1268
1269 fn test_journal(label: &str) -> (PathBuf, HistorySession) {
1270 let root = std::env::temp_dir().join(format!(
1271 "kcode-kweb-context-{label}-{}-{}",
1272 std::process::id(),
1273 SystemTime::now()
1274 .duration_since(UNIX_EPOCH)
1275 .unwrap()
1276 .as_nanos()
1277 ));
1278 let history = SessionHistory::open(HistoryConfig {
1279 directory: root.join("sessions"),
1280 completed_list: root.join("completed.jsonl"),
1281 provider_cost_compatibility: None,
1282 })
1283 .unwrap();
1284 let journal = history
1285 .create_session(NewSession {
1286 kind: SessionKind::Conversation,
1287 created_at: "2026-07-29T00:00:00Z".into(),
1288 effective_context_tokens: 10_000,
1289 channel: Value::Null,
1290 })
1291 .unwrap();
1292 (root, journal)
1293 }
1294
1295 fn connection_summary_box_ids(journal: &HistorySession) -> Vec<BoxId> {
1296 journal
1297 .state()
1298 .tool_layouts
1299 .get(KWEB_TOOL_INSTANCE)
1300 .into_iter()
1301 .flatten()
1302 .copied()
1303 .filter(|box_id| {
1304 journal
1305 .state()
1306 .box_state(*box_id)
1307 .and_then(|state| state.canonical.content.metadata.get("kwebRole"))
1308 .and_then(Value::as_str)
1309 == Some("connection-summary")
1310 })
1311 .collect()
1312 }
1313
1314 fn all_connection_summary_box_ids(journal: &HistorySession) -> Vec<BoxId> {
1315 journal
1316 .state()
1317 .tools
1318 .get(KWEB_TOOL_INSTANCE)
1319 .into_iter()
1320 .flat_map(|tool| &tool.slots)
1321 .filter_map(|slot| {
1322 let state = journal.state().box_state(slot.box_id)?;
1323 (kweb_role(&state.canonical.content) == Some("connection-summary"))
1324 .then_some(slot.box_id)
1325 })
1326 .collect()
1327 }
1328
1329 fn box_id_for_logical(journal: &HistorySession, logical: &str) -> BoxId {
1330 journal.state().tools[KWEB_TOOL_INSTANCE]
1331 .slots
1332 .iter()
1333 .find_map(|slot| {
1334 let state = journal.state().box_state(slot.box_id).unwrap();
1335 (kweb_logical_slot(state, &slot.slot) == logical).then_some(slot.box_id)
1336 })
1337 .unwrap()
1338 }
1339
1340 fn tool_inputs_and_layout(journal: &HistorySession) -> (Vec<ToolSlotInput>, Vec<String>) {
1341 let tool = &journal.state().tools[KWEB_TOOL_INSTANCE];
1342 let inputs = tool
1343 .slots
1344 .iter()
1345 .map(|slot| {
1346 let state = journal.state().box_state(slot.box_id).unwrap();
1347 ToolSlotInput {
1348 slot: slot.slot.clone(),
1349 name: state.name.clone(),
1350 content: state.canonical.content.clone(),
1351 retired: slot.retired,
1352 }
1353 })
1354 .collect();
1355 let layout = journal.state().tool_layouts[KWEB_TOOL_INSTANCE]
1356 .iter()
1357 .map(|box_id| {
1358 tool.slots
1359 .iter()
1360 .find(|slot| slot.box_id == *box_id)
1361 .unwrap()
1362 .slot
1363 .clone()
1364 })
1365 .collect();
1366 (inputs, layout)
1367 }
1368
1369 fn metadata_without_revision(metadata: &Value) -> Value {
1370 let mut metadata = metadata.clone();
1371 metadata.as_object_mut().unwrap().remove("revisionHash");
1372 metadata
1373 }
1374
1375 #[test]
1376 fn compatibility_fixed_nodes_yield_to_direct_loads() {
1377 let mut context = Context::with_fixed_connections(vec![id(1)], true).unwrap();
1378 context
1379 .apply_load(node(1, &[2], &[]), vec![node(2, &[], &[])])
1380 .unwrap();
1381 let report = context.apply_load(node(2, &[], &[]), Vec::new()).unwrap();
1382 assert!(report.promoted_from_fixed);
1383 assert_eq!(context.loaded_node_ids(), &[id(1), id(2)]);
1384 assert!(context.fixed_node_ids().is_empty());
1385 assert_eq!(
1386 context
1387 .box_specs(&BTreeMap::new(), &[])
1388 .unwrap()
1389 .iter()
1390 .map(|spec| spec.kind)
1391 .collect::<Vec<_>>(),
1392 vec![BoxKind::Loaded, BoxKind::Loaded, BoxKind::Connections]
1393 );
1394 }
1395
1396 #[test]
1397 fn default_projection_keeps_only_direct_nodes_full() {
1398 let mut context = Context::new(vec![id(1)]).unwrap();
1399 context.apply_load(node(1, &[2], &[3]), Vec::new()).unwrap();
1400
1401 let projected = context.projection(&BTreeMap::new(), &[]).unwrap();
1402 assert_eq!(
1403 projected
1404 .iter()
1405 .map(|item| item.key.clone())
1406 .collect::<Vec<_>>(),
1407 vec![
1408 id(1),
1409 format!("connection-summaries:{}", id(2)),
1410 format!("connection-summaries:{}", id(3)),
1411 ]
1412 );
1413 assert_eq!(projected[0].name, "Kweb loaded node");
1414 assert!(projected[0].text.contains("Long description 1"));
1415 assert_eq!(projected[1].name, "Kweb connection map marker");
1416 assert!(projected[1].text.contains(&id(2)));
1417 assert!(!projected[1].text.contains(&id(3)));
1418 assert_eq!(projected[2].name, "Kweb connection map marker");
1419 assert!(projected[2].text.contains(&id(3)));
1420 assert!(!projected[2].text.contains(&id(2)));
1421 assert!(!context.contains_full_node(&id(2)));
1422 }
1423
1424 #[test]
1425 fn box_free_connection_states_change_independently() {
1426 let mut context = Context::new(vec![id(1)]).unwrap();
1427 context
1428 .apply_load(
1429 node_with_recent_description(1, &[], &[2, 3], "old"),
1430 Vec::new(),
1431 )
1432 .unwrap();
1433 let initial = context
1434 .projection(&BTreeMap::new(), &[])
1435 .unwrap()
1436 .into_iter()
1437 .map(|item| (item.key, item.text))
1438 .collect::<BTreeMap<_, _>>();
1439
1440 context
1441 .refresh([node_with_recent_description(1, &[], &[2, 3, 4], "old")])
1442 .unwrap();
1443 let expanded = context
1444 .projection(&BTreeMap::new(), &[])
1445 .unwrap()
1446 .into_iter()
1447 .map(|item| (item.key, item.text))
1448 .collect::<BTreeMap<_, _>>();
1449 assert_eq!(
1450 expanded[&format!("connection-summaries:{}", id(2))],
1451 initial[&format!("connection-summaries:{}", id(2))]
1452 );
1453 assert_eq!(
1454 expanded[&format!("connection-summaries:{}", id(3))],
1455 initial[&format!("connection-summaries:{}", id(3))]
1456 );
1457 assert!(expanded.contains_key(&format!("connection-summaries:{}", id(4))));
1458
1459 let mut changed_node = node_with_recent_description(1, &[], &[2, 3, 4], "old");
1460 changed_node.recent_connections[1].short_description = "changed 3".into();
1461 context.refresh([changed_node]).unwrap();
1462 let changed = context
1463 .projection(&BTreeMap::new(), &[])
1464 .unwrap()
1465 .into_iter()
1466 .map(|item| (item.key, item.text))
1467 .collect::<BTreeMap<_, _>>();
1468 let second = format!("connection-summaries:{}", id(2));
1469 let third = format!("connection-summaries:{}", id(3));
1470 let fourth = format!("connection-summaries:{}", id(4));
1471 assert_eq!(changed[&second], expanded[&second]);
1472 assert_ne!(changed[&third], expanded[&third]);
1473 assert_eq!(changed[&fourth], expanded[&fourth]);
1474 assert_eq!(changed[&id(1)], expanded[&id(1)]);
1475 }
1476
1477 #[test]
1478 fn full_node_kinds_share_one_body_format_without_active_connections() {
1479 let mut context = Context::with_fixed_connections(vec![id(1)], true).unwrap();
1480 context
1481 .apply_load(node(1, &[2], &[]), vec![node(2, &[], &[])])
1482 .unwrap();
1483 let create = StagedCreate {
1484 pending_id: "pending:1".into(),
1485 data: draft(3, &[]),
1486 };
1487 let specs = context.box_specs(&BTreeMap::new(), &[create]).unwrap();
1488 assert_eq!(specs[0].kind.name(), "Kweb loaded node");
1489 assert_eq!(specs[1].kind.name(), "Kweb fixed connection");
1490 assert_eq!(specs[2].kind.name(), "Kweb staged node");
1491 for spec in &specs[..3] {
1492 assert!(spec.text.contains("Node ID:"));
1493 assert!(spec.text.contains("Node name:"));
1494 assert!(spec.text.contains("Node owner ID:"));
1495 assert!(spec.text.contains("Fixed connection IDs:"));
1496 assert!(spec.text.contains("Recent connection IDs:"));
1497 assert!(!spec.text.contains("Active"));
1498 }
1499 assert_eq!(
1500 specs[0].text,
1501 concat!(
1502 "Node ID: AAAAAAAB\n",
1503 "Node name: Node 1\n",
1504 "Map marker: Summary 1\n",
1505 "Node owner ID: AAAAAAAB\n",
1506 "Node long description:\n",
1507 " Long description 1\n",
1508 "Fixed connection IDs: AAAAAAAC\n",
1509 "Recent connection IDs: none"
1510 )
1511 );
1512 }
1513
1514 #[test]
1515 fn fixed_and_recent_connections_share_one_ordered_deduplicated_box() {
1516 let mut context = Context::new(vec![id(1)]).unwrap();
1517 context
1518 .apply_load(node(1, &[2], &[4, 5]), Vec::new())
1519 .unwrap();
1520 context
1521 .apply_load(node(2, &[8], &[5, 6, 7]), Vec::new())
1522 .unwrap();
1523 let creates = vec![StagedCreate {
1524 pending_id: "pending:1".into(),
1525 data: draft(3, &[6, 7]),
1526 }];
1527 let specs = context.box_specs(&BTreeMap::new(), &creates).unwrap();
1528 let connections = specs
1529 .iter()
1530 .filter(|spec| spec.kind == BoxKind::Connections)
1531 .collect::<Vec<_>>();
1532 assert_eq!(connections.len(), 1);
1533 assert_eq!(
1534 connections[0].text,
1535 format!(
1536 concat!(
1537 "Connection map markers\n",
1538 "{} · Node 2: Summary 2\n",
1539 "{} · Node 4: Summary 4\n",
1540 "{} · Node 5: Summary 5\n",
1541 "{} · Node 8: Summary 8\n",
1542 "{} · Node 6: Summary 6\n",
1543 "{} · Node 7: Summary 7"
1544 ),
1545 id(2),
1546 id(4),
1547 id(5),
1548 id(8),
1549 id(6),
1550 id(7)
1551 )
1552 );
1553 }
1554
1555 #[test]
1556 fn empty_connection_projection_is_still_one_exact_box() {
1557 let mut context = Context::new(vec![id(1)]).unwrap();
1558 context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1559 let specs = context.box_specs(&BTreeMap::new(), &[]).unwrap();
1560 assert_eq!(specs.len(), 2);
1561 assert_eq!(specs[1].kind, BoxKind::Connections);
1562 assert_eq!(specs[1].text, "Connection map markers\nNone.");
1563 }
1564
1565 #[test]
1566 fn connection_projection_rejects_missing_name_or_description() {
1567 for missing_name in [true, false] {
1568 let mut source = node(1, &[], &[2]);
1569 if missing_name {
1570 source.recent_connections[0].short_name.clear();
1571 } else {
1572 source.recent_connections[0].short_description.clear();
1573 }
1574 let mut context = Context::new(vec![id(1)]).unwrap();
1575 context.apply_load(source, Vec::new()).unwrap();
1576 assert_eq!(
1577 context
1578 .box_specs(&BTreeMap::new(), &[])
1579 .unwrap_err()
1580 .to_string(),
1581 format!(
1582 "connection map marker {} must resolve to a nonempty node name and map marker",
1583 id(2)
1584 )
1585 );
1586 }
1587 }
1588
1589 #[test]
1590 fn connection_summary_entries_accept_only_legacy_and_new_headings() {
1591 let entry = format!("{} · Node 2: Summary 2", id(2));
1592 for heading in ["Connection summaries", "Connection map markers"] {
1593 let content = BoxContent {
1594 text: format!("{heading}\n{entry}"),
1595 objects: Vec::new(),
1596 metadata: json!({CONNECTION_SUMMARY_IDS_METADATA: [id(2)]}),
1597 };
1598 assert_eq!(
1599 connection_summary_entries(&content).unwrap(),
1600 vec![ConnectionSummaryEntry {
1601 id: id(2),
1602 text: entry.clone(),
1603 }]
1604 );
1605 }
1606
1607 for heading in [
1608 "Connection summary",
1609 "Connection map marker",
1610 "Connection map markers extra",
1611 ] {
1612 let content = BoxContent {
1613 text: format!("{heading}\n{entry}"),
1614 objects: Vec::new(),
1615 metadata: json!({}),
1616 };
1617 assert_eq!(
1618 connection_summary_entries(&content)
1619 .unwrap_err()
1620 .to_string(),
1621 "Kweb connection-summary box has an invalid heading"
1622 );
1623 }
1624
1625 let content = BoxContent {
1626 text: "Connection map markers".into(),
1627 objects: Vec::new(),
1628 metadata: json!({}),
1629 };
1630 assert_eq!(
1631 connection_summary_entries(&content)
1632 .unwrap_err()
1633 .to_string(),
1634 "Kweb connection-summary box has no body"
1635 );
1636 }
1637
1638 #[test]
1639 fn fresh_connection_marker_rendering_uses_only_new_terminology() {
1640 let mut context = Context::new(vec![id(1)]).unwrap();
1641 context.apply_load(node(1, &[2], &[]), Vec::new()).unwrap();
1642
1643 let specs = context.box_specs(&BTreeMap::new(), &[]).unwrap();
1644 let markers = specs.last().unwrap();
1645 assert_eq!(markers.kind.name(), "Kweb connection map markers");
1646 assert!(markers.text.starts_with("Connection map markers\n"));
1647 assert!(!markers.text.contains("Connection summaries"));
1648
1649 let projected = context.projection(&BTreeMap::new(), &[]).unwrap();
1650 assert_eq!(projected[1].name, "Kweb connection map marker");
1651 assert!(!projected[1].name.contains("summary"));
1652 }
1653
1654 #[test]
1655 fn staged_updates_drive_full_text_and_recent_projection() {
1656 let mut context = Context::new(vec![id(1)]).unwrap();
1657 context.apply_load(node(1, &[3], &[2]), Vec::new()).unwrap();
1658 let mut updates = BTreeMap::new();
1659 updates.insert(id(1), draft(9, &[3]));
1660 let specs = context.box_specs(&updates, &[]).unwrap();
1661 assert!(specs[0].text.contains("Node name: Node 9"));
1662 assert!(specs[0].staged_node.is_some());
1663 assert!(!specs.last().unwrap().text.contains(&id(2)));
1664 assert!(specs.last().unwrap().text.contains(&id(3)));
1665 }
1666
1667 #[test]
1668 fn sync_appends_fresh_connection_boxes_eight_at_a_time() {
1669 let (root, mut journal) = test_journal("connection-boxes");
1670 let mut context = Context::new(vec![id(1)]).unwrap();
1671 let initial_indices = (2..=19).collect::<Vec<_>>();
1672 context
1673 .apply_load(
1674 node_with_recent_description(1, &[], &initial_indices, "old"),
1675 Vec::new(),
1676 )
1677 .unwrap();
1678 context
1679 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1680 .unwrap();
1681
1682 let original_ids = connection_summary_box_ids(&journal);
1683 assert_eq!(
1684 original_ids
1685 .iter()
1686 .map(|box_id| {
1687 connection_summary_entries(
1688 &journal
1689 .state()
1690 .box_state(*box_id)
1691 .unwrap()
1692 .canonical
1693 .content,
1694 )
1695 .unwrap()
1696 .len()
1697 })
1698 .collect::<Vec<_>>(),
1699 vec![8, 8, 2]
1700 );
1701 let original_revisions = original_ids
1702 .iter()
1703 .map(|box_id| {
1704 journal
1705 .state()
1706 .box_state(*box_id)
1707 .unwrap()
1708 .canonical
1709 .event_id
1710 })
1711 .collect::<Vec<_>>();
1712 journal
1713 .summarize_box("t2", original_ids[0], "retained first box")
1714 .unwrap();
1715 journal.dehydrate_boxes("t3", &original_ids[1..=2]).unwrap();
1716
1717 let expanded_indices = (2..=28).collect::<Vec<_>>();
1718 context
1719 .refresh([node_with_recent_description(
1720 1,
1721 &[],
1722 &expanded_indices,
1723 "new",
1724 )])
1725 .unwrap();
1726 let changed = context
1727 .sync_chatend(&mut journal, "t4", &BTreeMap::new(), &[])
1728 .unwrap();
1729 let current_ids = connection_summary_box_ids(&journal);
1730 assert_eq!(
1731 current_ids
1732 .iter()
1733 .map(|box_id| {
1734 connection_summary_entries(
1735 &journal
1736 .state()
1737 .box_state(*box_id)
1738 .unwrap()
1739 .canonical
1740 .content,
1741 )
1742 .unwrap()
1743 .len()
1744 })
1745 .collect::<Vec<_>>(),
1746 vec![8, 8, 2, 8, 1]
1747 );
1748 assert_eq!(¤t_ids[..3], original_ids.as_slice());
1749 assert!(changed.contains(&original_ids[0]));
1750 assert!(changed.contains(&original_ids[1]));
1751 assert!(changed.contains(&original_ids[2]));
1752 assert!(changed.contains(¤t_ids[3]));
1753 assert!(changed.contains(¤t_ids[4]));
1754
1755 let first = journal.state().box_state(original_ids[0]).unwrap();
1756 assert_ne!(first.canonical.event_id, original_revisions[0]);
1757 assert!(first.canonical.content.text.contains("new 2"));
1758 assert!(matches!(
1759 first.representation,
1760 Representation::Hydrated { canonical_event } if canonical_event == first.canonical.event_id
1761 ));
1762 let second = journal.state().box_state(original_ids[1]).unwrap();
1763 assert_ne!(second.canonical.event_id, original_revisions[1]);
1764 assert!(second.canonical.content.text.contains("new 10"));
1765 assert!(matches!(
1766 second.representation,
1767 Representation::Hydrated { canonical_event } if canonical_event == second.canonical.event_id
1768 ));
1769 let third = journal.state().box_state(original_ids[2]).unwrap();
1770 assert_ne!(third.canonical.event_id, original_revisions[2]);
1771 assert!(!third.canonical.content.text.contains("new 25"));
1772 assert!(third.canonical.content.text.contains("new 19"));
1773 assert!(matches!(
1774 third.representation,
1775 Representation::Hydrated { canonical_event } if canonical_event == third.canonical.event_id
1776 ));
1777 let fourth = journal.state().box_state(current_ids[3]).unwrap();
1778 assert!(fourth.canonical.content.text.contains("new 20"));
1779 assert!(fourth.canonical.content.text.contains("new 27"));
1780 assert!(!fourth.canonical.content.text.contains("new 28"));
1781 let fifth = journal.state().box_state(current_ids[4]).unwrap();
1782 assert!(fifth.canonical.content.text.contains("new 28"));
1783
1784 drop(journal);
1785 std::fs::remove_dir_all(root).unwrap();
1786 }
1787
1788 #[test]
1789 fn mutable_sync_hydrates_only_boxes_with_changed_canonical_content() {
1790 let (root, mut journal) = test_journal("selective-hydration");
1791 let mut context = Context::new(vec![id(1)]).unwrap();
1792 let changed_node = node(1, &[], &[]);
1793 let unchanged_node = node(2, &[], &[]);
1794 context
1795 .apply_load(changed_node.clone(), Vec::new())
1796 .unwrap();
1797 context
1798 .apply_load(unchanged_node.clone(), Vec::new())
1799 .unwrap();
1800 context
1801 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1802 .unwrap();
1803 let changed_box = box_id_for_logical(&journal, &id(1));
1804 let unchanged_box = box_id_for_logical(&journal, &id(2));
1805 let connection_box = connection_summary_box_ids(&journal)[0];
1806 journal
1807 .summarize_box("t2", changed_box, "old changed node")
1808 .unwrap();
1809 journal
1810 .summarize_box("t3", unchanged_box, "retained unchanged node")
1811 .unwrap();
1812 journal.dehydrate_boxes("t4", &[connection_box]).unwrap();
1813 let unchanged_event = journal
1814 .state()
1815 .box_state(unchanged_box)
1816 .unwrap()
1817 .canonical
1818 .event_id;
1819 let connection_event = journal
1820 .state()
1821 .box_state(connection_box)
1822 .unwrap()
1823 .canonical
1824 .event_id;
1825
1826 let mut latest_node = changed_node;
1827 latest_node.long_description = "Latest mutable Kweb text".into();
1828 context.refresh([latest_node.clone()]).unwrap();
1829 assert_eq!(
1830 context
1831 .sync_chatend(&mut journal, "t5", &BTreeMap::new(), &[])
1832 .unwrap(),
1833 vec![changed_box]
1834 );
1835 let changed = journal.state().box_state(changed_box).unwrap();
1836 assert!(
1837 changed
1838 .canonical
1839 .content
1840 .text
1841 .contains("Latest mutable Kweb text")
1842 );
1843 assert!(matches!(
1844 changed.representation,
1845 Representation::Hydrated { canonical_event } if canonical_event == changed.canonical.event_id
1846 ));
1847 let unchanged = journal.state().box_state(unchanged_box).unwrap();
1848 assert_eq!(unchanged.canonical.event_id, unchanged_event);
1849 assert!(matches!(
1850 unchanged.representation,
1851 Representation::Summarized { based_on, .. } if based_on == unchanged_event
1852 ));
1853 let connections = journal.state().box_state(connection_box).unwrap();
1854 assert_eq!(connections.canonical.event_id, connection_event);
1855 assert!(matches!(
1856 connections.representation,
1857 Representation::Dehydrated { based_on } if based_on == connection_event
1858 ));
1859
1860 context
1861 .restore([latest_node, unchanged_node], vec![id(2), id(1)])
1862 .unwrap();
1863 assert!(
1864 context
1865 .sync_chatend(&mut journal, "t6", &BTreeMap::new(), &[])
1866 .unwrap()
1867 .is_empty()
1868 );
1869 assert_eq!(
1870 journal.state().tool_layouts[KWEB_TOOL_INSTANCE][..2],
1871 [unchanged_box, changed_box]
1872 );
1873 assert!(matches!(
1874 journal.state().box_state(unchanged_box).unwrap().representation,
1875 Representation::Summarized { based_on, .. } if based_on == unchanged_event
1876 ));
1877 assert!(matches!(
1878 journal.state().box_state(connection_box).unwrap().representation,
1879 Representation::Dehydrated { based_on } if based_on == connection_event
1880 ));
1881
1882 drop(journal);
1883 std::fs::remove_dir_all(root).unwrap();
1884 }
1885
1886 #[test]
1887 fn ordinary_sync_upgrades_legacy_text_and_hydrates_changed_boxes() {
1888 let (root, mut journal) = test_journal("legacy-heading-upgrade");
1889 let mut context = Context::new(vec![id(1)]).unwrap();
1890 let indices = (2..=10).collect::<Vec<_>>();
1891 context
1892 .apply_load(node(1, &[], &indices), Vec::new())
1893 .unwrap();
1894 context
1895 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1896 .unwrap();
1897
1898 let original_ids = connection_summary_box_ids(&journal);
1899 assert_eq!(original_ids.len(), 2);
1900 let current_tool = journal.state().tools[KWEB_TOOL_INSTANCE].clone();
1901 let layout_slots = journal.state().tool_layouts[KWEB_TOOL_INSTANCE]
1902 .iter()
1903 .map(|box_id| {
1904 current_tool
1905 .slots
1906 .iter()
1907 .find(|slot| slot.box_id == *box_id)
1908 .unwrap()
1909 .slot
1910 .clone()
1911 })
1912 .collect::<Vec<_>>();
1913 let legacy_slots = current_tool
1914 .slots
1915 .iter()
1916 .map(|slot| {
1917 let state = journal.state().box_state(slot.box_id).unwrap();
1918 let mut content = state.canonical.content.clone();
1919 let mut name = state.name.clone();
1920 if content.metadata.get("kwebRole").and_then(Value::as_str)
1921 == Some("connection-summary")
1922 {
1923 content.text =
1924 content
1925 .text
1926 .replacen("Connection map markers", "Connection summaries", 1);
1927 content.metadata["revisionHash"] = json!(revision_hash(&content.text));
1928 content.metadata["retainedCompatibilityMetadata"] =
1929 json!(content.metadata["kwebLogicalSlot"].clone());
1930 name = "Kweb connection summaries".into();
1931 }
1932 ToolSlotInput {
1933 slot: slot.slot.clone(),
1934 name,
1935 content,
1936 retired: slot.retired,
1937 }
1938 })
1939 .collect::<Vec<_>>();
1940 journal
1941 .apply_tool_slots_with_layout("t2", KWEB_TOOL_INSTANCE, legacy_slots, &layout_slots)
1942 .unwrap();
1943
1944 let legacy = original_ids
1945 .iter()
1946 .map(|box_id| {
1947 let slot = journal.state().tools[KWEB_TOOL_INSTANCE]
1948 .slots
1949 .iter()
1950 .find(|slot| slot.box_id == *box_id)
1951 .unwrap()
1952 .slot
1953 .clone();
1954 let state = journal.state().box_state(*box_id).unwrap();
1955 assert_eq!(state.name, "Kweb connection summaries");
1956 assert!(
1957 state
1958 .canonical
1959 .content
1960 .text
1961 .starts_with("Connection summaries\n")
1962 );
1963 (
1964 slot,
1965 state.canonical.event_id,
1966 connection_summary_entries(&state.canonical.content)
1967 .unwrap()
1968 .into_iter()
1969 .map(|entry| entry.id)
1970 .collect::<Vec<_>>(),
1971 metadata_without_revision(&state.canonical.content.metadata),
1972 )
1973 })
1974 .collect::<Vec<_>>();
1975 journal
1976 .summarize_box("t3", original_ids[0], "retained legacy map markers")
1977 .unwrap();
1978 journal.dehydrate_boxes("t4", &original_ids[1..]).unwrap();
1979
1980 let changed = context
1981 .sync_chatend(&mut journal, "t5", &BTreeMap::new(), &[])
1982 .unwrap();
1983 assert_eq!(connection_summary_box_ids(&journal), original_ids);
1984 assert!(changed.contains(&original_ids[0]));
1985 assert!(changed.contains(&original_ids[1]));
1986
1987 for (index, box_id) in original_ids.iter().enumerate() {
1988 let state = journal.state().box_state(*box_id).unwrap();
1989 let actual_slot = journal.state().tools[KWEB_TOOL_INSTANCE]
1990 .slots
1991 .iter()
1992 .find(|slot| slot.box_id == *box_id)
1993 .unwrap()
1994 .slot
1995 .as_str();
1996 assert_eq!(actual_slot, legacy[index].0);
1997 assert_eq!(state.name, "Kweb connection map markers");
1998 assert!(
1999 state
2000 .canonical
2001 .content
2002 .text
2003 .starts_with("Connection map markers\n")
2004 );
2005 assert!(
2006 !state
2007 .canonical
2008 .content
2009 .text
2010 .contains("Connection summaries")
2011 );
2012 assert_eq!(
2013 connection_summary_entries(&state.canonical.content)
2014 .unwrap()
2015 .into_iter()
2016 .map(|entry| entry.id)
2017 .collect::<Vec<_>>(),
2018 legacy[index].2
2019 );
2020 assert_eq!(
2021 metadata_without_revision(&state.canonical.content.metadata),
2022 legacy[index].3
2023 );
2024 assert_eq!(
2025 state.canonical.content.metadata["revisionHash"],
2026 json!(revision_hash(&state.canonical.content.text))
2027 );
2028 assert!(matches!(
2029 state.representation,
2030 Representation::Hydrated { canonical_event } if canonical_event == state.canonical.event_id
2031 ));
2032 }
2033
2034 drop(journal);
2035 std::fs::remove_dir_all(root).unwrap();
2036 }
2037
2038 #[test]
2039 fn sync_preserves_empty_connection_box_when_later_summaries_arrive() {
2040 let (root, mut journal) = test_journal("empty-connection-box");
2041 let mut context = Context::new(vec![id(1)]).unwrap();
2042 context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
2043 context
2044 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
2045 .unwrap();
2046
2047 let original_boxes = connection_summary_box_ids(&journal);
2048 assert_eq!(original_boxes.len(), 1);
2049 let original_revision = journal
2050 .state()
2051 .box_state(original_boxes[0])
2052 .unwrap()
2053 .canonical
2054 .event_id;
2055 let content = &journal
2056 .state()
2057 .box_state(original_boxes[0])
2058 .unwrap()
2059 .canonical
2060 .content;
2061 assert!(connection_summary_entries(content).unwrap().is_empty());
2062 assert_eq!(content.metadata[CONNECTION_SUMMARY_IDS_METADATA], json!([]));
2063
2064 context.refresh([node(1, &[], &[2])]).unwrap();
2065 let changed = context
2066 .sync_chatend(&mut journal, "t2", &BTreeMap::new(), &[])
2067 .unwrap();
2068 let current_boxes = connection_summary_box_ids(&journal);
2069 assert_eq!(current_boxes.len(), 2);
2070 assert_eq!(current_boxes[0], original_boxes[0]);
2071 assert!(!changed.contains(&original_boxes[0]));
2072 assert!(changed.contains(¤t_boxes[1]));
2073 let original = journal.state().box_state(original_boxes[0]).unwrap();
2074 assert_eq!(original.canonical.event_id, original_revision);
2075 assert!(
2076 connection_summary_entries(&original.canonical.content)
2077 .unwrap()
2078 .is_empty()
2079 );
2080 let fresh = journal.state().box_state(current_boxes[1]).unwrap();
2081 assert_eq!(
2082 connection_summary_entries(&fresh.canonical.content)
2083 .unwrap()
2084 .iter()
2085 .map(|entry| entry.id.clone())
2086 .collect::<Vec<_>>(),
2087 vec![id(2)]
2088 );
2089
2090 drop(journal);
2091 std::fs::remove_dir_all(root).unwrap();
2092 }
2093
2094 #[test]
2095 fn sync_reports_only_changed_boxes_in_projection_order() {
2096 let (root, mut journal) = test_journal("changed-boxes");
2097 let mut context = Context::new(vec![id(1)]).unwrap();
2098 context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
2099 let initial = context
2100 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
2101 .unwrap();
2102 assert_eq!(initial.len(), 2);
2103 assert!(
2104 context
2105 .sync_chatend(&mut journal, "t2", &BTreeMap::new(), &[],)
2106 .unwrap()
2107 .is_empty()
2108 );
2109
2110 context.apply_load(node(1, &[2], &[]), Vec::new()).unwrap();
2111 let changed = context
2112 .sync_chatend(&mut journal, "t3", &BTreeMap::new(), &[])
2113 .unwrap();
2114 assert_eq!(changed.len(), 2);
2115 assert_eq!(
2116 changed
2117 .iter()
2118 .map(|box_id| journal.state().box_state(*box_id).unwrap().name.as_str())
2119 .collect::<Vec<_>>(),
2120 vec!["Kweb loaded node", "Kweb connection map markers"]
2121 );
2122 assert!(
2123 context
2124 .sync_chatend(&mut journal, "t4", &BTreeMap::new(), &[])
2125 .unwrap()
2126 .is_empty()
2127 );
2128
2129 drop(journal);
2130 std::fs::remove_dir_all(root).unwrap();
2131 }
2132
2133 #[test]
2134 fn load_sync_appends_unseen_nodes_after_exact_old_prefix_without_layout_change() {
2135 let (root, mut journal) = test_journal("load-node-tail");
2136 let mut context = Context::new(vec![id(1)]).unwrap();
2137 context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
2138 context
2139 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
2140 .unwrap();
2141 let layout = journal.state().tool_layouts[KWEB_TOOL_INSTANCE].clone();
2142 let old_slots = journal.state().tools[KWEB_TOOL_INSTANCE]
2143 .slots
2144 .iter()
2145 .map(|slot| {
2146 let state = journal.state().box_state(slot.box_id).unwrap();
2147 (
2148 slot.slot.clone(),
2149 slot.box_id,
2150 slot.retired,
2151 state.name.clone(),
2152 state.canonical.content.clone(),
2153 state.canonical.event_id,
2154 )
2155 })
2156 .collect::<Vec<_>>();
2157
2158 context.apply_load(node(2, &[], &[]), Vec::new()).unwrap();
2159 let stale = context
2160 .sync_load_chatend(&mut journal, "t2", &BTreeMap::new(), &[])
2161 .unwrap();
2162 assert!(stale.is_empty());
2163 assert_eq!(journal.state().tool_layouts[KWEB_TOOL_INSTANCE], layout);
2164 let slots = &journal.state().tools[KWEB_TOOL_INSTANCE].slots;
2165 assert_eq!(slots.len(), old_slots.len() + 1);
2166 for (slot, old) in slots.iter().zip(&old_slots) {
2167 let state = journal.state().box_state(slot.box_id).unwrap();
2168 assert_eq!(slot.slot, old.0);
2169 assert_eq!(slot.box_id, old.1);
2170 assert_eq!(slot.retired, old.2);
2171 assert_eq!(state.name, old.3);
2172 assert_eq!(state.canonical.content, old.4);
2173 assert_eq!(state.canonical.event_id, old.5);
2174 }
2175 let appended = journal
2176 .state()
2177 .box_state(slots.last().unwrap().box_id)
2178 .unwrap();
2179 assert_eq!(
2180 appended.canonical.content.metadata["kwebLogicalSlot"],
2181 json!(id(2))
2182 );
2183
2184 drop(journal);
2185 SessionHistory::open(HistoryConfig {
2186 directory: root.join("sessions"),
2187 completed_list: root.join("completed.jsonl"),
2188 provider_cost_compatibility: None,
2189 })
2190 .unwrap();
2191 std::fs::remove_dir_all(root).unwrap();
2192 }
2193
2194 #[test]
2195 fn load_sync_never_fills_old_partial_or_empty_connection_boxes() {
2196 for (label, initial) in [("empty", Vec::new()), ("partial", vec![2])] {
2197 let (root, mut journal) = test_journal(label);
2198 let mut context = Context::new(vec![id(1)]).unwrap();
2199 context
2200 .apply_load(node(1, &[], &initial), Vec::new())
2201 .unwrap();
2202 context
2203 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
2204 .unwrap();
2205 let old_box = all_connection_summary_box_ids(&journal)[0];
2206 let old_content = journal
2207 .state()
2208 .box_state(old_box)
2209 .unwrap()
2210 .canonical
2211 .content
2212 .clone();
2213 let old_event = journal
2214 .state()
2215 .box_state(old_box)
2216 .unwrap()
2217 .canonical
2218 .event_id;
2219 let layout = journal.state().tool_layouts[KWEB_TOOL_INSTANCE].clone();
2220
2221 let expanded = (2..=19).collect::<Vec<_>>();
2222 context.refresh([node(1, &[], &expanded)]).unwrap();
2223 context
2224 .sync_load_chatend(&mut journal, "t2", &BTreeMap::new(), &[])
2225 .unwrap();
2226 let old = journal.state().box_state(old_box).unwrap();
2227 assert_eq!(old.canonical.content, old_content);
2228 assert_eq!(old.canonical.event_id, old_event);
2229 assert_eq!(journal.state().tool_layouts[KWEB_TOOL_INSTANCE], layout);
2230 let fresh = all_connection_summary_box_ids(&journal)
2231 .into_iter()
2232 .filter(|box_id| *box_id != old_box)
2233 .collect::<Vec<_>>();
2234 let expected_lengths = if initial.is_empty() {
2235 vec![8, 8, 2]
2236 } else {
2237 vec![8, 8, 1]
2238 };
2239 assert_eq!(
2240 fresh
2241 .iter()
2242 .map(|box_id| connection_summary_entries(
2243 &journal
2244 .state()
2245 .box_state(*box_id)
2246 .unwrap()
2247 .canonical
2248 .content
2249 )
2250 .unwrap()
2251 .len())
2252 .collect::<Vec<_>>(),
2253 expected_lengths
2254 );
2255
2256 drop(journal);
2257 std::fs::remove_dir_all(root).unwrap();
2258 }
2259 }
2260
2261 #[test]
2262 fn load_sync_metadata_only_advance_preserves_visible_and_occurrence_state() {
2263 let (root, mut journal) = test_journal("metadata-only");
2264 let mut context = Context::new(vec![id(1)]).unwrap();
2265 context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
2266 context
2267 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
2268 .unwrap();
2269 let full = box_id_for_logical(&journal, &id(1));
2270 let old = journal.state().box_state(full).unwrap();
2271 let old_name = old.name.clone();
2272 let old_text = old.canonical.content.text.clone();
2273 let old_metadata = old.canonical.content.metadata.clone();
2274 let old_event = old.canonical.event_id;
2275 let old_representation = old.representation.clone();
2276 let old_occurrences = old.occurrence_events.clone();
2277 let layout = journal.state().tool_layouts[KWEB_TOOL_INSTANCE].clone();
2278
2279 let mut refreshed = node(1, &[], &[]);
2280 refreshed.last_modified_at = Some("2026-08-16T00:00:00Z".into());
2281 context.refresh([refreshed]).unwrap();
2282 assert_eq!(
2283 context
2284 .sync_load_chatend(&mut journal, "t2", &BTreeMap::new(), &[])
2285 .unwrap(),
2286 vec![full]
2287 );
2288 let current = journal.state().box_state(full).unwrap();
2289 assert_eq!(current.name, old_name);
2290 assert!(current.active);
2291 assert_eq!(current.canonical.content.text, old_text);
2292 assert_ne!(current.canonical.content.metadata, old_metadata);
2293 assert_ne!(current.canonical.event_id, old_event);
2294 assert_eq!(current.representation, old_representation);
2295 assert_eq!(current.occurrence_events, old_occurrences);
2296 assert_eq!(journal.state().tool_layouts[KWEB_TOOL_INSTANCE], layout);
2297 let current_event = current.canonical.event_id;
2298 assert!(
2299 context
2300 .sync_load_chatend(&mut journal, "t3", &BTreeMap::new(), &[])
2301 .unwrap()
2302 .is_empty()
2303 );
2304 assert_eq!(
2305 journal.state().box_state(full).unwrap().canonical.event_id,
2306 current_event
2307 );
2308
2309 drop(journal);
2310 std::fs::remove_dir_all(root).unwrap();
2311 }
2312
2313 #[test]
2314 fn load_sync_advances_full_and_marker_caches_once_without_visible_changes() {
2315 let (root, mut journal) = test_journal("cache-preservation");
2316 let mut context = Context::new(vec![id(1)]).unwrap();
2317 let indices = (2..=10).collect::<Vec<_>>();
2318 context
2319 .apply_load(
2320 node_with_recent_description(1, &[], &indices, "old"),
2321 Vec::new(),
2322 )
2323 .unwrap();
2324 context
2325 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
2326 .unwrap();
2327 let full = box_id_for_logical(&journal, &id(1));
2328 let connections = all_connection_summary_box_ids(&journal);
2329 journal
2330 .summarize_box("t2", full, "visible full summary")
2331 .unwrap();
2332 journal.dehydrate_boxes("t3", &connections[..1]).unwrap();
2333 let ordered = std::iter::once(full)
2334 .chain(connections.iter().copied())
2335 .collect::<Vec<_>>();
2336 let snapshots = ordered
2337 .iter()
2338 .map(|box_id| {
2339 let state = journal.state().box_state(*box_id).unwrap();
2340 (
2341 state.name.clone(),
2342 state.active,
2343 state.canonical.event_id,
2344 state.representation.clone(),
2345 state.occurrence_events.clone(),
2346 )
2347 })
2348 .collect::<Vec<_>>();
2349 let layout = journal.state().tool_layouts[KWEB_TOOL_INSTANCE].clone();
2350
2351 context
2352 .refresh([node_with_recent_description(1, &[], &indices, "new")])
2353 .unwrap();
2354 assert_eq!(
2355 context
2356 .sync_load_chatend(&mut journal, "t4", &BTreeMap::new(), &[])
2357 .unwrap(),
2358 ordered
2359 );
2360 for (index, box_id) in ordered.iter().enumerate() {
2361 let state = journal.state().box_state(*box_id).unwrap();
2362 assert_eq!(state.name, snapshots[index].0);
2363 assert_eq!(state.active, snapshots[index].1);
2364 assert_ne!(state.canonical.event_id, snapshots[index].2);
2365 assert_eq!(state.representation, snapshots[index].3);
2366 assert_eq!(state.occurrence_events, snapshots[index].4);
2367 }
2368 assert_eq!(journal.state().tool_layouts[KWEB_TOOL_INSTANCE], layout);
2369 let first_entries = connection_summary_entries(
2370 &journal
2371 .state()
2372 .box_state(connections[0])
2373 .unwrap()
2374 .canonical
2375 .content,
2376 )
2377 .unwrap();
2378 assert_eq!(first_entries.len(), 8);
2379 assert!(first_entries.iter().all(|entry| entry.text.contains("new")));
2380 assert!(
2381 context
2382 .sync_load_chatend(&mut journal, "t5", &BTreeMap::new(), &[])
2383 .unwrap()
2384 .is_empty()
2385 );
2386
2387 drop(journal);
2388 std::fs::remove_dir_all(root).unwrap();
2389 }
2390
2391 #[test]
2392 fn load_sync_preserves_legacy_connection_name_heading_and_metadata() {
2393 let (root, mut journal) = test_journal("load-legacy");
2394 let mut context = Context::new(vec![id(1)]).unwrap();
2395 context
2396 .apply_load(
2397 node_with_recent_description(1, &[], &[2, 3], "old"),
2398 Vec::new(),
2399 )
2400 .unwrap();
2401 context
2402 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
2403 .unwrap();
2404 let connection_box = all_connection_summary_box_ids(&journal)[0];
2405 let (mut inputs, layout_slots) = tool_inputs_and_layout(&journal);
2406 for input in &mut inputs {
2407 if input.slot
2408 == journal.state().tools[KWEB_TOOL_INSTANCE]
2409 .slots
2410 .iter()
2411 .find(|slot| slot.box_id == connection_box)
2412 .unwrap()
2413 .slot
2414 {
2415 input.name = "Kweb connection summaries".into();
2416 input.content.text = input.content.text.replacen(
2417 "Connection map markers",
2418 "Connection summaries",
2419 1,
2420 );
2421 input.content.metadata["revisionHash"] = json!(revision_hash(&input.content.text));
2422 input.content.metadata["legacyMetadata"] = json!({"kept": true});
2423 }
2424 }
2425 journal
2426 .apply_tool_slots_with_layout("t2", KWEB_TOOL_INSTANCE, inputs, &layout_slots)
2427 .unwrap();
2428 let old = journal.state().box_state(connection_box).unwrap();
2429 let old_metadata = metadata_without_revision(&old.canonical.content.metadata);
2430 let old_representation = old.representation.clone();
2431 let old_occurrences = old.occurrence_events.clone();
2432 let layout = journal.state().tool_layouts[KWEB_TOOL_INSTANCE].clone();
2433
2434 context
2435 .refresh([node_with_recent_description(1, &[], &[2, 3], "new")])
2436 .unwrap();
2437 let stale = context
2438 .sync_load_chatend(&mut journal, "t3", &BTreeMap::new(), &[])
2439 .unwrap();
2440 assert!(stale.contains(&connection_box));
2441 let current = journal.state().box_state(connection_box).unwrap();
2442 assert_eq!(current.name, "Kweb connection summaries");
2443 assert!(
2444 current
2445 .canonical
2446 .content
2447 .text
2448 .starts_with("Connection summaries\n")
2449 );
2450 assert!(
2451 !current
2452 .canonical
2453 .content
2454 .text
2455 .contains("Connection map markers")
2456 );
2457 assert_eq!(
2458 metadata_without_revision(¤t.canonical.content.metadata),
2459 old_metadata
2460 );
2461 assert_eq!(current.representation, old_representation);
2462 assert_eq!(current.occurrence_events, old_occurrences);
2463 assert_eq!(journal.state().tool_layouts[KWEB_TOOL_INSTANCE], layout);
2464 assert!(
2465 connection_summary_entries(¤t.canonical.content)
2466 .unwrap()
2467 .iter()
2468 .all(|entry| entry.text.contains("new"))
2469 );
2470
2471 drop(journal);
2472 std::fs::remove_dir_all(root).unwrap();
2473 }
2474
2475 #[test]
2476 fn load_sync_keeps_fixed_identity_on_promotion_and_never_retires_shrunk_closure() {
2477 let (root, mut journal) = test_journal("fixed-promotion");
2478 let mut context = Context::with_fixed_connections(vec![id(1)], true).unwrap();
2479 context
2480 .apply_load(
2481 node(1, &[2, 3], &[]),
2482 vec![node(2, &[], &[]), node(3, &[], &[])],
2483 )
2484 .unwrap();
2485 context
2486 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
2487 .unwrap();
2488 let promoted_box = box_id_for_logical(&journal, &id(2));
2489 let removed_box = box_id_for_logical(&journal, &id(3));
2490 let promoted_slot = journal.state().tools[KWEB_TOOL_INSTANCE]
2491 .slots
2492 .iter()
2493 .find(|slot| slot.box_id == promoted_box)
2494 .unwrap()
2495 .slot
2496 .clone();
2497 let removed_event = journal
2498 .state()
2499 .box_state(removed_box)
2500 .unwrap()
2501 .canonical
2502 .event_id;
2503 let layout = journal.state().tool_layouts[KWEB_TOOL_INSTANCE].clone();
2504
2505 let mut promoted = node(2, &[], &[]);
2506 promoted.long_description = "Promoted direct content".into();
2507 context.apply_load(promoted, Vec::new()).unwrap();
2508 context.refresh([node(1, &[], &[])]).unwrap();
2509 let stale = context
2510 .sync_load_chatend(&mut journal, "t2", &BTreeMap::new(), &[])
2511 .unwrap();
2512 assert!(stale.contains(&promoted_box));
2513 let promoted = journal.state().box_state(promoted_box).unwrap();
2514 assert_eq!(promoted.name, "Kweb fixed connection");
2515 assert_eq!(
2516 promoted.canonical.content.metadata["kwebRole"],
2517 json!("fixed")
2518 );
2519 assert!(
2520 promoted
2521 .canonical
2522 .content
2523 .text
2524 .contains("Promoted direct content")
2525 );
2526 let promoted_current_slot = journal.state().tools[KWEB_TOOL_INSTANCE]
2527 .slots
2528 .iter()
2529 .find(|slot| slot.box_id == promoted_box)
2530 .unwrap();
2531 assert_eq!(promoted_current_slot.slot, promoted_slot);
2532 assert!(!promoted_current_slot.retired);
2533 let removed = journal.state().box_state(removed_box).unwrap();
2534 assert!(removed.active);
2535 assert_eq!(removed.canonical.event_id, removed_event);
2536 assert!(
2537 !journal.state().tools[KWEB_TOOL_INSTANCE]
2538 .slots
2539 .iter()
2540 .find(|slot| slot.box_id == removed_box)
2541 .unwrap()
2542 .retired
2543 );
2544 assert_eq!(journal.state().tool_layouts[KWEB_TOOL_INSTANCE], layout);
2545
2546 drop(journal);
2547 std::fs::remove_dir_all(root).unwrap();
2548 }
2549
2550 #[test]
2551 fn load_sync_global_dedupe_counts_duplicate_and_retired_connection_boxes() {
2552 let (root, mut journal) = test_journal("global-dedupe");
2553 let mut context = Context::new(vec![id(1)]).unwrap();
2554 let initial = (2..=18).collect::<Vec<_>>();
2555 context
2556 .apply_load(
2557 node_with_recent_description(1, &[], &initial, "old"),
2558 Vec::new(),
2559 )
2560 .unwrap();
2561 context
2562 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
2563 .unwrap();
2564 let old_boxes = all_connection_summary_box_ids(&journal);
2565 assert_eq!(old_boxes.len(), 3);
2566 let duplicate_entry = connection_summary_entries(
2567 &journal
2568 .state()
2569 .box_state(old_boxes[0])
2570 .unwrap()
2571 .canonical
2572 .content,
2573 )
2574 .unwrap()[0]
2575 .clone();
2576 let (mut inputs, mut layout_slots) = tool_inputs_and_layout(&journal);
2577 let tool = &journal.state().tools[KWEB_TOOL_INSTANCE];
2578 let second_slot = tool
2579 .slots
2580 .iter()
2581 .find(|slot| slot.box_id == old_boxes[1])
2582 .unwrap()
2583 .slot
2584 .clone();
2585 let retired_slot = tool
2586 .slots
2587 .iter()
2588 .find(|slot| slot.box_id == old_boxes[2])
2589 .unwrap()
2590 .slot
2591 .clone();
2592 for input in &mut inputs {
2593 if input.slot == second_slot {
2594 let mut entries = connection_summary_entries(&input.content).unwrap();
2595 entries[0] = duplicate_entry.clone();
2596 let logical = input.content.metadata["kwebLogicalSlot"]
2597 .as_str()
2598 .unwrap()
2599 .to_owned();
2600 update_connection_summary_content(&mut input.content, &logical, &entries);
2601 }
2602 if input.slot == retired_slot {
2603 input.retired = true;
2604 }
2605 }
2606 layout_slots.retain(|slot| slot != &retired_slot);
2607 journal
2608 .apply_tool_slots_with_layout("t2", KWEB_TOOL_INSTANCE, inputs, &layout_slots)
2609 .unwrap();
2610 let old_events = old_boxes
2611 .iter()
2612 .map(|box_id| {
2613 journal
2614 .state()
2615 .box_state(*box_id)
2616 .unwrap()
2617 .canonical
2618 .event_id
2619 })
2620 .collect::<Vec<_>>();
2621 let layout = journal.state().tool_layouts[KWEB_TOOL_INSTANCE].clone();
2622
2623 let expanded = (2..=20).collect::<Vec<_>>();
2624 context
2625 .refresh([node_with_recent_description(1, &[], &expanded, "new")])
2626 .unwrap();
2627 let stale = context
2628 .sync_load_chatend(&mut journal, "t3", &BTreeMap::new(), &[])
2629 .unwrap();
2630 for (index, box_id) in old_boxes.iter().enumerate() {
2631 assert!(!stale.contains(box_id));
2632 assert_eq!(
2633 journal
2634 .state()
2635 .box_state(*box_id)
2636 .unwrap()
2637 .canonical
2638 .event_id,
2639 old_events[index]
2640 );
2641 }
2642 assert_eq!(journal.state().tool_layouts[KWEB_TOOL_INSTANCE], layout);
2643 let fresh = all_connection_summary_box_ids(&journal)
2644 .into_iter()
2645 .filter(|box_id| !old_boxes.contains(box_id))
2646 .collect::<Vec<_>>();
2647 assert_eq!(fresh.len(), 1);
2648 assert_eq!(
2649 connection_summary_entries(
2650 &journal
2651 .state()
2652 .box_state(fresh[0])
2653 .unwrap()
2654 .canonical
2655 .content
2656 )
2657 .unwrap()
2658 .into_iter()
2659 .map(|entry| entry.id)
2660 .collect::<Vec<_>>(),
2661 vec![id(10), id(19), id(20)]
2662 );
2663 assert!(
2664 journal.state().tools[KWEB_TOOL_INSTANCE]
2665 .slots
2666 .iter()
2667 .find(|slot| slot.box_id == old_boxes[2])
2668 .unwrap()
2669 .retired
2670 );
2671
2672 drop(journal);
2673 std::fs::remove_dir_all(root).unwrap();
2674 }
2675}