Skip to main content

kcode_kweb_context/
lib.rs

1#![forbid(unsafe_code)]
2
3use std::{
4    collections::{BTreeMap, HashMap, HashSet},
5    error, fmt,
6};
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::{Deserialize, Serialize};
14use serde_json::{Value, json};
15use sha2::{Digest, Sha256};
16
17const KWEB_TOOL_INSTANCE: &str = "kweb";
18const RECENT_CONNECTIONS_PER_BOX: usize = 8;
19const RECENT_CONNECTIONS_LOGICAL_SLOT: &str = "recent-connections";
20const RECENT_CONNECTION_IDS_METADATA: &str = "kwebRecentConnectionIds";
21
22pub type Result<T> = std::result::Result<T, Error>;
23
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct Error {
26    message: String,
27}
28
29impl Error {
30    fn new(message: impl Into<String>) -> Self {
31        Self {
32            message: message.into(),
33        }
34    }
35}
36
37impl fmt::Display for Error {
38    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
39        formatter.write_str(&self.message)
40    }
41}
42
43impl error::Error for Error {}
44
45#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct Connection {
48    pub id: String,
49    pub short_name: String,
50    pub short_description: String,
51}
52
53#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct Node {
56    pub id: String,
57    pub short_name: String,
58    pub short_description: String,
59    pub long_description: String,
60    pub owner: String,
61    #[serde(default)]
62    pub fixed_connections: Vec<Connection>,
63    #[serde(default)]
64    pub recent_connections: Vec<Connection>,
65    #[serde(default)]
66    pub objects: Vec<String>,
67    #[serde(default)]
68    pub last_modified_by: String,
69    #[serde(default)]
70    pub last_modified_at: Option<String>,
71}
72
73impl Node {
74    pub fn from_kweb_value(value: &Value) -> Result<Self> {
75        let id = required_string(value, "id")?;
76        canonical_node_id(&id)?;
77        let owner = value
78            .get("owner_node_id")
79            .or_else(|| value.get("owner_root_node_id"))
80            .and_then(Value::as_str)
81            .unwrap_or("unowned")
82            .to_owned();
83        if !matches!(owner.as_str(), "self" | "unowned") {
84            canonical_node_id(&owner)?;
85        }
86        let summaries = value
87            .get("connection_summaries")
88            .and_then(Value::as_array)
89            .into_iter()
90            .flatten()
91            .filter_map(|summary| Some((summary.get("id")?.as_str()?.to_owned(), summary)))
92            .collect::<HashMap<_, _>>();
93        let fixed_connections = connections(
94            value.get("fixed_connections"),
95            &summaries,
96            "fixed connection",
97        )?;
98        let recent_connections = connections(
99            value.get("recent_connections"),
100            &summaries,
101            "recent connection",
102        )?;
103        let objects = string_ids(value.get("objects"), "object")?;
104        Ok(Self {
105            id,
106            short_name: optional_string(value, "short_name"),
107            short_description: optional_string(value, "short_description"),
108            long_description: optional_string(value, "long_description"),
109            owner,
110            fixed_connections,
111            recent_connections,
112            objects,
113            last_modified_by: optional_string(value, "last_modified_by"),
114            last_modified_at: value
115                .get("last_modified_at")
116                .and_then(Value::as_str)
117                .map(str::to_owned),
118        })
119    }
120
121    pub fn draft(&self) -> NodeDraft {
122        NodeDraft {
123            short_name: self.short_name.clone(),
124            short_description: self.short_description.clone(),
125            long_description: self.long_description.clone(),
126            owner: self.owner.clone(),
127            fixed_connections: self
128                .fixed_connections
129                .iter()
130                .map(|connection| connection.id.clone())
131                .collect(),
132            recent_connections: self
133                .recent_connections
134                .iter()
135                .map(|connection| connection.id.clone())
136                .collect(),
137            objects: self.objects.clone(),
138        }
139    }
140}
141
142#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct NodeDraft {
145    pub short_name: String,
146    pub short_description: String,
147    pub long_description: String,
148    pub owner: String,
149    #[serde(default)]
150    pub fixed_connections: Vec<String>,
151    #[serde(default)]
152    pub recent_connections: Vec<String>,
153    #[serde(default)]
154    pub objects: Vec<String>,
155}
156
157#[derive(Clone, Debug, Eq, PartialEq)]
158pub struct StagedCreate {
159    pub pending_id: String,
160    pub data: NodeDraft,
161}
162
163#[derive(Clone, Copy, Debug, Eq, PartialEq)]
164enum BoxKind {
165    Loaded,
166    Fixed,
167    Staged,
168    Recent,
169}
170
171impl BoxKind {
172    pub const fn name(self) -> &'static str {
173        match self {
174            Self::Loaded => "Kweb loaded node",
175            Self::Fixed => "Kweb fixed connection",
176            Self::Staged => "Kweb staged node",
177            Self::Recent => "Kweb recent connections",
178        }
179    }
180
181    pub const fn metadata_name(self) -> &'static str {
182        match self {
183            Self::Loaded => "loaded",
184            Self::Fixed => "fixed",
185            Self::Staged => "staged",
186            Self::Recent => "recent",
187        }
188    }
189}
190
191#[derive(Clone, Debug, Eq, PartialEq)]
192struct BoxSpec {
193    logical_slot: String,
194    kind: BoxKind,
195    text: String,
196    stored_node: Option<Node>,
197    staged_node: Option<NodeDraft>,
198}
199
200#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
201#[serde(rename_all = "camelCase")]
202pub struct LoadReport {
203    pub requested_id: String,
204    pub newly_loaded: bool,
205    pub promoted_from_fixed: bool,
206    pub new_fixed_ids: Vec<String>,
207}
208
209#[derive(Clone, Debug)]
210pub struct Context {
211    root_node_ids: Vec<String>,
212    loaded_node_ids: Vec<String>,
213    fixed_node_ids: Vec<String>,
214    nodes_by_id: BTreeMap<String, Node>,
215}
216
217impl Context {
218    pub fn new(root_node_ids: Vec<String>) -> Result<Self> {
219        if root_node_ids.is_empty() {
220            return Err(Error::new("Kweb context requires at least one root node"));
221        }
222        let mut seen = HashSet::new();
223        for id in &root_node_ids {
224            canonical_node_id(id)?;
225            if !seen.insert(id.clone()) {
226                return Err(Error::new("Kweb root node IDs must be distinct"));
227            }
228        }
229        Ok(Self {
230            root_node_ids,
231            loaded_node_ids: Vec::new(),
232            fixed_node_ids: Vec::new(),
233            nodes_by_id: BTreeMap::new(),
234        })
235    }
236
237    pub fn root_node_ids(&self) -> &[String] {
238        &self.root_node_ids
239    }
240
241    pub fn loaded_node_ids(&self) -> &[String] {
242        &self.loaded_node_ids
243    }
244
245    pub fn fixed_node_ids(&self) -> &[String] {
246        &self.fixed_node_ids
247    }
248
249    pub fn full_node_ids(&self) -> Vec<&str> {
250        self.loaded_node_ids
251            .iter()
252            .chain(&self.fixed_node_ids)
253            .map(String::as_str)
254            .collect()
255    }
256
257    pub fn contains_full_node(&self, id: &str) -> bool {
258        self.loaded_node_ids.iter().any(|candidate| candidate == id)
259            || self.fixed_node_ids.iter().any(|candidate| candidate == id)
260    }
261
262    pub fn node(&self, id: &str) -> Option<&Node> {
263        self.nodes_by_id.get(id)
264    }
265
266    pub fn apply_load(&mut self, requested: Node, fixed: Vec<Node>) -> Result<LoadReport> {
267        let requested_id = requested.id.clone();
268        let was_loaded = self
269            .loaded_node_ids
270            .iter()
271            .any(|candidate| candidate == &requested_id);
272        let was_fixed = self
273            .fixed_node_ids
274            .iter()
275            .any(|candidate| candidate == &requested_id);
276        let previous_full = self
277            .full_node_ids()
278            .into_iter()
279            .map(str::to_owned)
280            .collect::<HashSet<_>>();
281        let expected_fixed = requested
282            .fixed_connections
283            .iter()
284            .map(|connection| connection.id.as_str())
285            .filter(|id| *id != requested_id)
286            .collect::<HashSet<_>>();
287        let provided_fixed = fixed
288            .iter()
289            .map(|node| node.id.as_str())
290            .collect::<HashSet<_>>();
291        if expected_fixed != provided_fixed {
292            return Err(Error::new(format!(
293                "load for {requested_id} did not provide exactly its fixed connections"
294            )));
295        }
296        self.nodes_by_id.insert(requested_id.clone(), requested);
297        for node in fixed {
298            self.nodes_by_id.insert(node.id.clone(), node);
299        }
300        if !was_loaded {
301            self.loaded_node_ids.push(requested_id.clone());
302        }
303        self.rebuild_fixed();
304        let new_fixed_ids = self
305            .fixed_node_ids
306            .iter()
307            .filter(|id| !previous_full.contains(*id))
308            .cloned()
309            .collect();
310        Ok(LoadReport {
311            requested_id,
312            newly_loaded: !was_loaded && !was_fixed,
313            promoted_from_fixed: !was_loaded && was_fixed,
314            new_fixed_ids,
315        })
316    }
317
318    pub fn refresh(&mut self, nodes: impl IntoIterator<Item = Node>) -> Result<()> {
319        for node in nodes {
320            if !self.contains_full_node(&node.id) {
321                return Err(Error::new(format!(
322                    "cannot refresh unloaded Kweb node {}",
323                    node.id
324                )));
325            }
326            self.nodes_by_id.insert(node.id.clone(), node);
327        }
328        self.rebuild_fixed();
329        Ok(())
330    }
331
332    pub fn restore(
333        &mut self,
334        nodes: impl IntoIterator<Item = Node>,
335        directly_loaded: Vec<String>,
336    ) -> Result<()> {
337        self.nodes_by_id.clear();
338        for node in nodes {
339            self.nodes_by_id.insert(node.id.clone(), node);
340        }
341        let mut seen = HashSet::new();
342        self.loaded_node_ids = directly_loaded
343            .into_iter()
344            .filter(|id| self.nodes_by_id.contains_key(id) && seen.insert(id.clone()))
345            .collect();
346        if !self.nodes_by_id.is_empty() && self.loaded_node_ids.is_empty() {
347            return Err(Error::new(
348                "restored Kweb context contains nodes but no loaded node",
349            ));
350        }
351        self.rebuild_fixed();
352        Ok(())
353    }
354
355    fn box_specs(
356        &self,
357        updates: &BTreeMap<String, NodeDraft>,
358        creates: &[StagedCreate],
359    ) -> Result<Vec<BoxSpec>> {
360        for id in updates.keys() {
361            if !self.contains_full_node(id) {
362                return Err(Error::new(format!(
363                    "staged update targets unloaded Kweb node {id}"
364                )));
365            }
366        }
367        let mut specs = Vec::new();
368        for id in &self.loaded_node_ids {
369            specs.push(self.full_box(id, BoxKind::Loaded, updates.get(id))?);
370        }
371        for id in &self.fixed_node_ids {
372            specs.push(self.full_box(id, BoxKind::Fixed, updates.get(id))?);
373        }
374        for create in creates {
375            specs.push(BoxSpec {
376                logical_slot: create.pending_id.clone(),
377                kind: BoxKind::Staged,
378                text: format_node(&create.pending_id, &create.data),
379                stored_node: None,
380                staged_node: Some(create.data.clone()),
381            });
382        }
383        specs.push(BoxSpec {
384            logical_slot: "recent-connections".into(),
385            kind: BoxKind::Recent,
386            text: self.format_recent_connections(updates, creates)?,
387            stored_node: None,
388            staged_node: None,
389        });
390        Ok(specs)
391    }
392
393    /// Reconcile this context's complete provider-facing projection through an
394    /// already-open durable Session History handle.
395    ///
396    /// The returned IDs are the active Kweb boxes whose name or canonical
397    /// revision changed, in current projection order.
398    pub fn sync_chatend(
399        &self,
400        journal: &mut HistorySession,
401        recorded_at: impl Into<String>,
402        updates: &BTreeMap<String, NodeDraft>,
403        creates: &[StagedCreate],
404    ) -> Result<Vec<BoxId>> {
405        let recorded_at = recorded_at.into();
406        let previous = kweb_box_versions(journal);
407        let specs = self.box_specs(updates, creates)?;
408        let mut desired = Vec::with_capacity(specs.len());
409        let mut recent = None;
410        for spec in specs {
411            let mut metadata = json!({
412                "revisionHash": revision_hash(&spec.text),
413            });
414            if let Some(node) = spec.stored_node {
415                metadata["canonicalNodeId"] = json!(node.id);
416                metadata["storedNode"] = serde_json::to_value(node).map_err(|error| {
417                    Error::new(format!("serializing stored Kweb node: {error}"))
418                })?;
419            }
420            if let Some(node) = spec.staged_node {
421                metadata["staged"] = json!(true);
422                metadata["nodeData"] = serde_json::to_value(node).map_err(|error| {
423                    Error::new(format!("serializing staged Kweb node: {error}"))
424                })?;
425            }
426            let mut content = BoxContent {
427                text: spec.text,
428                objects: Vec::new(),
429                metadata,
430            };
431            content.use_concise_header();
432            mark_kweb_content(&mut content, &spec.logical_slot, spec.kind.metadata_name());
433            let entry = DesiredKwebBox {
434                logical_slot: spec.logical_slot,
435                name: spec.kind.name().into(),
436                content,
437            };
438            if spec.kind == BoxKind::Recent {
439                if recent.replace(entry).is_some() {
440                    return Err(Error::new(
441                        "Kweb context produced more than one recent-connections candidate",
442                    ));
443                }
444            } else {
445                desired.push(entry);
446            }
447        }
448        let recent = recent
449            .ok_or_else(|| Error::new("Kweb context produced no recent-connections candidate"))?;
450        desired.extend(desired_recent_connection_boxes(journal, recent)?);
451        reconcile_kweb_slots(journal, &recorded_at, desired)?;
452        Ok(changed_kweb_box_ids(journal, &previous))
453    }
454
455    fn full_box(&self, id: &str, kind: BoxKind, update: Option<&NodeDraft>) -> Result<BoxSpec> {
456        let node = self
457            .nodes_by_id
458            .get(id)
459            .ok_or_else(|| Error::new(format!("missing full Kweb node {id}")))?;
460        let data = update.cloned().unwrap_or_else(|| node.draft());
461        Ok(BoxSpec {
462            logical_slot: id.to_owned(),
463            kind,
464            text: format_node(id, &data),
465            stored_node: Some(node.clone()),
466            staged_node: update.cloned(),
467        })
468    }
469
470    fn format_recent_connections(
471        &self,
472        updates: &BTreeMap<String, NodeDraft>,
473        creates: &[StagedCreate],
474    ) -> Result<String> {
475        let creates_by_id = creates
476            .iter()
477            .map(|create| (create.pending_id.as_str(), &create.data))
478            .collect::<HashMap<_, _>>();
479        let mut summaries = HashMap::new();
480        for node in self.nodes_by_id.values() {
481            summaries.insert(
482                node.id.as_str(),
483                (node.short_name.as_str(), node.short_description.as_str()),
484            );
485            for connection in node
486                .fixed_connections
487                .iter()
488                .chain(&node.recent_connections)
489            {
490                summaries.entry(connection.id.as_str()).or_insert((
491                    connection.short_name.as_str(),
492                    connection.short_description.as_str(),
493                ));
494            }
495        }
496        let mut recent_ids = Vec::new();
497        let mut seen = HashSet::new();
498        for id in self.full_node_ids() {
499            let node = self
500                .nodes_by_id
501                .get(id)
502                .ok_or_else(|| Error::new(format!("missing full Kweb node {id}")))?;
503            let recent = updates
504                .get(id)
505                .map(|draft| draft.recent_connections.as_slice())
506                .unwrap_or_else(|| &[]);
507            if updates.contains_key(id) {
508                for connection_id in recent {
509                    if seen.insert(connection_id.clone()) {
510                        recent_ids.push(connection_id.clone());
511                    }
512                }
513            } else {
514                for connection in &node.recent_connections {
515                    if seen.insert(connection.id.clone()) {
516                        recent_ids.push(connection.id.clone());
517                    }
518                }
519            }
520        }
521        for create in creates {
522            for connection_id in &create.data.recent_connections {
523                if seen.insert(connection_id.clone()) {
524                    recent_ids.push(connection_id.clone());
525                }
526            }
527        }
528        let mut lines = vec!["Recent connections".to_owned()];
529        for id in recent_ids {
530            let staged_summary = updates
531                .get(&id)
532                .or_else(|| creates_by_id.get(id.as_str()).copied())
533                .map(|node| (node.short_name.as_str(), node.short_description.as_str()));
534            let (name, description) = staged_summary
535                .or_else(|| summaries.get(id.as_str()).copied())
536                .ok_or_else(|| {
537                    Error::new(format!(
538                        "recent connection {id} must resolve to a nonempty short name and short description"
539                    ))
540                })?;
541            if name.trim().is_empty() || description.trim().is_empty() {
542                return Err(Error::new(format!(
543                    "recent connection {id} must resolve to a nonempty short name and short description"
544                )));
545            }
546            lines.push(format!("{id} · {name}: {description}"));
547        }
548        if lines.len() == 1 {
549            lines.push("None.".into());
550        }
551        Ok(lines.join("\n"))
552    }
553
554    fn rebuild_fixed(&mut self) {
555        let loaded = self.loaded_node_ids.iter().cloned().collect::<HashSet<_>>();
556        let mut seen = loaded.clone();
557        let mut fixed = Vec::new();
558        for id in &self.loaded_node_ids {
559            let Some(node) = self.nodes_by_id.get(id) else {
560                continue;
561            };
562            for connection in &node.fixed_connections {
563                if self.nodes_by_id.contains_key(&connection.id)
564                    && seen.insert(connection.id.clone())
565                {
566                    fixed.push(connection.id.clone());
567                }
568            }
569        }
570        self.fixed_node_ids = fixed;
571        self.nodes_by_id
572            .retain(|id, _| loaded.contains(id) || seen.contains(id));
573    }
574}
575
576struct DesiredKwebBox {
577    logical_slot: String,
578    name: String,
579    content: BoxContent,
580}
581
582#[derive(Clone, Debug, Eq, PartialEq)]
583struct RecentConnectionEntry {
584    id: String,
585    text: String,
586}
587
588fn mark_kweb_content(content: &mut BoxContent, logical_slot: &str, role: &str) {
589    if !content.metadata.is_object() {
590        content.metadata = json!({});
591    }
592    content.metadata["kwebLogicalSlot"] = json!(logical_slot);
593    content.metadata["kwebRole"] = json!(role);
594}
595
596fn kweb_logical_slot(state: &BoxState, actual_slot: &str) -> String {
597    state
598        .canonical
599        .content
600        .metadata
601        .get("kwebLogicalSlot")
602        .and_then(Value::as_str)
603        .unwrap_or(actual_slot)
604        .to_owned()
605}
606
607fn recent_connection_entries(content: &BoxContent) -> Result<Vec<RecentConnectionEntry>> {
608    let body = content
609        .text
610        .strip_prefix("Recent connections")
611        .ok_or_else(|| Error::new("Kweb recent-connections box has an invalid heading"))?;
612    let body = body
613        .strip_prefix('\n')
614        .ok_or_else(|| Error::new("Kweb recent-connections box has no body"))?;
615    if body == "None." {
616        return Ok(Vec::new());
617    }
618
619    let mut entries: Vec<RecentConnectionEntry> = Vec::new();
620    for line in body.split('\n') {
621        let identifier = line.split_once(" · ").and_then(|(identifier, _)| {
622            let canonical = identifier.parse::<NodeId>().is_ok();
623            let pending = PendingId::parse(identifier.to_owned()).is_ok();
624            (canonical || pending).then_some(identifier)
625        });
626        if let Some(identifier) = identifier {
627            entries.push(RecentConnectionEntry {
628                id: identifier.to_owned(),
629                text: line.to_owned(),
630            });
631        } else {
632            let entry = entries.last_mut().ok_or_else(|| {
633                Error::new("Kweb recent-connections box starts with invalid entry text")
634            })?;
635            entry.text.push('\n');
636            entry.text.push_str(line);
637        }
638    }
639
640    if let Some(expected) = content
641        .metadata
642        .get(RECENT_CONNECTION_IDS_METADATA)
643        .and_then(Value::as_array)
644    {
645        let expected = expected
646            .iter()
647            .map(|value| {
648                value.as_str().ok_or_else(|| {
649                    Error::new("Kweb recent-connection IDs metadata contains a non-string value")
650                })
651            })
652            .collect::<Result<Vec<_>>>()?;
653        if expected
654            != entries
655                .iter()
656                .map(|entry| entry.id.as_str())
657                .collect::<Vec<_>>()
658        {
659            return Err(Error::new(
660                "Kweb recent-connection IDs metadata does not match its canonical text",
661            ));
662        }
663    }
664    Ok(entries)
665}
666
667fn format_recent_connection_entries(entries: &[RecentConnectionEntry]) -> String {
668    if entries.is_empty() {
669        return "Recent connections\nNone.".into();
670    }
671    format!(
672        "Recent connections\n{}",
673        entries
674            .iter()
675            .map(|entry| entry.text.as_str())
676            .collect::<Vec<_>>()
677            .join("\n")
678    )
679}
680
681fn revision_hash(text: &str) -> String {
682    hex::encode(Sha256::digest(text.as_bytes()))
683}
684
685fn update_recent_connection_content(
686    content: &mut BoxContent,
687    logical_slot: &str,
688    entries: &[RecentConnectionEntry],
689) {
690    content.text = format_recent_connection_entries(entries);
691    mark_kweb_content(content, logical_slot, "recent");
692    content.metadata["revisionHash"] = json!(revision_hash(&content.text));
693    content.metadata[RECENT_CONNECTION_IDS_METADATA] = json!(
694        entries
695            .iter()
696            .map(|entry| entry.id.as_str())
697            .collect::<Vec<_>>()
698    );
699}
700
701fn desired_recent_connection_boxes(
702    journal: &HistorySession,
703    fresh: DesiredKwebBox,
704) -> Result<Vec<DesiredKwebBox>> {
705    let mut boxes = Vec::new();
706    let mut seen = HashSet::new();
707    let mut used_logical_slots = HashSet::new();
708
709    if let Some(tool) = journal.state().tools.get(KWEB_TOOL_INSTANCE) {
710        for slot in &tool.slots {
711            let state = journal
712                .state()
713                .box_state(slot.box_id)
714                .ok_or_else(|| Error::new("Kweb tool slot box is missing"))?;
715            let logical_slot = kweb_logical_slot(state, &slot.slot);
716            used_logical_slots.insert(logical_slot.clone());
717            if slot.retired
718                || state
719                    .canonical
720                    .content
721                    .metadata
722                    .get("kwebRole")
723                    .and_then(Value::as_str)
724                    != Some("recent")
725            {
726                continue;
727            }
728            let entries = recent_connection_entries(&state.canonical.content)?;
729            for entry in &entries {
730                seen.insert(entry.id.clone());
731            }
732            boxes.push((
733                DesiredKwebBox {
734                    logical_slot,
735                    name: state.name.clone(),
736                    content: state.canonical.content.clone(),
737                },
738                entries,
739            ));
740        }
741    }
742
743    let additions = recent_connection_entries(&fresh.content)?
744        .into_iter()
745        .filter(|entry| seen.insert(entry.id.clone()))
746        .collect::<Vec<_>>();
747    let mut next_addition = 0;
748
749    if let Some((last, entries)) = boxes.last_mut()
750        && entries.len() < RECENT_CONNECTIONS_PER_BOX
751    {
752        let available = RECENT_CONNECTIONS_PER_BOX - entries.len();
753        let end = additions.len().min(available);
754        entries.extend_from_slice(&additions[..end]);
755        next_addition = end;
756        if end > 0 {
757            update_recent_connection_content(&mut last.content, &last.logical_slot, entries);
758        }
759    }
760
761    while next_addition < additions.len() || boxes.is_empty() {
762        let end = (next_addition + RECENT_CONNECTIONS_PER_BOX).min(additions.len());
763        let entries = additions[next_addition..end].to_vec();
764        let mut sequence = boxes.len() + 1;
765        let logical_slot = loop {
766            let candidate = if sequence == 1 {
767                RECENT_CONNECTIONS_LOGICAL_SLOT.to_owned()
768            } else {
769                format!("{RECENT_CONNECTIONS_LOGICAL_SLOT}:{sequence}")
770            };
771            if used_logical_slots.insert(candidate.clone()) {
772                break candidate;
773            }
774            sequence += 1;
775        };
776        let mut content = fresh.content.clone();
777        update_recent_connection_content(&mut content, &logical_slot, &entries);
778        boxes.push((
779            DesiredKwebBox {
780                logical_slot,
781                name: fresh.name.clone(),
782                content,
783            },
784            entries,
785        ));
786        next_addition = end;
787    }
788
789    Ok(boxes.into_iter().map(|(box_spec, _)| box_spec).collect())
790}
791
792type KwebBoxVersions = BTreeMap<BoxId, (String, EventId)>;
793
794fn kweb_box_versions(journal: &HistorySession) -> KwebBoxVersions {
795    journal
796        .state()
797        .tool_layouts
798        .get(KWEB_TOOL_INSTANCE)
799        .into_iter()
800        .flatten()
801        .filter_map(|box_id| {
802            let state = journal.state().box_state(*box_id)?;
803            state
804                .active
805                .then(|| (*box_id, (state.name.clone(), state.canonical.event_id)))
806        })
807        .collect()
808}
809
810fn changed_kweb_box_ids(journal: &HistorySession, previous: &KwebBoxVersions) -> Vec<BoxId> {
811    journal
812        .state()
813        .tool_layouts
814        .get(KWEB_TOOL_INSTANCE)
815        .into_iter()
816        .flatten()
817        .filter_map(|box_id| {
818            let state = journal.state().box_state(*box_id)?;
819            let current = (state.name.as_str(), state.canonical.event_id);
820            let changed = previous
821                .get(box_id)
822                .map(|(name, revision)| (name.as_str(), *revision) != current)
823                .unwrap_or(true);
824            (state.active && changed).then_some(*box_id)
825        })
826        .collect()
827}
828
829fn unique_slot(logical: &str, used: &mut HashSet<String>) -> String {
830    if used.insert(logical.to_owned()) {
831        return logical.to_owned();
832    }
833    let mut generation = 2_u64;
834    loop {
835        let candidate = format!("{logical}#generation-{generation}");
836        if used.insert(candidate.clone()) {
837            return candidate;
838        }
839        generation += 1;
840    }
841}
842
843fn reconcile_kweb_slots(
844    journal: &mut HistorySession,
845    recorded_at: &str,
846    desired: Vec<DesiredKwebBox>,
847) -> Result<()> {
848    let current = journal
849        .state()
850        .tools
851        .get(KWEB_TOOL_INSTANCE)
852        .cloned()
853        .unwrap_or_default();
854    let desired_by_logical = desired
855        .iter()
856        .enumerate()
857        .map(|(index, entry)| (entry.logical_slot.as_str(), index))
858        .collect::<BTreeMap<_, _>>();
859    if desired_by_logical.len() != desired.len() {
860        return Err(Error::new(
861            "Kweb box layout contains duplicate logical slots",
862        ));
863    }
864    let mut claimed = HashSet::new();
865    let mut actual_by_desired = BTreeMap::new();
866    let mut slots = Vec::with_capacity(current.slots.len() + desired.len());
867    let mut used_actual = current
868        .slots
869        .iter()
870        .map(|slot| slot.slot.clone())
871        .collect::<HashSet<_>>();
872    for slot in &current.slots {
873        let state = journal
874            .state()
875            .box_state(slot.box_id)
876            .ok_or_else(|| Error::new("Kweb tool slot box is missing"))?;
877        let logical = kweb_logical_slot(state, &slot.slot);
878        let selected = !slot.retired
879            && desired_by_logical.contains_key(logical.as_str())
880            && claimed.insert(logical.clone());
881        if selected {
882            let entry = &desired[desired_by_logical[logical.as_str()]];
883            slots.push(ToolSlotInput {
884                slot: slot.slot.clone(),
885                name: entry.name.clone(),
886                content: entry.content.clone(),
887                retired: false,
888            });
889            actual_by_desired.insert(entry.logical_slot.clone(), slot.slot.clone());
890        } else {
891            slots.push(ToolSlotInput {
892                slot: slot.slot.clone(),
893                name: state.name.clone(),
894                content: state.canonical.content.clone(),
895                retired: slot.retired || !selected,
896            });
897        }
898    }
899    for entry in &desired {
900        if actual_by_desired.contains_key(&entry.logical_slot) {
901            continue;
902        }
903        let actual = unique_slot(&entry.logical_slot, &mut used_actual);
904        slots.push(ToolSlotInput {
905            slot: actual.clone(),
906            name: entry.name.clone(),
907            content: entry.content.clone(),
908            retired: false,
909        });
910        actual_by_desired.insert(entry.logical_slot.clone(), actual);
911    }
912    let layout_slots = desired
913        .iter()
914        .map(|entry| actual_by_desired[&entry.logical_slot].clone())
915        .collect::<Vec<_>>();
916    journal
917        .apply_tool_slots_with_layout(recorded_at, KWEB_TOOL_INSTANCE, slots, &layout_slots)
918        .map_err(|error| Error::new(format!("applying Kweb projection: {error}")))?;
919    Ok(())
920}
921
922fn connections(
923    value: Option<&Value>,
924    summaries: &HashMap<String, &Value>,
925    label: &str,
926) -> Result<Vec<Connection>> {
927    let mut result = Vec::new();
928    let mut seen = HashSet::new();
929    for entry in value.and_then(Value::as_array).into_iter().flatten() {
930        let id = entry
931            .as_str()
932            .or_else(|| entry.get("id").and_then(Value::as_str))
933            .ok_or_else(|| Error::new(format!("{label} has no node ID")))?
934            .to_owned();
935        canonical_node_id(&id)?;
936        if !seen.insert(id.clone()) {
937            continue;
938        }
939        let summary = summaries.get(&id).copied();
940        result.push(Connection {
941            id,
942            short_name: entry
943                .get("short_name")
944                .and_then(Value::as_str)
945                .or_else(|| summary.and_then(|value| value.get("short_name")?.as_str()))
946                .unwrap_or_default()
947                .to_owned(),
948            short_description: entry
949                .get("short_description")
950                .and_then(Value::as_str)
951                .or_else(|| summary.and_then(|value| value.get("short_description")?.as_str()))
952                .unwrap_or_default()
953                .to_owned(),
954        });
955    }
956    Ok(result)
957}
958
959fn string_ids(value: Option<&Value>, label: &str) -> Result<Vec<String>> {
960    let mut result = Vec::new();
961    let mut seen = HashSet::new();
962    for entry in value.and_then(Value::as_array).into_iter().flatten() {
963        let id = entry
964            .as_str()
965            .ok_or_else(|| Error::new(format!("{label} ID must be a string")))?
966            .to_owned();
967        if seen.insert(id.clone()) {
968            result.push(id);
969        }
970    }
971    Ok(result)
972}
973
974fn canonical_node_id(value: &str) -> Result<()> {
975    value
976        .parse::<NodeId>()
977        .map(|_| ())
978        .map_err(|_| Error::new(format!("{value:?} is not a canonical Kweb node ID")))
979}
980
981fn required_string(value: &Value, key: &str) -> Result<String> {
982    value
983        .get(key)
984        .and_then(Value::as_str)
985        .map(str::to_owned)
986        .ok_or_else(|| Error::new(format!("Kweb node has no string {key}")))
987}
988
989fn optional_string(value: &Value, key: &str) -> String {
990    value
991        .get(key)
992        .and_then(Value::as_str)
993        .unwrap_or_default()
994        .to_owned()
995}
996
997fn format_node(identifier: &str, node: &NodeDraft) -> String {
998    [
999        format!("Node ID: {identifier}"),
1000        format!("Node name: {}", fallback(&node.short_name)),
1001        format!("Node summary: {}", fallback(&node.short_description)),
1002        format!("Node owner ID: {}", fallback(&node.owner)),
1003        "Node long description:".into(),
1004        indent(&node.long_description),
1005        format!(
1006            "Fixed connection IDs: {}",
1007            list_or_none(&node.fixed_connections)
1008        ),
1009        format!(
1010            "Recent connection IDs: {}",
1011            list_or_none(&node.recent_connections)
1012        ),
1013    ]
1014    .join("\n")
1015}
1016
1017fn indent(value: &str) -> String {
1018    fallback(value)
1019        .lines()
1020        .map(|line| format!("  {line}"))
1021        .collect::<Vec<_>>()
1022        .join("\n")
1023}
1024
1025fn fallback(value: &str) -> &str {
1026    if value.trim().is_empty() {
1027        "(none)"
1028    } else {
1029        value
1030    }
1031}
1032
1033fn list_or_none(values: &[String]) -> String {
1034    if values.is_empty() {
1035        "none".into()
1036    } else {
1037        values.join(", ")
1038    }
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use std::path::PathBuf;
1044    use std::time::{SystemTime, UNIX_EPOCH};
1045
1046    use super::*;
1047    use kcode_session_history::{
1048        Config as HistoryConfig, NewSession, SessionHistory,
1049        chatend::{Representation, SessionKind},
1050    };
1051    use serde_json::json;
1052
1053    fn id(index: u8) -> String {
1054        NodeId::from_bytes([0, 0, 0, 0, 0, index])
1055            .unwrap()
1056            .to_string()
1057    }
1058
1059    fn connection(index: u8) -> Connection {
1060        Connection {
1061            id: id(index),
1062            short_name: format!("Node {index}"),
1063            short_description: format!("Summary {index}"),
1064        }
1065    }
1066
1067    fn node(index: u8, fixed: &[u8], recent: &[u8]) -> Node {
1068        Node {
1069            id: id(index),
1070            short_name: format!("Node {index}"),
1071            short_description: format!("Summary {index}"),
1072            long_description: format!("Long description {index}"),
1073            owner: id(1),
1074            fixed_connections: fixed.iter().copied().map(connection).collect(),
1075            recent_connections: recent.iter().copied().map(connection).collect(),
1076            objects: vec![],
1077            last_modified_by: "test-model-high".into(),
1078            last_modified_at: Some("2026-07-28T00:00:00Z".into()),
1079        }
1080    }
1081
1082    fn node_with_recent_description(
1083        index: u8,
1084        fixed: &[u8],
1085        recent: &[u8],
1086        description: &str,
1087    ) -> Node {
1088        let mut node = node(index, fixed, recent);
1089        for (connection, connection_index) in node.recent_connections.iter_mut().zip(recent) {
1090            connection.short_description = format!("{description} {connection_index}");
1091        }
1092        node
1093    }
1094
1095    fn draft(index: u8, recent: &[u8]) -> NodeDraft {
1096        NodeDraft {
1097            short_name: format!("Node {index}"),
1098            short_description: format!("Summary {index}"),
1099            long_description: format!("Long description {index}"),
1100            owner: id(1),
1101            fixed_connections: Vec::new(),
1102            recent_connections: recent.iter().map(|value| id(*value)).collect(),
1103            objects: Vec::new(),
1104        }
1105    }
1106
1107    fn test_journal(label: &str) -> (PathBuf, HistorySession) {
1108        let root = std::env::temp_dir().join(format!(
1109            "kcode-kweb-context-{label}-{}-{}",
1110            std::process::id(),
1111            SystemTime::now()
1112                .duration_since(UNIX_EPOCH)
1113                .unwrap()
1114                .as_nanos()
1115        ));
1116        let history = SessionHistory::open(HistoryConfig {
1117            directory: root.join("sessions"),
1118            completed_list: root.join("completed.jsonl"),
1119        })
1120        .unwrap();
1121        let journal = history
1122            .create_session(NewSession {
1123                kind: SessionKind::Conversation,
1124                created_at: "2026-07-29T00:00:00Z".into(),
1125                effective_context_tokens: 10_000,
1126                channel: Value::Null,
1127            })
1128            .unwrap();
1129        (root, journal)
1130    }
1131
1132    fn recent_box_ids(journal: &HistorySession) -> Vec<BoxId> {
1133        journal
1134            .state()
1135            .tool_layouts
1136            .get(KWEB_TOOL_INSTANCE)
1137            .into_iter()
1138            .flatten()
1139            .copied()
1140            .filter(|box_id| {
1141                journal
1142                    .state()
1143                    .box_state(*box_id)
1144                    .and_then(|state| state.canonical.content.metadata.get("kwebRole"))
1145                    .and_then(Value::as_str)
1146                    == Some("recent")
1147            })
1148            .collect()
1149    }
1150
1151    #[test]
1152    fn parses_the_kweb_wire_shape_into_typed_connections() {
1153        let parsed = Node::from_kweb_value(&json!({
1154            "id": id(1),
1155            "owner_node_id": id(1),
1156            "short_name": "Root",
1157            "short_description": "Root summary",
1158            "long_description": "Root details",
1159            "fixed_connections": [id(2)],
1160            "recent_connections": [id(3), id(3)],
1161            "objects": [],
1162            "connection_summaries": [
1163                {"id":id(2),"short_name":"Fixed","short_description":"Fixed summary"},
1164                {"id":id(3),"short_name":"Recent","short_description":"Recent summary"}
1165            ]
1166        }))
1167        .unwrap();
1168        assert_eq!(
1169            parsed.fixed_connections,
1170            vec![Connection {
1171                id: id(2),
1172                short_name: "Fixed".into(),
1173                short_description: "Fixed summary".into(),
1174            }]
1175        );
1176        assert_eq!(parsed.recent_connections.len(), 1);
1177        assert_eq!(parsed.recent_connections[0].short_name, "Recent");
1178    }
1179
1180    #[test]
1181    fn loaded_nodes_take_precedence_over_the_fixed_role() {
1182        let mut context = Context::new(vec![id(1)]).unwrap();
1183        context
1184            .apply_load(node(1, &[2], &[]), vec![node(2, &[], &[])])
1185            .unwrap();
1186        let report = context.apply_load(node(2, &[], &[]), Vec::new()).unwrap();
1187        assert!(report.promoted_from_fixed);
1188        assert_eq!(context.loaded_node_ids(), &[id(1), id(2)]);
1189        assert!(context.fixed_node_ids().is_empty());
1190        assert_eq!(
1191            context
1192                .box_specs(&BTreeMap::new(), &[])
1193                .unwrap()
1194                .iter()
1195                .map(|spec| spec.kind)
1196                .collect::<Vec<_>>(),
1197            vec![BoxKind::Loaded, BoxKind::Loaded, BoxKind::Recent]
1198        );
1199    }
1200
1201    #[test]
1202    fn full_node_kinds_share_one_body_format_without_active_connections() {
1203        let mut context = Context::new(vec![id(1)]).unwrap();
1204        context
1205            .apply_load(node(1, &[2], &[]), vec![node(2, &[], &[])])
1206            .unwrap();
1207        let create = StagedCreate {
1208            pending_id: "pending:1".into(),
1209            data: draft(3, &[]),
1210        };
1211        let specs = context.box_specs(&BTreeMap::new(), &[create]).unwrap();
1212        assert_eq!(specs[0].kind.name(), "Kweb loaded node");
1213        assert_eq!(specs[1].kind.name(), "Kweb fixed connection");
1214        assert_eq!(specs[2].kind.name(), "Kweb staged node");
1215        for spec in &specs[..3] {
1216            assert!(spec.text.contains("Node ID:"));
1217            assert!(spec.text.contains("Node name:"));
1218            assert!(spec.text.contains("Node owner ID:"));
1219            assert!(spec.text.contains("Fixed connection IDs:"));
1220            assert!(spec.text.contains("Recent connection IDs:"));
1221            assert!(!spec.text.contains("Active"));
1222        }
1223        assert_eq!(
1224            specs[0].text,
1225            concat!(
1226                "Node ID: AAAAAAAB\n",
1227                "Node name: Node 1\n",
1228                "Node summary: Summary 1\n",
1229                "Node owner ID: AAAAAAAB\n",
1230                "Node long description:\n",
1231                "  Long description 1\n",
1232                "Fixed connection IDs: AAAAAAAC\n",
1233                "Recent connection IDs: none"
1234            )
1235        );
1236    }
1237
1238    #[test]
1239    fn all_recent_connections_share_one_globally_deduplicated_box() {
1240        let mut context = Context::new(vec![id(1)]).unwrap();
1241        context
1242            .apply_load(node(1, &[2], &[4, 5]), vec![node(2, &[], &[5, 6, 7])])
1243            .unwrap();
1244        let creates = vec![StagedCreate {
1245            pending_id: "pending:1".into(),
1246            data: draft(3, &[6, 7]),
1247        }];
1248        let specs = context.box_specs(&BTreeMap::new(), &creates).unwrap();
1249        let recent = specs
1250            .iter()
1251            .filter(|spec| spec.kind == BoxKind::Recent)
1252            .collect::<Vec<_>>();
1253        assert_eq!(recent.len(), 1);
1254        assert_eq!(
1255            recent[0].text,
1256            format!(
1257                concat!(
1258                    "Recent connections\n",
1259                    "{} · Node 4: Summary 4\n",
1260                    "{} · Node 5: Summary 5\n",
1261                    "{} · Node 6: Summary 6\n",
1262                    "{} · Node 7: Summary 7"
1263                ),
1264                id(4),
1265                id(5),
1266                id(6),
1267                id(7)
1268            )
1269        );
1270    }
1271
1272    #[test]
1273    fn empty_recent_projection_is_still_one_exact_box() {
1274        let mut context = Context::new(vec![id(1)]).unwrap();
1275        context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1276        let specs = context.box_specs(&BTreeMap::new(), &[]).unwrap();
1277        assert_eq!(specs.len(), 2);
1278        assert_eq!(specs[1].kind, BoxKind::Recent);
1279        assert_eq!(specs[1].text, "Recent connections\nNone.");
1280    }
1281
1282    #[test]
1283    fn recent_projection_rejects_missing_name_or_description() {
1284        for missing_name in [true, false] {
1285            let mut source = node(1, &[], &[2]);
1286            if missing_name {
1287                source.recent_connections[0].short_name.clear();
1288            } else {
1289                source.recent_connections[0].short_description.clear();
1290            }
1291            let mut context = Context::new(vec![id(1)]).unwrap();
1292            context.apply_load(source, Vec::new()).unwrap();
1293            assert_eq!(
1294                context
1295                    .box_specs(&BTreeMap::new(), &[])
1296                    .unwrap_err()
1297                    .to_string(),
1298                format!(
1299                    "recent connection {} must resolve to a nonempty short name and short description",
1300                    id(2)
1301                )
1302            );
1303        }
1304    }
1305
1306    #[test]
1307    fn staged_updates_drive_full_text_and_recent_projection() {
1308        let mut context = Context::new(vec![id(1)]).unwrap();
1309        context
1310            .apply_load(node(1, &[3], &[2]), vec![node(3, &[], &[])])
1311            .unwrap();
1312        let mut updates = BTreeMap::new();
1313        updates.insert(id(1), draft(9, &[3]));
1314        let specs = context.box_specs(&updates, &[]).unwrap();
1315        assert!(specs[0].text.contains("Node name: Node 9"));
1316        assert!(specs[0].staged_node.is_some());
1317        assert!(!specs.last().unwrap().text.contains(&id(2)));
1318        assert!(specs.last().unwrap().text.contains(&id(3)));
1319    }
1320
1321    #[test]
1322    fn sync_fills_permanent_recent_boxes_eight_at_a_time() {
1323        let (root, mut journal) = test_journal("recent-boxes");
1324        let mut context = Context::new(vec![id(1)]).unwrap();
1325        let initial_indices = (2..=19).collect::<Vec<_>>();
1326        context
1327            .apply_load(
1328                node_with_recent_description(1, &[], &initial_indices, "old"),
1329                Vec::new(),
1330            )
1331            .unwrap();
1332        context
1333            .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1334            .unwrap();
1335
1336        let original_ids = recent_box_ids(&journal);
1337        assert_eq!(
1338            original_ids
1339                .iter()
1340                .map(|box_id| {
1341                    recent_connection_entries(
1342                        &journal
1343                            .state()
1344                            .box_state(*box_id)
1345                            .unwrap()
1346                            .canonical
1347                            .content,
1348                    )
1349                    .unwrap()
1350                    .len()
1351                })
1352                .collect::<Vec<_>>(),
1353            vec![8, 8, 2]
1354        );
1355        let original_revisions = original_ids
1356            .iter()
1357            .map(|box_id| {
1358                journal
1359                    .state()
1360                    .box_state(*box_id)
1361                    .unwrap()
1362                    .canonical
1363                    .event_id
1364            })
1365            .collect::<Vec<_>>();
1366        journal
1367            .summarize_box("t2", original_ids[0], "retained first box")
1368            .unwrap();
1369        journal.dehydrate_box("t3", original_ids[1]).unwrap();
1370        journal.dehydrate_box("t3", original_ids[2]).unwrap();
1371
1372        let expanded_indices = (2..=28).collect::<Vec<_>>();
1373        context
1374            .refresh([node_with_recent_description(
1375                1,
1376                &[],
1377                &expanded_indices,
1378                "new",
1379            )])
1380            .unwrap();
1381        let changed = context
1382            .sync_chatend(&mut journal, "t4", &BTreeMap::new(), &[])
1383            .unwrap();
1384        let current_ids = recent_box_ids(&journal);
1385        assert_eq!(
1386            current_ids
1387                .iter()
1388                .map(|box_id| {
1389                    recent_connection_entries(
1390                        &journal
1391                            .state()
1392                            .box_state(*box_id)
1393                            .unwrap()
1394                            .canonical
1395                            .content,
1396                    )
1397                    .unwrap()
1398                    .len()
1399                })
1400                .collect::<Vec<_>>(),
1401            vec![8, 8, 8, 3]
1402        );
1403        assert_eq!(&current_ids[..3], original_ids.as_slice());
1404        assert!(!changed.contains(&original_ids[0]));
1405        assert!(!changed.contains(&original_ids[1]));
1406        assert!(changed.contains(&original_ids[2]));
1407        assert!(changed.contains(&current_ids[3]));
1408
1409        let first = journal.state().box_state(original_ids[0]).unwrap();
1410        assert_eq!(first.canonical.event_id, original_revisions[0]);
1411        assert!(matches!(
1412            first.representation,
1413            Representation::Summarized { based_on, .. } if based_on == first.canonical.event_id
1414        ));
1415        let second = journal.state().box_state(original_ids[1]).unwrap();
1416        assert_eq!(second.canonical.event_id, original_revisions[1]);
1417        assert!(matches!(
1418            second.representation,
1419            Representation::Dehydrated { based_on } if based_on == second.canonical.event_id
1420        ));
1421        let third = journal.state().box_state(original_ids[2]).unwrap();
1422        assert_ne!(third.canonical.event_id, original_revisions[2]);
1423        assert!(third.canonical.content.text.contains("old 19"));
1424        assert!(third.canonical.content.text.contains("new 25"));
1425        assert!(!third.canonical.content.text.contains("new 19"));
1426        assert!(matches!(
1427            third.representation,
1428            Representation::Dehydrated { based_on } if based_on == original_revisions[2]
1429        ));
1430
1431        drop(journal);
1432        std::fs::remove_dir_all(root).unwrap();
1433    }
1434
1435    #[test]
1436    fn sync_starts_one_empty_fillable_recent_box() {
1437        let (root, mut journal) = test_journal("empty-recent-box");
1438        let mut context = Context::new(vec![id(1)]).unwrap();
1439        context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1440        context
1441            .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1442            .unwrap();
1443
1444        let boxes = recent_box_ids(&journal);
1445        assert_eq!(boxes.len(), 1);
1446        let content = &journal
1447            .state()
1448            .box_state(boxes[0])
1449            .unwrap()
1450            .canonical
1451            .content;
1452        assert!(recent_connection_entries(content).unwrap().is_empty());
1453        assert_eq!(content.metadata[RECENT_CONNECTION_IDS_METADATA], json!([]));
1454
1455        drop(journal);
1456        std::fs::remove_dir_all(root).unwrap();
1457    }
1458
1459    #[test]
1460    fn sync_reports_only_changed_boxes_in_projection_order() {
1461        let (root, mut journal) = test_journal("changed-boxes");
1462        let mut context = Context::new(vec![id(1)]).unwrap();
1463        context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1464        let initial = context
1465            .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1466            .unwrap();
1467        assert_eq!(initial.len(), 2);
1468        assert!(
1469            context
1470                .sync_chatend(&mut journal, "t2", &BTreeMap::new(), &[],)
1471                .unwrap()
1472                .is_empty()
1473        );
1474
1475        context
1476            .apply_load(node(1, &[2], &[]), vec![node(2, &[], &[])])
1477            .unwrap();
1478        let changed = context
1479            .sync_chatend(&mut journal, "t3", &BTreeMap::new(), &[])
1480            .unwrap();
1481        assert_eq!(changed.len(), 2);
1482        assert_eq!(
1483            changed
1484                .iter()
1485                .map(|box_id| journal.state().box_state(*box_id).unwrap().name.as_str())
1486                .collect::<Vec<_>>(),
1487            vec!["Kweb loaded node", "Kweb fixed connection"]
1488        );
1489
1490        drop(journal);
1491        std::fs::remove_dir_all(root).unwrap();
1492    }
1493}