Skip to main content

kcode_kweb_context/
lib.rs

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 summaries",
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/// One current, effect-free provider-facing Kweb context section.
69#[derive(Clone, Debug, Eq, PartialEq)]
70pub struct ProjectionItem {
71    /// Stable logical identity used to replace a prior rendering.
72    pub key: String,
73    /// Human-readable section name.
74    pub name: String,
75    /// Complete current section text.
76    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    /// Creates Kweb context with optional legacy full fixed-node loading.
94    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    /// Renders the complete current Kweb projection without touching Chatend.
295    ///
296    /// Full nodes and individual connection summaries have independent stable
297    /// keys so ordinary replaceable-state consumers can update only values
298    /// whose rendered content changed.
299    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 summary".into(),
321                    text: entry.text,
322                }),
323        );
324        Ok(projected)
325    }
326
327    /// Reconcile this context's complete provider-facing projection through an
328    /// already-open durable Session History handle.
329    ///
330    /// The returned IDs are the active Kweb boxes whose name or canonical
331    /// revision changed, in current projection order.
332    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 summary {id} must resolve to a nonempty short name and short description"
482                    ))
483                })?;
484            if name.trim().is_empty() || description.trim().is_empty() {
485                return Err(Error::new(format!(
486                    "connection summary {id} must resolve to a nonempty short name and short description"
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 = content
555        .text
556        .strip_prefix("Connection summaries")
557        .ok_or_else(|| Error::new("Kweb connection-summary box has an invalid heading"))?;
558    let body = body
559        .strip_prefix('\n')
560        .ok_or_else(|| Error::new("Kweb connection-summary box has no body"))?;
561    if body == "None." {
562        return Ok(Vec::new());
563    }
564
565    let mut entries: Vec<ConnectionSummaryEntry> = Vec::new();
566    for line in body.split('\n') {
567        let identifier = line.split_once(" · ").and_then(|(identifier, _)| {
568            let canonical = identifier.parse::<NodeId>().is_ok();
569            let pending = PendingId::parse(identifier.to_owned()).is_ok();
570            (canonical || pending).then_some(identifier)
571        });
572        if let Some(identifier) = identifier {
573            entries.push(ConnectionSummaryEntry {
574                id: identifier.to_owned(),
575                text: line.to_owned(),
576            });
577        } else {
578            let entry = entries.last_mut().ok_or_else(|| {
579                Error::new("Kweb connection-summary box starts with invalid entry text")
580            })?;
581            entry.text.push('\n');
582            entry.text.push_str(line);
583        }
584    }
585
586    if let Some(expected) = content
587        .metadata
588        .get(CONNECTION_SUMMARY_IDS_METADATA)
589        .and_then(Value::as_array)
590    {
591        let expected = expected
592            .iter()
593            .map(|value| {
594                value.as_str().ok_or_else(|| {
595                    Error::new("Kweb connection-summary IDs metadata contains a non-string value")
596                })
597            })
598            .collect::<Result<Vec<_>>>()?;
599        if expected
600            != entries
601                .iter()
602                .map(|entry| entry.id.as_str())
603                .collect::<Vec<_>>()
604        {
605            return Err(Error::new(
606                "Kweb connection-summary IDs metadata does not match its canonical text",
607            ));
608        }
609    }
610    Ok(entries)
611}
612
613fn format_connection_summary_entries(entries: &[ConnectionSummaryEntry]) -> String {
614    if entries.is_empty() {
615        return "Connection summaries\nNone.".into();
616    }
617    format!(
618        "Connection summaries\n{}",
619        entries
620            .iter()
621            .map(|entry| entry.text.as_str())
622            .collect::<Vec<_>>()
623            .join("\n")
624    )
625}
626
627fn revision_hash(text: &str) -> String {
628    hex::encode(Sha256::digest(text.as_bytes()))
629}
630
631fn update_connection_summary_content(
632    content: &mut BoxContent,
633    logical_slot: &str,
634    entries: &[ConnectionSummaryEntry],
635) {
636    content.text = format_connection_summary_entries(entries);
637    mark_kweb_content(content, logical_slot, "connection-summary");
638    content.metadata["revisionHash"] = json!(revision_hash(&content.text));
639    content.metadata[CONNECTION_SUMMARY_IDS_METADATA] = json!(
640        entries
641            .iter()
642            .map(|entry| entry.id.as_str())
643            .collect::<Vec<_>>()
644    );
645}
646
647fn desired_connection_summary_boxes(
648    journal: &HistorySession,
649    fresh: DesiredKwebBox,
650) -> Result<Vec<DesiredKwebBox>> {
651    let mut boxes = Vec::new();
652    let mut seen = HashSet::new();
653    let mut used_logical_slots = HashSet::new();
654    let fresh_entries = connection_summary_entries(&fresh.content)?;
655    let fresh_by_id = fresh_entries
656        .iter()
657        .map(|entry| (entry.id.as_str(), entry.text.as_str()))
658        .collect::<HashMap<_, _>>();
659
660    if let Some(tool) = journal.state().tools.get(KWEB_TOOL_INSTANCE) {
661        for slot in &tool.slots {
662            let state = journal
663                .state()
664                .box_state(slot.box_id)
665                .ok_or_else(|| Error::new("Kweb tool slot box is missing"))?;
666            let logical_slot = kweb_logical_slot(state, &slot.slot);
667            used_logical_slots.insert(logical_slot.clone());
668            if slot.retired
669                || state
670                    .canonical
671                    .content
672                    .metadata
673                    .get("kwebRole")
674                    .and_then(Value::as_str)
675                    != Some("connection-summary")
676            {
677                continue;
678            }
679            let mut entries = connection_summary_entries(&state.canonical.content)?;
680            for entry in &mut entries {
681                if let Some(text) = fresh_by_id.get(entry.id.as_str()) {
682                    entry.text = (*text).to_owned();
683                }
684            }
685            for entry in &entries {
686                seen.insert(entry.id.clone());
687            }
688            let mut content = state.canonical.content.clone();
689            update_connection_summary_content(&mut content, &logical_slot, &entries);
690            boxes.push(DesiredKwebBox {
691                logical_slot,
692                name: state.name.clone(),
693                content,
694            });
695        }
696    }
697
698    let additions = fresh_entries
699        .iter()
700        .filter(|entry| seen.insert(entry.id.clone()))
701        .cloned()
702        .collect::<Vec<_>>();
703    let mut next_addition = 0;
704
705    while next_addition < additions.len() || boxes.is_empty() {
706        let end = (next_addition + CONNECTION_SUMMARIES_PER_BOX).min(additions.len());
707        let entries = additions[next_addition..end].to_vec();
708        let mut sequence = boxes.len() + 1;
709        let logical_slot = loop {
710            let candidate = if sequence == 1 {
711                CONNECTION_SUMMARIES_LOGICAL_SLOT.to_owned()
712            } else {
713                format!("{CONNECTION_SUMMARIES_LOGICAL_SLOT}:{sequence}")
714            };
715            if used_logical_slots.insert(candidate.clone()) {
716                break candidate;
717            }
718            sequence += 1;
719        };
720        let mut content = fresh.content.clone();
721        update_connection_summary_content(&mut content, &logical_slot, &entries);
722        boxes.push(DesiredKwebBox {
723            logical_slot,
724            name: fresh.name.clone(),
725            content,
726        });
727        next_addition = end;
728    }
729
730    Ok(boxes)
731}
732
733type KwebBoxVersions = BTreeMap<BoxId, (String, EventId)>;
734
735fn kweb_box_versions(journal: &HistorySession) -> KwebBoxVersions {
736    journal
737        .state()
738        .tool_layouts
739        .get(KWEB_TOOL_INSTANCE)
740        .into_iter()
741        .flatten()
742        .filter_map(|box_id| {
743            let state = journal.state().box_state(*box_id)?;
744            state
745                .active
746                .then(|| (*box_id, (state.name.clone(), state.canonical.event_id)))
747        })
748        .collect()
749}
750
751fn changed_kweb_box_ids(journal: &HistorySession, previous: &KwebBoxVersions) -> Vec<BoxId> {
752    journal
753        .state()
754        .tool_layouts
755        .get(KWEB_TOOL_INSTANCE)
756        .into_iter()
757        .flatten()
758        .filter_map(|box_id| {
759            let state = journal.state().box_state(*box_id)?;
760            let current = (state.name.as_str(), state.canonical.event_id);
761            let changed = previous
762                .get(box_id)
763                .map(|(name, revision)| (name.as_str(), *revision) != current)
764                .unwrap_or(true);
765            (state.active && changed).then_some(*box_id)
766        })
767        .collect()
768}
769
770fn unique_slot(logical: &str, used: &mut HashSet<String>) -> String {
771    if used.insert(logical.to_owned()) {
772        return logical.to_owned();
773    }
774    let mut generation = 2_u64;
775    loop {
776        let candidate = format!("{logical}#generation-{generation}");
777        if used.insert(candidate.clone()) {
778            return candidate;
779        }
780        generation += 1;
781    }
782}
783
784fn reconcile_kweb_slots(
785    journal: &mut HistorySession,
786    recorded_at: &str,
787    desired: Vec<DesiredKwebBox>,
788) -> Result<()> {
789    let current = journal
790        .state()
791        .tools
792        .get(KWEB_TOOL_INSTANCE)
793        .cloned()
794        .unwrap_or_default();
795    let desired_by_logical = desired
796        .iter()
797        .enumerate()
798        .map(|(index, entry)| (entry.logical_slot.as_str(), index))
799        .collect::<BTreeMap<_, _>>();
800    if desired_by_logical.len() != desired.len() {
801        return Err(Error::new(
802            "Kweb box layout contains duplicate logical slots",
803        ));
804    }
805    let mut claimed = HashSet::new();
806    let mut actual_by_desired = BTreeMap::new();
807    let mut slots = Vec::with_capacity(current.slots.len() + desired.len());
808    let mut used_actual = current
809        .slots
810        .iter()
811        .map(|slot| slot.slot.clone())
812        .collect::<HashSet<_>>();
813    for slot in &current.slots {
814        let state = journal
815            .state()
816            .box_state(slot.box_id)
817            .ok_or_else(|| Error::new("Kweb tool slot box is missing"))?;
818        let logical = kweb_logical_slot(state, &slot.slot);
819        let selected = !slot.retired
820            && desired_by_logical.contains_key(logical.as_str())
821            && claimed.insert(logical.clone());
822        if selected {
823            let entry = &desired[desired_by_logical[logical.as_str()]];
824            slots.push(ToolSlotInput {
825                slot: slot.slot.clone(),
826                name: entry.name.clone(),
827                content: entry.content.clone(),
828                retired: false,
829            });
830            actual_by_desired.insert(entry.logical_slot.clone(), slot.slot.clone());
831        } else {
832            slots.push(ToolSlotInput {
833                slot: slot.slot.clone(),
834                name: state.name.clone(),
835                content: state.canonical.content.clone(),
836                retired: slot.retired || !selected,
837            });
838        }
839    }
840    for entry in &desired {
841        if actual_by_desired.contains_key(&entry.logical_slot) {
842            continue;
843        }
844        let actual = unique_slot(&entry.logical_slot, &mut used_actual);
845        slots.push(ToolSlotInput {
846            slot: actual.clone(),
847            name: entry.name.clone(),
848            content: entry.content.clone(),
849            retired: false,
850        });
851        actual_by_desired.insert(entry.logical_slot.clone(), actual);
852    }
853    let layout_slots = desired
854        .iter()
855        .map(|entry| actual_by_desired[&entry.logical_slot].clone())
856        .collect::<Vec<_>>();
857    journal
858        .apply_tool_slots_with_layout(recorded_at, KWEB_TOOL_INSTANCE, slots, &layout_slots)
859        .map_err(|error| Error::new(format!("applying Kweb projection: {error}")))?;
860    Ok(())
861}
862
863fn canonical_node_id(value: &str) -> Result<()> {
864    value
865        .parse::<NodeId>()
866        .map(|_| ())
867        .map_err(|_| Error::new(format!("{value:?} is not a canonical Kweb node ID")))
868}
869
870#[cfg(test)]
871mod tests {
872    use std::path::PathBuf;
873    use std::time::{SystemTime, UNIX_EPOCH};
874
875    use super::*;
876    use kcode_session_history::{
877        Config as HistoryConfig, NewSession, SessionHistory,
878        chatend::{Representation, SessionKind},
879    };
880    use serde_json::json;
881
882    fn id(index: u8) -> String {
883        NodeId::from_bytes([0, 0, 0, 0, 0, index])
884            .unwrap()
885            .to_string()
886    }
887
888    fn connection(index: u8) -> Connection {
889        Connection {
890            id: id(index),
891            short_name: format!("Node {index}"),
892            short_description: format!("Summary {index}"),
893        }
894    }
895
896    fn node(index: u8, fixed: &[u8], recent: &[u8]) -> Node {
897        Node {
898            id: id(index),
899            short_name: format!("Node {index}"),
900            short_description: format!("Summary {index}"),
901            long_description: format!("Long description {index}"),
902            owner: id(1),
903            fixed_connections: fixed.iter().copied().map(connection).collect(),
904            recent_connections: recent.iter().copied().map(connection).collect(),
905            objects: vec![],
906            last_modified_by: "test-model-high".into(),
907            last_modified_at: Some("2026-07-28T00:00:00Z".into()),
908        }
909    }
910
911    fn node_with_recent_description(
912        index: u8,
913        fixed: &[u8],
914        recent: &[u8],
915        description: &str,
916    ) -> Node {
917        let mut node = node(index, fixed, recent);
918        for (connection, connection_index) in node.recent_connections.iter_mut().zip(recent) {
919            connection.short_description = format!("{description} {connection_index}");
920        }
921        node
922    }
923
924    fn draft(index: u8, recent: &[u8]) -> NodeDraft {
925        NodeDraft {
926            short_name: format!("Node {index}"),
927            short_description: format!("Summary {index}"),
928            long_description: format!("Long description {index}"),
929            owner: id(1),
930            fixed_connections: Vec::new(),
931            recent_connections: recent.iter().map(|value| id(*value)).collect(),
932            objects: Vec::new(),
933        }
934    }
935
936    fn test_journal(label: &str) -> (PathBuf, HistorySession) {
937        let root = std::env::temp_dir().join(format!(
938            "kcode-kweb-context-{label}-{}-{}",
939            std::process::id(),
940            SystemTime::now()
941                .duration_since(UNIX_EPOCH)
942                .unwrap()
943                .as_nanos()
944        ));
945        let history = SessionHistory::open(HistoryConfig {
946            directory: root.join("sessions"),
947            completed_list: root.join("completed.jsonl"),
948            provider_cost_compatibility: None,
949        })
950        .unwrap();
951        let journal = history
952            .create_session(NewSession {
953                kind: SessionKind::Conversation,
954                created_at: "2026-07-29T00:00:00Z".into(),
955                effective_context_tokens: 10_000,
956                channel: Value::Null,
957            })
958            .unwrap();
959        (root, journal)
960    }
961
962    fn connection_summary_box_ids(journal: &HistorySession) -> Vec<BoxId> {
963        journal
964            .state()
965            .tool_layouts
966            .get(KWEB_TOOL_INSTANCE)
967            .into_iter()
968            .flatten()
969            .copied()
970            .filter(|box_id| {
971                journal
972                    .state()
973                    .box_state(*box_id)
974                    .and_then(|state| state.canonical.content.metadata.get("kwebRole"))
975                    .and_then(Value::as_str)
976                    == Some("connection-summary")
977            })
978            .collect()
979    }
980
981    #[test]
982    fn compatibility_fixed_nodes_yield_to_direct_loads() {
983        let mut context = Context::with_fixed_connections(vec![id(1)], true).unwrap();
984        context
985            .apply_load(node(1, &[2], &[]), vec![node(2, &[], &[])])
986            .unwrap();
987        let report = context.apply_load(node(2, &[], &[]), Vec::new()).unwrap();
988        assert!(report.promoted_from_fixed);
989        assert_eq!(context.loaded_node_ids(), &[id(1), id(2)]);
990        assert!(context.fixed_node_ids().is_empty());
991        assert_eq!(
992            context
993                .box_specs(&BTreeMap::new(), &[])
994                .unwrap()
995                .iter()
996                .map(|spec| spec.kind)
997                .collect::<Vec<_>>(),
998            vec![BoxKind::Loaded, BoxKind::Loaded, BoxKind::Connections]
999        );
1000    }
1001
1002    #[test]
1003    fn default_projection_keeps_only_direct_nodes_full() {
1004        let mut context = Context::new(vec![id(1)]).unwrap();
1005        context.apply_load(node(1, &[2], &[3]), Vec::new()).unwrap();
1006
1007        let projected = context.projection(&BTreeMap::new(), &[]).unwrap();
1008        assert_eq!(
1009            projected
1010                .iter()
1011                .map(|item| item.key.clone())
1012                .collect::<Vec<_>>(),
1013            vec![
1014                id(1),
1015                format!("connection-summaries:{}", id(2)),
1016                format!("connection-summaries:{}", id(3)),
1017            ]
1018        );
1019        assert_eq!(projected[0].name, "Kweb loaded node");
1020        assert!(projected[0].text.contains("Long description 1"));
1021        assert_eq!(projected[1].name, "Kweb connection summary");
1022        assert!(projected[1].text.contains(&id(2)));
1023        assert!(!projected[1].text.contains(&id(3)));
1024        assert_eq!(projected[2].name, "Kweb connection summary");
1025        assert!(projected[2].text.contains(&id(3)));
1026        assert!(!projected[2].text.contains(&id(2)));
1027        assert!(!context.contains_full_node(&id(2)));
1028    }
1029
1030    #[test]
1031    fn box_free_connection_states_change_independently() {
1032        let mut context = Context::new(vec![id(1)]).unwrap();
1033        context
1034            .apply_load(
1035                node_with_recent_description(1, &[], &[2, 3], "old"),
1036                Vec::new(),
1037            )
1038            .unwrap();
1039        let initial = context
1040            .projection(&BTreeMap::new(), &[])
1041            .unwrap()
1042            .into_iter()
1043            .map(|item| (item.key, item.text))
1044            .collect::<BTreeMap<_, _>>();
1045
1046        context
1047            .refresh([node_with_recent_description(1, &[], &[2, 3, 4], "old")])
1048            .unwrap();
1049        let expanded = context
1050            .projection(&BTreeMap::new(), &[])
1051            .unwrap()
1052            .into_iter()
1053            .map(|item| (item.key, item.text))
1054            .collect::<BTreeMap<_, _>>();
1055        assert_eq!(
1056            expanded[&format!("connection-summaries:{}", id(2))],
1057            initial[&format!("connection-summaries:{}", id(2))]
1058        );
1059        assert_eq!(
1060            expanded[&format!("connection-summaries:{}", id(3))],
1061            initial[&format!("connection-summaries:{}", id(3))]
1062        );
1063        assert!(expanded.contains_key(&format!("connection-summaries:{}", id(4))));
1064
1065        let mut changed_node = node_with_recent_description(1, &[], &[2, 3, 4], "old");
1066        changed_node.recent_connections[1].short_description = "changed 3".into();
1067        context.refresh([changed_node]).unwrap();
1068        let changed = context
1069            .projection(&BTreeMap::new(), &[])
1070            .unwrap()
1071            .into_iter()
1072            .map(|item| (item.key, item.text))
1073            .collect::<BTreeMap<_, _>>();
1074        let second = format!("connection-summaries:{}", id(2));
1075        let third = format!("connection-summaries:{}", id(3));
1076        let fourth = format!("connection-summaries:{}", id(4));
1077        assert_eq!(changed[&second], expanded[&second]);
1078        assert_ne!(changed[&third], expanded[&third]);
1079        assert_eq!(changed[&fourth], expanded[&fourth]);
1080        assert_eq!(changed[&id(1)], expanded[&id(1)]);
1081    }
1082
1083    #[test]
1084    fn full_node_kinds_share_one_body_format_without_active_connections() {
1085        let mut context = Context::with_fixed_connections(vec![id(1)], true).unwrap();
1086        context
1087            .apply_load(node(1, &[2], &[]), vec![node(2, &[], &[])])
1088            .unwrap();
1089        let create = StagedCreate {
1090            pending_id: "pending:1".into(),
1091            data: draft(3, &[]),
1092        };
1093        let specs = context.box_specs(&BTreeMap::new(), &[create]).unwrap();
1094        assert_eq!(specs[0].kind.name(), "Kweb loaded node");
1095        assert_eq!(specs[1].kind.name(), "Kweb fixed connection");
1096        assert_eq!(specs[2].kind.name(), "Kweb staged node");
1097        for spec in &specs[..3] {
1098            assert!(spec.text.contains("Node ID:"));
1099            assert!(spec.text.contains("Node name:"));
1100            assert!(spec.text.contains("Node owner ID:"));
1101            assert!(spec.text.contains("Fixed connection IDs:"));
1102            assert!(spec.text.contains("Recent connection IDs:"));
1103            assert!(!spec.text.contains("Active"));
1104        }
1105        assert_eq!(
1106            specs[0].text,
1107            concat!(
1108                "Node ID: AAAAAAAB\n",
1109                "Node name: Node 1\n",
1110                "Node summary: Summary 1\n",
1111                "Node owner ID: AAAAAAAB\n",
1112                "Node long description:\n",
1113                "  Long description 1\n",
1114                "Fixed connection IDs: AAAAAAAC\n",
1115                "Recent connection IDs: none"
1116            )
1117        );
1118    }
1119
1120    #[test]
1121    fn fixed_and_recent_connections_share_one_ordered_deduplicated_box() {
1122        let mut context = Context::new(vec![id(1)]).unwrap();
1123        context
1124            .apply_load(node(1, &[2], &[4, 5]), Vec::new())
1125            .unwrap();
1126        context
1127            .apply_load(node(2, &[8], &[5, 6, 7]), Vec::new())
1128            .unwrap();
1129        let creates = vec![StagedCreate {
1130            pending_id: "pending:1".into(),
1131            data: draft(3, &[6, 7]),
1132        }];
1133        let specs = context.box_specs(&BTreeMap::new(), &creates).unwrap();
1134        let connections = specs
1135            .iter()
1136            .filter(|spec| spec.kind == BoxKind::Connections)
1137            .collect::<Vec<_>>();
1138        assert_eq!(connections.len(), 1);
1139        assert_eq!(
1140            connections[0].text,
1141            format!(
1142                concat!(
1143                    "Connection summaries\n",
1144                    "{} · Node 2: Summary 2\n",
1145                    "{} · Node 4: Summary 4\n",
1146                    "{} · Node 5: Summary 5\n",
1147                    "{} · Node 8: Summary 8\n",
1148                    "{} · Node 6: Summary 6\n",
1149                    "{} · Node 7: Summary 7"
1150                ),
1151                id(2),
1152                id(4),
1153                id(5),
1154                id(8),
1155                id(6),
1156                id(7)
1157            )
1158        );
1159    }
1160
1161    #[test]
1162    fn empty_connection_projection_is_still_one_exact_box() {
1163        let mut context = Context::new(vec![id(1)]).unwrap();
1164        context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1165        let specs = context.box_specs(&BTreeMap::new(), &[]).unwrap();
1166        assert_eq!(specs.len(), 2);
1167        assert_eq!(specs[1].kind, BoxKind::Connections);
1168        assert_eq!(specs[1].text, "Connection summaries\nNone.");
1169    }
1170
1171    #[test]
1172    fn connection_projection_rejects_missing_name_or_description() {
1173        for missing_name in [true, false] {
1174            let mut source = node(1, &[], &[2]);
1175            if missing_name {
1176                source.recent_connections[0].short_name.clear();
1177            } else {
1178                source.recent_connections[0].short_description.clear();
1179            }
1180            let mut context = Context::new(vec![id(1)]).unwrap();
1181            context.apply_load(source, Vec::new()).unwrap();
1182            assert_eq!(
1183                context
1184                    .box_specs(&BTreeMap::new(), &[])
1185                    .unwrap_err()
1186                    .to_string(),
1187                format!(
1188                    "connection summary {} must resolve to a nonempty short name and short description",
1189                    id(2)
1190                )
1191            );
1192        }
1193    }
1194
1195    #[test]
1196    fn staged_updates_drive_full_text_and_recent_projection() {
1197        let mut context = Context::new(vec![id(1)]).unwrap();
1198        context.apply_load(node(1, &[3], &[2]), Vec::new()).unwrap();
1199        let mut updates = BTreeMap::new();
1200        updates.insert(id(1), draft(9, &[3]));
1201        let specs = context.box_specs(&updates, &[]).unwrap();
1202        assert!(specs[0].text.contains("Node name: Node 9"));
1203        assert!(specs[0].staged_node.is_some());
1204        assert!(!specs.last().unwrap().text.contains(&id(2)));
1205        assert!(specs.last().unwrap().text.contains(&id(3)));
1206    }
1207
1208    #[test]
1209    fn sync_appends_fresh_connection_boxes_eight_at_a_time() {
1210        let (root, mut journal) = test_journal("connection-boxes");
1211        let mut context = Context::new(vec![id(1)]).unwrap();
1212        let initial_indices = (2..=19).collect::<Vec<_>>();
1213        context
1214            .apply_load(
1215                node_with_recent_description(1, &[], &initial_indices, "old"),
1216                Vec::new(),
1217            )
1218            .unwrap();
1219        context
1220            .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1221            .unwrap();
1222
1223        let original_ids = connection_summary_box_ids(&journal);
1224        assert_eq!(
1225            original_ids
1226                .iter()
1227                .map(|box_id| {
1228                    connection_summary_entries(
1229                        &journal
1230                            .state()
1231                            .box_state(*box_id)
1232                            .unwrap()
1233                            .canonical
1234                            .content,
1235                    )
1236                    .unwrap()
1237                    .len()
1238                })
1239                .collect::<Vec<_>>(),
1240            vec![8, 8, 2]
1241        );
1242        let original_revisions = original_ids
1243            .iter()
1244            .map(|box_id| {
1245                journal
1246                    .state()
1247                    .box_state(*box_id)
1248                    .unwrap()
1249                    .canonical
1250                    .event_id
1251            })
1252            .collect::<Vec<_>>();
1253        journal
1254            .summarize_box("t2", original_ids[0], "retained first box")
1255            .unwrap();
1256        journal.dehydrate_boxes("t3", &original_ids[1..=2]).unwrap();
1257
1258        let expanded_indices = (2..=28).collect::<Vec<_>>();
1259        context
1260            .refresh([node_with_recent_description(
1261                1,
1262                &[],
1263                &expanded_indices,
1264                "new",
1265            )])
1266            .unwrap();
1267        let changed = context
1268            .sync_chatend(&mut journal, "t4", &BTreeMap::new(), &[])
1269            .unwrap();
1270        let current_ids = connection_summary_box_ids(&journal);
1271        assert_eq!(
1272            current_ids
1273                .iter()
1274                .map(|box_id| {
1275                    connection_summary_entries(
1276                        &journal
1277                            .state()
1278                            .box_state(*box_id)
1279                            .unwrap()
1280                            .canonical
1281                            .content,
1282                    )
1283                    .unwrap()
1284                    .len()
1285                })
1286                .collect::<Vec<_>>(),
1287            vec![8, 8, 2, 8, 1]
1288        );
1289        assert_eq!(&current_ids[..3], original_ids.as_slice());
1290        assert!(changed.contains(&original_ids[0]));
1291        assert!(changed.contains(&original_ids[1]));
1292        assert!(changed.contains(&original_ids[2]));
1293        assert!(changed.contains(&current_ids[3]));
1294        assert!(changed.contains(&current_ids[4]));
1295
1296        let first = journal.state().box_state(original_ids[0]).unwrap();
1297        assert_ne!(first.canonical.event_id, original_revisions[0]);
1298        assert!(first.canonical.content.text.contains("new 2"));
1299        assert!(matches!(
1300            first.representation,
1301            Representation::Summarized { based_on, .. } if based_on == original_revisions[0]
1302        ));
1303        let second = journal.state().box_state(original_ids[1]).unwrap();
1304        assert_ne!(second.canonical.event_id, original_revisions[1]);
1305        assert!(second.canonical.content.text.contains("new 10"));
1306        assert!(matches!(
1307            second.representation,
1308            Representation::Dehydrated { based_on } if based_on == original_revisions[1]
1309        ));
1310        let third = journal.state().box_state(original_ids[2]).unwrap();
1311        assert_ne!(third.canonical.event_id, original_revisions[2]);
1312        assert!(!third.canonical.content.text.contains("new 25"));
1313        assert!(third.canonical.content.text.contains("new 19"));
1314        assert!(matches!(
1315            third.representation,
1316            Representation::Dehydrated { based_on } if based_on == original_revisions[2]
1317        ));
1318        let fourth = journal.state().box_state(current_ids[3]).unwrap();
1319        assert!(fourth.canonical.content.text.contains("new 20"));
1320        assert!(fourth.canonical.content.text.contains("new 27"));
1321        assert!(!fourth.canonical.content.text.contains("new 28"));
1322        let fifth = journal.state().box_state(current_ids[4]).unwrap();
1323        assert!(fifth.canonical.content.text.contains("new 28"));
1324
1325        drop(journal);
1326        std::fs::remove_dir_all(root).unwrap();
1327    }
1328
1329    #[test]
1330    fn sync_preserves_empty_connection_box_when_later_summaries_arrive() {
1331        let (root, mut journal) = test_journal("empty-connection-box");
1332        let mut context = Context::new(vec![id(1)]).unwrap();
1333        context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1334        context
1335            .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1336            .unwrap();
1337
1338        let original_boxes = connection_summary_box_ids(&journal);
1339        assert_eq!(original_boxes.len(), 1);
1340        let original_revision = journal
1341            .state()
1342            .box_state(original_boxes[0])
1343            .unwrap()
1344            .canonical
1345            .event_id;
1346        let content = &journal
1347            .state()
1348            .box_state(original_boxes[0])
1349            .unwrap()
1350            .canonical
1351            .content;
1352        assert!(connection_summary_entries(content).unwrap().is_empty());
1353        assert_eq!(content.metadata[CONNECTION_SUMMARY_IDS_METADATA], json!([]));
1354
1355        context.refresh([node(1, &[], &[2])]).unwrap();
1356        let changed = context
1357            .sync_chatend(&mut journal, "t2", &BTreeMap::new(), &[])
1358            .unwrap();
1359        let current_boxes = connection_summary_box_ids(&journal);
1360        assert_eq!(current_boxes.len(), 2);
1361        assert_eq!(current_boxes[0], original_boxes[0]);
1362        assert!(!changed.contains(&original_boxes[0]));
1363        assert!(changed.contains(&current_boxes[1]));
1364        let original = journal.state().box_state(original_boxes[0]).unwrap();
1365        assert_eq!(original.canonical.event_id, original_revision);
1366        assert!(
1367            connection_summary_entries(&original.canonical.content)
1368                .unwrap()
1369                .is_empty()
1370        );
1371        let fresh = journal.state().box_state(current_boxes[1]).unwrap();
1372        assert_eq!(
1373            connection_summary_entries(&fresh.canonical.content)
1374                .unwrap()
1375                .iter()
1376                .map(|entry| entry.id.clone())
1377                .collect::<Vec<_>>(),
1378            vec![id(2)]
1379        );
1380
1381        drop(journal);
1382        std::fs::remove_dir_all(root).unwrap();
1383    }
1384
1385    #[test]
1386    fn sync_reports_only_changed_boxes_in_projection_order() {
1387        let (root, mut journal) = test_journal("changed-boxes");
1388        let mut context = Context::new(vec![id(1)]).unwrap();
1389        context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1390        let initial = context
1391            .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1392            .unwrap();
1393        assert_eq!(initial.len(), 2);
1394        assert!(
1395            context
1396                .sync_chatend(&mut journal, "t2", &BTreeMap::new(), &[],)
1397                .unwrap()
1398                .is_empty()
1399        );
1400
1401        context.apply_load(node(1, &[2], &[]), Vec::new()).unwrap();
1402        let changed = context
1403            .sync_chatend(&mut journal, "t3", &BTreeMap::new(), &[])
1404            .unwrap();
1405        assert_eq!(changed.len(), 2);
1406        assert_eq!(
1407            changed
1408                .iter()
1409                .map(|box_id| journal.state().box_state(*box_id).unwrap().name.as_str())
1410                .collect::<Vec<_>>(),
1411            vec!["Kweb loaded node", "Kweb connection summaries"]
1412        );
1413        assert!(
1414            context
1415                .sync_chatend(&mut journal, "t4", &BTreeMap::new(), &[])
1416                .unwrap()
1417                .is_empty()
1418        );
1419
1420        drop(journal);
1421        std::fs::remove_dir_all(root).unwrap();
1422    }
1423}