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