Skip to main content

kcode_kweb_context/
lib.rs

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