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