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