Skip to main content

kcode_kweb_context/
lib.rs

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