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 specs = self.box_specs(updates, creates)?;
342 let mut desired = Vec::with_capacity(specs.len());
343 let mut connections = None;
344 for spec in specs {
345 let mut metadata = json!({
346 "revisionHash": revision_hash(&spec.text),
347 });
348 if let Some(node) = spec.stored_node {
349 metadata["canonicalNodeId"] = json!(node.id);
350 metadata["storedNode"] = serde_json::to_value(node).map_err(|error| {
351 Error::new(format!("serializing stored Kweb node: {error}"))
352 })?;
353 }
354 if let Some(node) = spec.staged_node {
355 metadata["staged"] = json!(true);
356 metadata["nodeData"] = serde_json::to_value(node).map_err(|error| {
357 Error::new(format!("serializing staged Kweb node: {error}"))
358 })?;
359 }
360 let mut content = BoxContent {
361 text: spec.text,
362 objects: Vec::new(),
363 metadata,
364 };
365 content.use_concise_header();
366 mark_kweb_content(&mut content, &spec.logical_slot, spec.kind.metadata_name());
367 let entry = DesiredKwebBox {
368 logical_slot: spec.logical_slot,
369 name: spec.kind.name().into(),
370 content,
371 };
372 if spec.kind == BoxKind::Connections {
373 if connections.replace(entry).is_some() {
374 return Err(Error::new(
375 "Kweb context produced more than one connection-summary candidate",
376 ));
377 }
378 } else {
379 desired.push(entry);
380 }
381 }
382 let connections = connections
383 .ok_or_else(|| Error::new("Kweb context produced no connection-summary candidate"))?;
384 desired.extend(desired_connection_summary_boxes(journal, connections)?);
385 reconcile_kweb_slots(journal, &recorded_at, desired)?;
386 Ok(changed_kweb_box_ids(journal, &previous))
387 }
388
389 fn full_box(&self, id: &str, kind: BoxKind, update: Option<&NodeDraft>) -> Result<BoxSpec> {
390 let node = self
391 .nodes_by_id
392 .get(id)
393 .ok_or_else(|| Error::new(format!("missing full Kweb node {id}")))?;
394 let data = update.cloned().unwrap_or_else(|| node.draft());
395 Ok(BoxSpec {
396 logical_slot: id.to_owned(),
397 kind,
398 text: format_node(id, &data),
399 stored_node: Some(node.clone()),
400 staged_node: update.cloned(),
401 })
402 }
403
404 fn projected_connection_summaries(
405 &self,
406 updates: &BTreeMap<String, NodeDraft>,
407 creates: &[StagedCreate],
408 ) -> Result<Vec<ConnectionSummaryEntry>> {
409 let creates_by_id = creates
410 .iter()
411 .map(|create| (create.pending_id.as_str(), &create.data))
412 .collect::<HashMap<_, _>>();
413 let mut summaries = HashMap::new();
414 for node in self.nodes_by_id.values() {
415 summaries.insert(
416 node.id.as_str(),
417 (node.short_name.as_str(), node.short_description.as_str()),
418 );
419 for connection in node
420 .fixed_connections
421 .iter()
422 .chain(&node.recent_connections)
423 {
424 summaries.entry(connection.id.as_str()).or_insert((
425 connection.short_name.as_str(),
426 connection.short_description.as_str(),
427 ));
428 }
429 }
430 let mut connection_ids = Vec::new();
431 let mut seen = HashSet::new();
432 for id in self.full_node_ids() {
433 let node = self
434 .nodes_by_id
435 .get(id)
436 .ok_or_else(|| Error::new(format!("missing full Kweb node {id}")))?;
437 if let Some(draft) = updates.get(id) {
438 for connection_id in draft
439 .fixed_connections
440 .iter()
441 .chain(&draft.recent_connections)
442 {
443 if seen.insert(connection_id.clone()) {
444 connection_ids.push(connection_id.clone());
445 }
446 }
447 } else {
448 for connection in node
449 .fixed_connections
450 .iter()
451 .chain(&node.recent_connections)
452 {
453 if seen.insert(connection.id.clone()) {
454 connection_ids.push(connection.id.clone());
455 }
456 }
457 }
458 }
459 for create in creates {
460 for connection_id in create
461 .data
462 .fixed_connections
463 .iter()
464 .chain(&create.data.recent_connections)
465 {
466 if seen.insert(connection_id.clone()) {
467 connection_ids.push(connection_id.clone());
468 }
469 }
470 }
471 let mut entries = Vec::with_capacity(connection_ids.len());
472 for id in connection_ids {
473 let staged_summary = updates
474 .get(&id)
475 .or_else(|| creates_by_id.get(id.as_str()).copied())
476 .map(|node| (node.short_name.as_str(), node.short_description.as_str()));
477 let (name, description) = staged_summary
478 .or_else(|| summaries.get(id.as_str()).copied())
479 .ok_or_else(|| {
480 Error::new(format!(
481 "connection map marker {id} must resolve to a nonempty node name and map marker"
482 ))
483 })?;
484 if name.trim().is_empty() || description.trim().is_empty() {
485 return Err(Error::new(format!(
486 "connection map marker {id} must resolve to a nonempty node name and map marker"
487 )));
488 }
489 let text = format!("{id} · {name}: {description}");
490 entries.push(ConnectionSummaryEntry { id, text });
491 }
492 Ok(entries)
493 }
494
495 fn rebuild_fixed(&mut self) {
496 let loaded = self.loaded_node_ids.iter().cloned().collect::<HashSet<_>>();
497 if !self.load_fixed_connections {
498 self.fixed_node_ids.clear();
499 self.nodes_by_id.retain(|id, _| loaded.contains(id));
500 return;
501 }
502 let mut seen = loaded.clone();
503 let mut fixed = Vec::new();
504 for id in &self.loaded_node_ids {
505 let Some(node) = self.nodes_by_id.get(id) else {
506 continue;
507 };
508 for connection in &node.fixed_connections {
509 if self.nodes_by_id.contains_key(&connection.id)
510 && seen.insert(connection.id.clone())
511 {
512 fixed.push(connection.id.clone());
513 }
514 }
515 }
516 self.fixed_node_ids = fixed;
517 self.nodes_by_id
518 .retain(|id, _| loaded.contains(id) || seen.contains(id));
519 }
520}
521
522struct DesiredKwebBox {
523 logical_slot: String,
524 name: String,
525 content: BoxContent,
526}
527
528#[derive(Clone, Debug, Eq, PartialEq)]
529struct ConnectionSummaryEntry {
530 id: String,
531 text: String,
532}
533
534fn mark_kweb_content(content: &mut BoxContent, logical_slot: &str, role: &str) {
535 if !content.metadata.is_object() {
536 content.metadata = json!({});
537 }
538 content.metadata["kwebLogicalSlot"] = json!(logical_slot);
539 content.metadata["kwebRole"] = json!(role);
540}
541
542fn kweb_logical_slot(state: &BoxState, actual_slot: &str) -> String {
543 state
544 .canonical
545 .content
546 .metadata
547 .get("kwebLogicalSlot")
548 .and_then(Value::as_str)
549 .unwrap_or(actual_slot)
550 .to_owned()
551}
552
553fn connection_summary_entries(content: &BoxContent) -> Result<Vec<ConnectionSummaryEntry>> {
554 let body = ["Connection map markers", "Connection summaries"]
555 .into_iter()
556 .find_map(|heading| {
557 content
558 .text
559 .strip_prefix(heading)
560 .and_then(|body| (body.is_empty() || body.starts_with('\n')).then_some(body))
561 })
562 .ok_or_else(|| Error::new("Kweb connection-summary box has an invalid heading"))?;
563 let body = body
564 .strip_prefix('\n')
565 .ok_or_else(|| Error::new("Kweb connection-summary box has no body"))?;
566 if body == "None." {
567 return Ok(Vec::new());
568 }
569
570 let mut entries: Vec<ConnectionSummaryEntry> = Vec::new();
571 for line in body.split('\n') {
572 let identifier = line.split_once(" · ").and_then(|(identifier, _)| {
573 let canonical = identifier.parse::<NodeId>().is_ok();
574 let pending = PendingId::parse(identifier.to_owned()).is_ok();
575 (canonical || pending).then_some(identifier)
576 });
577 if let Some(identifier) = identifier {
578 entries.push(ConnectionSummaryEntry {
579 id: identifier.to_owned(),
580 text: line.to_owned(),
581 });
582 } else {
583 let entry = entries.last_mut().ok_or_else(|| {
584 Error::new("Kweb connection-summary box starts with invalid entry text")
585 })?;
586 entry.text.push('\n');
587 entry.text.push_str(line);
588 }
589 }
590
591 if let Some(expected) = content
592 .metadata
593 .get(CONNECTION_SUMMARY_IDS_METADATA)
594 .and_then(Value::as_array)
595 {
596 let expected = expected
597 .iter()
598 .map(|value| {
599 value.as_str().ok_or_else(|| {
600 Error::new("Kweb connection-summary IDs metadata contains a non-string value")
601 })
602 })
603 .collect::<Result<Vec<_>>>()?;
604 if expected
605 != entries
606 .iter()
607 .map(|entry| entry.id.as_str())
608 .collect::<Vec<_>>()
609 {
610 return Err(Error::new(
611 "Kweb connection-summary IDs metadata does not match its canonical text",
612 ));
613 }
614 }
615 Ok(entries)
616}
617
618fn format_connection_summary_entries(entries: &[ConnectionSummaryEntry]) -> String {
619 if entries.is_empty() {
620 return "Connection map markers\nNone.".into();
621 }
622 format!(
623 "Connection map markers\n{}",
624 entries
625 .iter()
626 .map(|entry| entry.text.as_str())
627 .collect::<Vec<_>>()
628 .join("\n")
629 )
630}
631
632fn revision_hash(text: &str) -> String {
633 hex::encode(Sha256::digest(text.as_bytes()))
634}
635
636fn update_connection_summary_content(
637 content: &mut BoxContent,
638 logical_slot: &str,
639 entries: &[ConnectionSummaryEntry],
640) {
641 content.text = format_connection_summary_entries(entries);
642 mark_kweb_content(content, logical_slot, "connection-summary");
643 content.metadata["revisionHash"] = json!(revision_hash(&content.text));
644 content.metadata[CONNECTION_SUMMARY_IDS_METADATA] = json!(
645 entries
646 .iter()
647 .map(|entry| entry.id.as_str())
648 .collect::<Vec<_>>()
649 );
650}
651
652fn desired_connection_summary_boxes(
653 journal: &HistorySession,
654 fresh: DesiredKwebBox,
655) -> Result<Vec<DesiredKwebBox>> {
656 let mut boxes = Vec::new();
657 let mut seen = HashSet::new();
658 let mut used_logical_slots = HashSet::new();
659 let fresh_entries = connection_summary_entries(&fresh.content)?;
660 let fresh_by_id = fresh_entries
661 .iter()
662 .map(|entry| (entry.id.as_str(), entry.text.as_str()))
663 .collect::<HashMap<_, _>>();
664
665 if let Some(tool) = journal.state().tools.get(KWEB_TOOL_INSTANCE) {
666 for slot in &tool.slots {
667 let state = journal
668 .state()
669 .box_state(slot.box_id)
670 .ok_or_else(|| Error::new("Kweb tool slot box is missing"))?;
671 let logical_slot = kweb_logical_slot(state, &slot.slot);
672 used_logical_slots.insert(logical_slot.clone());
673 if slot.retired
674 || state
675 .canonical
676 .content
677 .metadata
678 .get("kwebRole")
679 .and_then(Value::as_str)
680 != Some("connection-summary")
681 {
682 continue;
683 }
684 let mut entries = connection_summary_entries(&state.canonical.content)?;
685 for entry in &mut entries {
686 if let Some(text) = fresh_by_id.get(entry.id.as_str()) {
687 entry.text = (*text).to_owned();
688 }
689 }
690 for entry in &entries {
691 seen.insert(entry.id.clone());
692 }
693 let mut content = state.canonical.content.clone();
694 update_connection_summary_content(&mut content, &logical_slot, &entries);
695 boxes.push(DesiredKwebBox {
696 logical_slot,
697 name: fresh.name.clone(),
698 content,
699 });
700 }
701 }
702
703 let additions = fresh_entries
704 .iter()
705 .filter(|entry| seen.insert(entry.id.clone()))
706 .cloned()
707 .collect::<Vec<_>>();
708 let mut next_addition = 0;
709
710 while next_addition < additions.len() || boxes.is_empty() {
711 let end = (next_addition + CONNECTION_SUMMARIES_PER_BOX).min(additions.len());
712 let entries = additions[next_addition..end].to_vec();
713 let mut sequence = boxes.len() + 1;
714 let logical_slot = loop {
715 let candidate = if sequence == 1 {
716 CONNECTION_SUMMARIES_LOGICAL_SLOT.to_owned()
717 } else {
718 format!("{CONNECTION_SUMMARIES_LOGICAL_SLOT}:{sequence}")
719 };
720 if used_logical_slots.insert(candidate.clone()) {
721 break candidate;
722 }
723 sequence += 1;
724 };
725 let mut content = fresh.content.clone();
726 update_connection_summary_content(&mut content, &logical_slot, &entries);
727 boxes.push(DesiredKwebBox {
728 logical_slot,
729 name: fresh.name.clone(),
730 content,
731 });
732 next_addition = end;
733 }
734
735 Ok(boxes)
736}
737
738type KwebBoxVersions = BTreeMap<BoxId, (String, EventId)>;
739
740fn kweb_box_versions(journal: &HistorySession) -> KwebBoxVersions {
741 journal
742 .state()
743 .tool_layouts
744 .get(KWEB_TOOL_INSTANCE)
745 .into_iter()
746 .flatten()
747 .filter_map(|box_id| {
748 let state = journal.state().box_state(*box_id)?;
749 state
750 .active
751 .then(|| (*box_id, (state.name.clone(), state.canonical.event_id)))
752 })
753 .collect()
754}
755
756fn changed_kweb_box_ids(journal: &HistorySession, previous: &KwebBoxVersions) -> Vec<BoxId> {
757 journal
758 .state()
759 .tool_layouts
760 .get(KWEB_TOOL_INSTANCE)
761 .into_iter()
762 .flatten()
763 .filter_map(|box_id| {
764 let state = journal.state().box_state(*box_id)?;
765 let current = (state.name.as_str(), state.canonical.event_id);
766 let changed = previous
767 .get(box_id)
768 .map(|(name, revision)| (name.as_str(), *revision) != current)
769 .unwrap_or(true);
770 (state.active && changed).then_some(*box_id)
771 })
772 .collect()
773}
774
775fn unique_slot(logical: &str, used: &mut HashSet<String>) -> String {
776 if used.insert(logical.to_owned()) {
777 return logical.to_owned();
778 }
779 let mut generation = 2_u64;
780 loop {
781 let candidate = format!("{logical}#generation-{generation}");
782 if used.insert(candidate.clone()) {
783 return candidate;
784 }
785 generation += 1;
786 }
787}
788
789fn reconcile_kweb_slots(
790 journal: &mut HistorySession,
791 recorded_at: &str,
792 desired: Vec<DesiredKwebBox>,
793) -> Result<()> {
794 let current = journal
795 .state()
796 .tools
797 .get(KWEB_TOOL_INSTANCE)
798 .cloned()
799 .unwrap_or_default();
800 let desired_by_logical = desired
801 .iter()
802 .enumerate()
803 .map(|(index, entry)| (entry.logical_slot.as_str(), index))
804 .collect::<BTreeMap<_, _>>();
805 if desired_by_logical.len() != desired.len() {
806 return Err(Error::new(
807 "Kweb box layout contains duplicate logical slots",
808 ));
809 }
810 let mut claimed = HashSet::new();
811 let mut actual_by_desired = BTreeMap::new();
812 let mut slots = Vec::with_capacity(current.slots.len() + desired.len());
813 let mut used_actual = current
814 .slots
815 .iter()
816 .map(|slot| slot.slot.clone())
817 .collect::<HashSet<_>>();
818 for slot in ¤t.slots {
819 let state = journal
820 .state()
821 .box_state(slot.box_id)
822 .ok_or_else(|| Error::new("Kweb tool slot box is missing"))?;
823 let logical = kweb_logical_slot(state, &slot.slot);
824 let selected = !slot.retired
825 && desired_by_logical.contains_key(logical.as_str())
826 && claimed.insert(logical.clone());
827 if selected {
828 let entry = &desired[desired_by_logical[logical.as_str()]];
829 slots.push(ToolSlotInput {
830 slot: slot.slot.clone(),
831 name: entry.name.clone(),
832 content: entry.content.clone(),
833 retired: false,
834 });
835 actual_by_desired.insert(entry.logical_slot.clone(), slot.slot.clone());
836 } else {
837 slots.push(ToolSlotInput {
838 slot: slot.slot.clone(),
839 name: state.name.clone(),
840 content: state.canonical.content.clone(),
841 retired: slot.retired || !selected,
842 });
843 }
844 }
845 for entry in &desired {
846 if actual_by_desired.contains_key(&entry.logical_slot) {
847 continue;
848 }
849 let actual = unique_slot(&entry.logical_slot, &mut used_actual);
850 slots.push(ToolSlotInput {
851 slot: actual.clone(),
852 name: entry.name.clone(),
853 content: entry.content.clone(),
854 retired: false,
855 });
856 actual_by_desired.insert(entry.logical_slot.clone(), actual);
857 }
858 let layout_slots = desired
859 .iter()
860 .map(|entry| actual_by_desired[&entry.logical_slot].clone())
861 .collect::<Vec<_>>();
862 journal
863 .apply_tool_slots_with_layout(recorded_at, KWEB_TOOL_INSTANCE, slots, &layout_slots)
864 .map_err(|error| Error::new(format!("applying Kweb projection: {error}")))?;
865 Ok(())
866}
867
868fn canonical_node_id(value: &str) -> Result<()> {
869 value
870 .parse::<NodeId>()
871 .map(|_| ())
872 .map_err(|_| Error::new(format!("{value:?} is not a canonical Kweb node ID")))
873}
874
875#[cfg(test)]
876mod tests {
877 use std::path::PathBuf;
878 use std::time::{SystemTime, UNIX_EPOCH};
879
880 use super::*;
881 use kcode_session_history::{
882 Config as HistoryConfig, NewSession, SessionHistory,
883 chatend::{Representation, SessionKind},
884 };
885 use serde_json::json;
886
887 fn id(index: u8) -> String {
888 NodeId::from_bytes([0, 0, 0, 0, 0, index])
889 .unwrap()
890 .to_string()
891 }
892
893 fn connection(index: u8) -> Connection {
894 Connection {
895 id: id(index),
896 short_name: format!("Node {index}"),
897 short_description: format!("Summary {index}"),
898 }
899 }
900
901 fn node(index: u8, fixed: &[u8], recent: &[u8]) -> Node {
902 Node {
903 id: id(index),
904 short_name: format!("Node {index}"),
905 short_description: format!("Summary {index}"),
906 long_description: format!("Long description {index}"),
907 owner: id(1),
908 fixed_connections: fixed.iter().copied().map(connection).collect(),
909 recent_connections: recent.iter().copied().map(connection).collect(),
910 objects: vec![],
911 last_modified_by: "test-model-high".into(),
912 last_modified_at: Some("2026-07-28T00:00:00Z".into()),
913 }
914 }
915
916 fn node_with_recent_description(
917 index: u8,
918 fixed: &[u8],
919 recent: &[u8],
920 description: &str,
921 ) -> Node {
922 let mut node = node(index, fixed, recent);
923 for (connection, connection_index) in node.recent_connections.iter_mut().zip(recent) {
924 connection.short_description = format!("{description} {connection_index}");
925 }
926 node
927 }
928
929 fn draft(index: u8, recent: &[u8]) -> NodeDraft {
930 NodeDraft {
931 short_name: format!("Node {index}"),
932 short_description: format!("Summary {index}"),
933 long_description: format!("Long description {index}"),
934 owner: id(1),
935 fixed_connections: Vec::new(),
936 recent_connections: recent.iter().map(|value| id(*value)).collect(),
937 objects: Vec::new(),
938 }
939 }
940
941 fn test_journal(label: &str) -> (PathBuf, HistorySession) {
942 let root = std::env::temp_dir().join(format!(
943 "kcode-kweb-context-{label}-{}-{}",
944 std::process::id(),
945 SystemTime::now()
946 .duration_since(UNIX_EPOCH)
947 .unwrap()
948 .as_nanos()
949 ));
950 let history = SessionHistory::open(HistoryConfig {
951 directory: root.join("sessions"),
952 completed_list: root.join("completed.jsonl"),
953 provider_cost_compatibility: None,
954 })
955 .unwrap();
956 let journal = history
957 .create_session(NewSession {
958 kind: SessionKind::Conversation,
959 created_at: "2026-07-29T00:00:00Z".into(),
960 effective_context_tokens: 10_000,
961 channel: Value::Null,
962 })
963 .unwrap();
964 (root, journal)
965 }
966
967 fn connection_summary_box_ids(journal: &HistorySession) -> Vec<BoxId> {
968 journal
969 .state()
970 .tool_layouts
971 .get(KWEB_TOOL_INSTANCE)
972 .into_iter()
973 .flatten()
974 .copied()
975 .filter(|box_id| {
976 journal
977 .state()
978 .box_state(*box_id)
979 .and_then(|state| state.canonical.content.metadata.get("kwebRole"))
980 .and_then(Value::as_str)
981 == Some("connection-summary")
982 })
983 .collect()
984 }
985
986 fn metadata_without_revision(metadata: &Value) -> Value {
987 let mut metadata = metadata.clone();
988 metadata.as_object_mut().unwrap().remove("revisionHash");
989 metadata
990 }
991
992 #[test]
993 fn compatibility_fixed_nodes_yield_to_direct_loads() {
994 let mut context = Context::with_fixed_connections(vec![id(1)], true).unwrap();
995 context
996 .apply_load(node(1, &[2], &[]), vec![node(2, &[], &[])])
997 .unwrap();
998 let report = context.apply_load(node(2, &[], &[]), Vec::new()).unwrap();
999 assert!(report.promoted_from_fixed);
1000 assert_eq!(context.loaded_node_ids(), &[id(1), id(2)]);
1001 assert!(context.fixed_node_ids().is_empty());
1002 assert_eq!(
1003 context
1004 .box_specs(&BTreeMap::new(), &[])
1005 .unwrap()
1006 .iter()
1007 .map(|spec| spec.kind)
1008 .collect::<Vec<_>>(),
1009 vec![BoxKind::Loaded, BoxKind::Loaded, BoxKind::Connections]
1010 );
1011 }
1012
1013 #[test]
1014 fn default_projection_keeps_only_direct_nodes_full() {
1015 let mut context = Context::new(vec![id(1)]).unwrap();
1016 context.apply_load(node(1, &[2], &[3]), Vec::new()).unwrap();
1017
1018 let projected = context.projection(&BTreeMap::new(), &[]).unwrap();
1019 assert_eq!(
1020 projected
1021 .iter()
1022 .map(|item| item.key.clone())
1023 .collect::<Vec<_>>(),
1024 vec![
1025 id(1),
1026 format!("connection-summaries:{}", id(2)),
1027 format!("connection-summaries:{}", id(3)),
1028 ]
1029 );
1030 assert_eq!(projected[0].name, "Kweb loaded node");
1031 assert!(projected[0].text.contains("Long description 1"));
1032 assert_eq!(projected[1].name, "Kweb connection map marker");
1033 assert!(projected[1].text.contains(&id(2)));
1034 assert!(!projected[1].text.contains(&id(3)));
1035 assert_eq!(projected[2].name, "Kweb connection map marker");
1036 assert!(projected[2].text.contains(&id(3)));
1037 assert!(!projected[2].text.contains(&id(2)));
1038 assert!(!context.contains_full_node(&id(2)));
1039 }
1040
1041 #[test]
1042 fn box_free_connection_states_change_independently() {
1043 let mut context = Context::new(vec![id(1)]).unwrap();
1044 context
1045 .apply_load(
1046 node_with_recent_description(1, &[], &[2, 3], "old"),
1047 Vec::new(),
1048 )
1049 .unwrap();
1050 let initial = context
1051 .projection(&BTreeMap::new(), &[])
1052 .unwrap()
1053 .into_iter()
1054 .map(|item| (item.key, item.text))
1055 .collect::<BTreeMap<_, _>>();
1056
1057 context
1058 .refresh([node_with_recent_description(1, &[], &[2, 3, 4], "old")])
1059 .unwrap();
1060 let expanded = context
1061 .projection(&BTreeMap::new(), &[])
1062 .unwrap()
1063 .into_iter()
1064 .map(|item| (item.key, item.text))
1065 .collect::<BTreeMap<_, _>>();
1066 assert_eq!(
1067 expanded[&format!("connection-summaries:{}", id(2))],
1068 initial[&format!("connection-summaries:{}", id(2))]
1069 );
1070 assert_eq!(
1071 expanded[&format!("connection-summaries:{}", id(3))],
1072 initial[&format!("connection-summaries:{}", id(3))]
1073 );
1074 assert!(expanded.contains_key(&format!("connection-summaries:{}", id(4))));
1075
1076 let mut changed_node = node_with_recent_description(1, &[], &[2, 3, 4], "old");
1077 changed_node.recent_connections[1].short_description = "changed 3".into();
1078 context.refresh([changed_node]).unwrap();
1079 let changed = context
1080 .projection(&BTreeMap::new(), &[])
1081 .unwrap()
1082 .into_iter()
1083 .map(|item| (item.key, item.text))
1084 .collect::<BTreeMap<_, _>>();
1085 let second = format!("connection-summaries:{}", id(2));
1086 let third = format!("connection-summaries:{}", id(3));
1087 let fourth = format!("connection-summaries:{}", id(4));
1088 assert_eq!(changed[&second], expanded[&second]);
1089 assert_ne!(changed[&third], expanded[&third]);
1090 assert_eq!(changed[&fourth], expanded[&fourth]);
1091 assert_eq!(changed[&id(1)], expanded[&id(1)]);
1092 }
1093
1094 #[test]
1095 fn full_node_kinds_share_one_body_format_without_active_connections() {
1096 let mut context = Context::with_fixed_connections(vec![id(1)], true).unwrap();
1097 context
1098 .apply_load(node(1, &[2], &[]), vec![node(2, &[], &[])])
1099 .unwrap();
1100 let create = StagedCreate {
1101 pending_id: "pending:1".into(),
1102 data: draft(3, &[]),
1103 };
1104 let specs = context.box_specs(&BTreeMap::new(), &[create]).unwrap();
1105 assert_eq!(specs[0].kind.name(), "Kweb loaded node");
1106 assert_eq!(specs[1].kind.name(), "Kweb fixed connection");
1107 assert_eq!(specs[2].kind.name(), "Kweb staged node");
1108 for spec in &specs[..3] {
1109 assert!(spec.text.contains("Node ID:"));
1110 assert!(spec.text.contains("Node name:"));
1111 assert!(spec.text.contains("Node owner ID:"));
1112 assert!(spec.text.contains("Fixed connection IDs:"));
1113 assert!(spec.text.contains("Recent connection IDs:"));
1114 assert!(!spec.text.contains("Active"));
1115 }
1116 assert_eq!(
1117 specs[0].text,
1118 concat!(
1119 "Node ID: AAAAAAAB\n",
1120 "Node name: Node 1\n",
1121 "Map marker: Summary 1\n",
1122 "Node owner ID: AAAAAAAB\n",
1123 "Node long description:\n",
1124 " Long description 1\n",
1125 "Fixed connection IDs: AAAAAAAC\n",
1126 "Recent connection IDs: none"
1127 )
1128 );
1129 }
1130
1131 #[test]
1132 fn fixed_and_recent_connections_share_one_ordered_deduplicated_box() {
1133 let mut context = Context::new(vec![id(1)]).unwrap();
1134 context
1135 .apply_load(node(1, &[2], &[4, 5]), Vec::new())
1136 .unwrap();
1137 context
1138 .apply_load(node(2, &[8], &[5, 6, 7]), Vec::new())
1139 .unwrap();
1140 let creates = vec![StagedCreate {
1141 pending_id: "pending:1".into(),
1142 data: draft(3, &[6, 7]),
1143 }];
1144 let specs = context.box_specs(&BTreeMap::new(), &creates).unwrap();
1145 let connections = specs
1146 .iter()
1147 .filter(|spec| spec.kind == BoxKind::Connections)
1148 .collect::<Vec<_>>();
1149 assert_eq!(connections.len(), 1);
1150 assert_eq!(
1151 connections[0].text,
1152 format!(
1153 concat!(
1154 "Connection map markers\n",
1155 "{} · Node 2: Summary 2\n",
1156 "{} · Node 4: Summary 4\n",
1157 "{} · Node 5: Summary 5\n",
1158 "{} · Node 8: Summary 8\n",
1159 "{} · Node 6: Summary 6\n",
1160 "{} · Node 7: Summary 7"
1161 ),
1162 id(2),
1163 id(4),
1164 id(5),
1165 id(8),
1166 id(6),
1167 id(7)
1168 )
1169 );
1170 }
1171
1172 #[test]
1173 fn empty_connection_projection_is_still_one_exact_box() {
1174 let mut context = Context::new(vec![id(1)]).unwrap();
1175 context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1176 let specs = context.box_specs(&BTreeMap::new(), &[]).unwrap();
1177 assert_eq!(specs.len(), 2);
1178 assert_eq!(specs[1].kind, BoxKind::Connections);
1179 assert_eq!(specs[1].text, "Connection map markers\nNone.");
1180 }
1181
1182 #[test]
1183 fn connection_projection_rejects_missing_name_or_description() {
1184 for missing_name in [true, false] {
1185 let mut source = node(1, &[], &[2]);
1186 if missing_name {
1187 source.recent_connections[0].short_name.clear();
1188 } else {
1189 source.recent_connections[0].short_description.clear();
1190 }
1191 let mut context = Context::new(vec![id(1)]).unwrap();
1192 context.apply_load(source, Vec::new()).unwrap();
1193 assert_eq!(
1194 context
1195 .box_specs(&BTreeMap::new(), &[])
1196 .unwrap_err()
1197 .to_string(),
1198 format!(
1199 "connection map marker {} must resolve to a nonempty node name and map marker",
1200 id(2)
1201 )
1202 );
1203 }
1204 }
1205
1206 #[test]
1207 fn connection_summary_entries_accept_only_legacy_and_new_headings() {
1208 let entry = format!("{} · Node 2: Summary 2", id(2));
1209 for heading in ["Connection summaries", "Connection map markers"] {
1210 let content = BoxContent {
1211 text: format!("{heading}\n{entry}"),
1212 objects: Vec::new(),
1213 metadata: json!({CONNECTION_SUMMARY_IDS_METADATA: [id(2)]}),
1214 };
1215 assert_eq!(
1216 connection_summary_entries(&content).unwrap(),
1217 vec![ConnectionSummaryEntry {
1218 id: id(2),
1219 text: entry.clone(),
1220 }]
1221 );
1222 }
1223
1224 for heading in [
1225 "Connection summary",
1226 "Connection map marker",
1227 "Connection map markers extra",
1228 ] {
1229 let content = BoxContent {
1230 text: format!("{heading}\n{entry}"),
1231 objects: Vec::new(),
1232 metadata: json!({}),
1233 };
1234 assert_eq!(
1235 connection_summary_entries(&content)
1236 .unwrap_err()
1237 .to_string(),
1238 "Kweb connection-summary box has an invalid heading"
1239 );
1240 }
1241
1242 let content = BoxContent {
1243 text: "Connection map markers".into(),
1244 objects: Vec::new(),
1245 metadata: json!({}),
1246 };
1247 assert_eq!(
1248 connection_summary_entries(&content)
1249 .unwrap_err()
1250 .to_string(),
1251 "Kweb connection-summary box has no body"
1252 );
1253 }
1254
1255 #[test]
1256 fn fresh_connection_marker_rendering_uses_only_new_terminology() {
1257 let mut context = Context::new(vec![id(1)]).unwrap();
1258 context.apply_load(node(1, &[2], &[]), Vec::new()).unwrap();
1259
1260 let specs = context.box_specs(&BTreeMap::new(), &[]).unwrap();
1261 let markers = specs.last().unwrap();
1262 assert_eq!(markers.kind.name(), "Kweb connection map markers");
1263 assert!(markers.text.starts_with("Connection map markers\n"));
1264 assert!(!markers.text.contains("Connection summaries"));
1265
1266 let projected = context.projection(&BTreeMap::new(), &[]).unwrap();
1267 assert_eq!(projected[1].name, "Kweb connection map marker");
1268 assert!(!projected[1].name.contains("summary"));
1269 }
1270
1271 #[test]
1272 fn staged_updates_drive_full_text_and_recent_projection() {
1273 let mut context = Context::new(vec![id(1)]).unwrap();
1274 context.apply_load(node(1, &[3], &[2]), Vec::new()).unwrap();
1275 let mut updates = BTreeMap::new();
1276 updates.insert(id(1), draft(9, &[3]));
1277 let specs = context.box_specs(&updates, &[]).unwrap();
1278 assert!(specs[0].text.contains("Node name: Node 9"));
1279 assert!(specs[0].staged_node.is_some());
1280 assert!(!specs.last().unwrap().text.contains(&id(2)));
1281 assert!(specs.last().unwrap().text.contains(&id(3)));
1282 }
1283
1284 #[test]
1285 fn sync_appends_fresh_connection_boxes_eight_at_a_time() {
1286 let (root, mut journal) = test_journal("connection-boxes");
1287 let mut context = Context::new(vec![id(1)]).unwrap();
1288 let initial_indices = (2..=19).collect::<Vec<_>>();
1289 context
1290 .apply_load(
1291 node_with_recent_description(1, &[], &initial_indices, "old"),
1292 Vec::new(),
1293 )
1294 .unwrap();
1295 context
1296 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1297 .unwrap();
1298
1299 let original_ids = connection_summary_box_ids(&journal);
1300 assert_eq!(
1301 original_ids
1302 .iter()
1303 .map(|box_id| {
1304 connection_summary_entries(
1305 &journal
1306 .state()
1307 .box_state(*box_id)
1308 .unwrap()
1309 .canonical
1310 .content,
1311 )
1312 .unwrap()
1313 .len()
1314 })
1315 .collect::<Vec<_>>(),
1316 vec![8, 8, 2]
1317 );
1318 let original_revisions = original_ids
1319 .iter()
1320 .map(|box_id| {
1321 journal
1322 .state()
1323 .box_state(*box_id)
1324 .unwrap()
1325 .canonical
1326 .event_id
1327 })
1328 .collect::<Vec<_>>();
1329 journal
1330 .summarize_box("t2", original_ids[0], "retained first box")
1331 .unwrap();
1332 journal.dehydrate_boxes("t3", &original_ids[1..=2]).unwrap();
1333
1334 let expanded_indices = (2..=28).collect::<Vec<_>>();
1335 context
1336 .refresh([node_with_recent_description(
1337 1,
1338 &[],
1339 &expanded_indices,
1340 "new",
1341 )])
1342 .unwrap();
1343 let changed = context
1344 .sync_chatend(&mut journal, "t4", &BTreeMap::new(), &[])
1345 .unwrap();
1346 let current_ids = connection_summary_box_ids(&journal);
1347 assert_eq!(
1348 current_ids
1349 .iter()
1350 .map(|box_id| {
1351 connection_summary_entries(
1352 &journal
1353 .state()
1354 .box_state(*box_id)
1355 .unwrap()
1356 .canonical
1357 .content,
1358 )
1359 .unwrap()
1360 .len()
1361 })
1362 .collect::<Vec<_>>(),
1363 vec![8, 8, 2, 8, 1]
1364 );
1365 assert_eq!(¤t_ids[..3], original_ids.as_slice());
1366 assert!(changed.contains(&original_ids[0]));
1367 assert!(changed.contains(&original_ids[1]));
1368 assert!(changed.contains(&original_ids[2]));
1369 assert!(changed.contains(¤t_ids[3]));
1370 assert!(changed.contains(¤t_ids[4]));
1371
1372 let first = journal.state().box_state(original_ids[0]).unwrap();
1373 assert_ne!(first.canonical.event_id, original_revisions[0]);
1374 assert!(first.canonical.content.text.contains("new 2"));
1375 assert!(matches!(
1376 first.representation,
1377 Representation::Summarized { based_on, .. } if based_on == original_revisions[0]
1378 ));
1379 let second = journal.state().box_state(original_ids[1]).unwrap();
1380 assert_ne!(second.canonical.event_id, original_revisions[1]);
1381 assert!(second.canonical.content.text.contains("new 10"));
1382 assert!(matches!(
1383 second.representation,
1384 Representation::Dehydrated { based_on } if based_on == original_revisions[1]
1385 ));
1386 let third = journal.state().box_state(original_ids[2]).unwrap();
1387 assert_ne!(third.canonical.event_id, original_revisions[2]);
1388 assert!(!third.canonical.content.text.contains("new 25"));
1389 assert!(third.canonical.content.text.contains("new 19"));
1390 assert!(matches!(
1391 third.representation,
1392 Representation::Dehydrated { based_on } if based_on == original_revisions[2]
1393 ));
1394 let fourth = journal.state().box_state(current_ids[3]).unwrap();
1395 assert!(fourth.canonical.content.text.contains("new 20"));
1396 assert!(fourth.canonical.content.text.contains("new 27"));
1397 assert!(!fourth.canonical.content.text.contains("new 28"));
1398 let fifth = journal.state().box_state(current_ids[4]).unwrap();
1399 assert!(fifth.canonical.content.text.contains("new 28"));
1400
1401 drop(journal);
1402 std::fs::remove_dir_all(root).unwrap();
1403 }
1404
1405 #[test]
1406 fn ordinary_sync_upgrades_legacy_text_without_membership_or_state_changes() {
1407 let (root, mut journal) = test_journal("legacy-heading-upgrade");
1408 let mut context = Context::new(vec![id(1)]).unwrap();
1409 let indices = (2..=10).collect::<Vec<_>>();
1410 context
1411 .apply_load(node(1, &[], &indices), Vec::new())
1412 .unwrap();
1413 context
1414 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1415 .unwrap();
1416
1417 let original_ids = connection_summary_box_ids(&journal);
1418 assert_eq!(original_ids.len(), 2);
1419 let current_tool = journal.state().tools[KWEB_TOOL_INSTANCE].clone();
1420 let layout_slots = journal.state().tool_layouts[KWEB_TOOL_INSTANCE]
1421 .iter()
1422 .map(|box_id| {
1423 current_tool
1424 .slots
1425 .iter()
1426 .find(|slot| slot.box_id == *box_id)
1427 .unwrap()
1428 .slot
1429 .clone()
1430 })
1431 .collect::<Vec<_>>();
1432 let legacy_slots = current_tool
1433 .slots
1434 .iter()
1435 .map(|slot| {
1436 let state = journal.state().box_state(slot.box_id).unwrap();
1437 let mut content = state.canonical.content.clone();
1438 let mut name = state.name.clone();
1439 if content.metadata.get("kwebRole").and_then(Value::as_str)
1440 == Some("connection-summary")
1441 {
1442 content.text =
1443 content
1444 .text
1445 .replacen("Connection map markers", "Connection summaries", 1);
1446 content.metadata["revisionHash"] = json!(revision_hash(&content.text));
1447 content.metadata["retainedCompatibilityMetadata"] =
1448 json!(content.metadata["kwebLogicalSlot"].clone());
1449 name = "Kweb connection summaries".into();
1450 }
1451 ToolSlotInput {
1452 slot: slot.slot.clone(),
1453 name,
1454 content,
1455 retired: slot.retired,
1456 }
1457 })
1458 .collect::<Vec<_>>();
1459 journal
1460 .apply_tool_slots_with_layout("t2", KWEB_TOOL_INSTANCE, legacy_slots, &layout_slots)
1461 .unwrap();
1462
1463 let legacy = original_ids
1464 .iter()
1465 .map(|box_id| {
1466 let slot = journal.state().tools[KWEB_TOOL_INSTANCE]
1467 .slots
1468 .iter()
1469 .find(|slot| slot.box_id == *box_id)
1470 .unwrap()
1471 .slot
1472 .clone();
1473 let state = journal.state().box_state(*box_id).unwrap();
1474 assert_eq!(state.name, "Kweb connection summaries");
1475 assert!(
1476 state
1477 .canonical
1478 .content
1479 .text
1480 .starts_with("Connection summaries\n")
1481 );
1482 (
1483 slot,
1484 state.canonical.event_id,
1485 connection_summary_entries(&state.canonical.content)
1486 .unwrap()
1487 .into_iter()
1488 .map(|entry| entry.id)
1489 .collect::<Vec<_>>(),
1490 metadata_without_revision(&state.canonical.content.metadata),
1491 )
1492 })
1493 .collect::<Vec<_>>();
1494 journal
1495 .summarize_box("t3", original_ids[0], "retained legacy map markers")
1496 .unwrap();
1497 journal.dehydrate_boxes("t4", &original_ids[1..]).unwrap();
1498
1499 let changed = context
1500 .sync_chatend(&mut journal, "t5", &BTreeMap::new(), &[])
1501 .unwrap();
1502 assert_eq!(connection_summary_box_ids(&journal), original_ids);
1503 assert!(changed.contains(&original_ids[0]));
1504 assert!(changed.contains(&original_ids[1]));
1505
1506 for (index, box_id) in original_ids.iter().enumerate() {
1507 let state = journal.state().box_state(*box_id).unwrap();
1508 let actual_slot = journal.state().tools[KWEB_TOOL_INSTANCE]
1509 .slots
1510 .iter()
1511 .find(|slot| slot.box_id == *box_id)
1512 .unwrap()
1513 .slot
1514 .as_str();
1515 assert_eq!(actual_slot, legacy[index].0);
1516 assert_eq!(state.name, "Kweb connection map markers");
1517 assert!(
1518 state
1519 .canonical
1520 .content
1521 .text
1522 .starts_with("Connection map markers\n")
1523 );
1524 assert!(
1525 !state
1526 .canonical
1527 .content
1528 .text
1529 .contains("Connection summaries")
1530 );
1531 assert_eq!(
1532 connection_summary_entries(&state.canonical.content)
1533 .unwrap()
1534 .into_iter()
1535 .map(|entry| entry.id)
1536 .collect::<Vec<_>>(),
1537 legacy[index].2
1538 );
1539 assert_eq!(
1540 metadata_without_revision(&state.canonical.content.metadata),
1541 legacy[index].3
1542 );
1543 assert_eq!(
1544 state.canonical.content.metadata["revisionHash"],
1545 json!(revision_hash(&state.canonical.content.text))
1546 );
1547 if index == 0 {
1548 assert!(matches!(
1549 state.representation,
1550 Representation::Summarized { based_on, .. }
1551 if based_on == legacy[index].1
1552 ));
1553 } else {
1554 assert!(matches!(
1555 state.representation,
1556 Representation::Dehydrated { based_on }
1557 if based_on == legacy[index].1
1558 ));
1559 }
1560 }
1561
1562 drop(journal);
1563 std::fs::remove_dir_all(root).unwrap();
1564 }
1565
1566 #[test]
1567 fn sync_preserves_empty_connection_box_when_later_summaries_arrive() {
1568 let (root, mut journal) = test_journal("empty-connection-box");
1569 let mut context = Context::new(vec![id(1)]).unwrap();
1570 context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1571 context
1572 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1573 .unwrap();
1574
1575 let original_boxes = connection_summary_box_ids(&journal);
1576 assert_eq!(original_boxes.len(), 1);
1577 let original_revision = journal
1578 .state()
1579 .box_state(original_boxes[0])
1580 .unwrap()
1581 .canonical
1582 .event_id;
1583 let content = &journal
1584 .state()
1585 .box_state(original_boxes[0])
1586 .unwrap()
1587 .canonical
1588 .content;
1589 assert!(connection_summary_entries(content).unwrap().is_empty());
1590 assert_eq!(content.metadata[CONNECTION_SUMMARY_IDS_METADATA], json!([]));
1591
1592 context.refresh([node(1, &[], &[2])]).unwrap();
1593 let changed = context
1594 .sync_chatend(&mut journal, "t2", &BTreeMap::new(), &[])
1595 .unwrap();
1596 let current_boxes = connection_summary_box_ids(&journal);
1597 assert_eq!(current_boxes.len(), 2);
1598 assert_eq!(current_boxes[0], original_boxes[0]);
1599 assert!(!changed.contains(&original_boxes[0]));
1600 assert!(changed.contains(¤t_boxes[1]));
1601 let original = journal.state().box_state(original_boxes[0]).unwrap();
1602 assert_eq!(original.canonical.event_id, original_revision);
1603 assert!(
1604 connection_summary_entries(&original.canonical.content)
1605 .unwrap()
1606 .is_empty()
1607 );
1608 let fresh = journal.state().box_state(current_boxes[1]).unwrap();
1609 assert_eq!(
1610 connection_summary_entries(&fresh.canonical.content)
1611 .unwrap()
1612 .iter()
1613 .map(|entry| entry.id.clone())
1614 .collect::<Vec<_>>(),
1615 vec![id(2)]
1616 );
1617
1618 drop(journal);
1619 std::fs::remove_dir_all(root).unwrap();
1620 }
1621
1622 #[test]
1623 fn sync_reports_only_changed_boxes_in_projection_order() {
1624 let (root, mut journal) = test_journal("changed-boxes");
1625 let mut context = Context::new(vec![id(1)]).unwrap();
1626 context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1627 let initial = context
1628 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1629 .unwrap();
1630 assert_eq!(initial.len(), 2);
1631 assert!(
1632 context
1633 .sync_chatend(&mut journal, "t2", &BTreeMap::new(), &[],)
1634 .unwrap()
1635 .is_empty()
1636 );
1637
1638 context.apply_load(node(1, &[2], &[]), Vec::new()).unwrap();
1639 let changed = context
1640 .sync_chatend(&mut journal, "t3", &BTreeMap::new(), &[])
1641 .unwrap();
1642 assert_eq!(changed.len(), 2);
1643 assert_eq!(
1644 changed
1645 .iter()
1646 .map(|box_id| journal.state().box_state(*box_id).unwrap().name.as_str())
1647 .collect::<Vec<_>>(),
1648 vec!["Kweb loaded node", "Kweb connection map markers"]
1649 );
1650 assert!(
1651 context
1652 .sync_chatend(&mut journal, "t4", &BTreeMap::new(), &[])
1653 .unwrap()
1654 .is_empty()
1655 );
1656
1657 drop(journal);
1658 std::fs::remove_dir_all(root).unwrap();
1659 }
1660}