Scope branch listings to a divergence point via node_id

ForkInfo now carries the divergence node's id, and the branches endpoint
accepts node_id + viewing_branch_id to return only the position-ranked
sibling branches at that fork (snippets taken from after the divergence,
skipping tool scaffolding) instead of every leaf in the tree. Options in
the same subtree as the viewing leaf anchor to that leaf so clients can
reliably identify "the branch I'm on". Tree-wide listing is unchanged
when node_id is absent.

- llm_client: descendant_leaves + branch_options_at helpers; optional
  position rank on BranchLeafInfo.
- insight_chat: ForkInfo.node_id set during render_tree_path; get_branches
  takes the scoping params.
- handlers: dedicated ChatBranchesQuery; unknown node_id maps to 400.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 4.8 committed 2026-08-07 19:39:57 -04:00
1 parent 1512fc5bdc
commit ee8a11d421
3 files changed
+220 -24

No files matched your search

+137
View File
@@ -288,6 +288,67 @@ impl ChatHistoryStore {
Some((position, total))
}
/// All leaf node IDs within the subtree rooted at `node_id` (the node
/// itself when it has no children). Empty when the node doesn't exist.
pub fn descendant_leaves(&self, node_id: u64) -> Vec<u64> {
if !self.nodes.iter().any(|n| n.id == node_id) {
return Vec::new();
}
let mut result = Vec::new();
let mut stack = vec![node_id];
while let Some(id) = stack.pop() {
let children = self.children_of(id);
if children.is_empty() {
result.push(id);
} else {
stack.extend(children.iter().map(|c| c.id));
}
}
result
}
/// Branch options at a divergence point: one entry per direct child of
/// `fork_node_id`, ordered by id (chronological — matches the stable
/// position rank shown in "X/Y" fork chips).
///
/// Each entry's `id` is the leaf to switch to for that branch: the
/// `preferred_leaf` when it lives in that child's subtree (so the
/// caller's current view maps onto its own option and can be filtered
/// out client-side), otherwise the most recent (max-id) descendant leaf.
pub fn branch_options_at(&self, fork_node_id: u64, preferred_leaf: u64) -> Vec<BranchLeafInfo> {
self.children_of(fork_node_id)
.iter()
.enumerate()
.map(|(i, child)| {
let leaves = self.descendant_leaves(child.id);
let rep = if leaves.contains(&preferred_leaf) {
preferred_leaf
} else {
leaves.iter().copied().max().unwrap_or(child.id)
};
let path = self.path_to_leaf(rep).unwrap_or_default();
let message_count = path.len();
// Snippet: first visible message *after* the divergence point —
// skip tool scaffolding (tool results, empty dispatch turns).
let snippet = path
.iter()
.skip_while(|n| n.id != child.id)
.find(|n| {
(n.message.role == "user" || n.message.role == "assistant")
&& !n.message.content.trim().is_empty()
})
.map(|n| n.message.content.chars().take(100).collect::<String>())
.unwrap_or_default();
BranchLeafInfo {
id: rep,
snippet,
message_count,
position: Some(i + 1),
}
})
.collect()
}
/// All leaf node IDs (nodes with no children).
pub fn leaves(&self) -> Vec<u64> {
let child_ids: std::collections::HashSet<u64> =
@@ -328,6 +389,7 @@ impl ChatHistoryStore {
id: leaf_id,
snippet,
message_count,
position: None,
}
})
.collect()
@@ -341,6 +403,11 @@ pub struct BranchLeafInfo {
#[serde(skip_serializing_if = "String::is_empty")]
pub snippet: String,
pub message_count: usize,
/// 1-based sibling rank at the divergence point. Only present for
/// scoped queries (`branch_options_at`); tree-wide leaf listings have
/// no single divergence to rank against.
#[serde(skip_serializing_if = "Option::is_none")]
pub position: Option<usize>,
}
/// Strip a leading `<think>…</think>` reasoning block from model output.
@@ -515,6 +582,76 @@ mod tests {
assert_eq!(store.next_id(), 3);
}
#[test]
fn store_descendant_leaves_walks_subtree() {
let mut store = ChatHistoryStore::empty();
let root = store.append_node(None, mk_msg("user", "root"));
let c1 = store.append_node(Some(root), mk_msg("assistant", "c1"));
let c2 = store.append_node(Some(root), mk_msg("user", "c2"));
let d1 = store.append_node(Some(c1), mk_msg("assistant", "d1"));
let d2 = store.append_node(Some(c1), mk_msg("assistant", "d2"));
// c1's subtree has two leaves; c2 is its own leaf; root sees all.
let mut c1_leaves = store.descendant_leaves(c1);
c1_leaves.sort_unstable();
assert_eq!(c1_leaves, vec![d1, d2]);
assert_eq!(store.descendant_leaves(c2), vec![c2]);
let mut all = store.descendant_leaves(root);
all.sort_unstable();
assert_eq!(all, vec![c2, d1, d2]);
assert!(store.descendant_leaves(999).is_empty());
}
#[test]
fn branch_options_ranks_children_and_prefers_viewing_leaf() {
let mut store = ChatHistoryStore::empty();
let root = store.append_node(None, mk_msg("user", "root"));
// First branch: two leaves (older + newer).
let c1 = store.append_node(Some(root), mk_msg("assistant", "reply one"));
let old_leaf = store.append_node(Some(c1), mk_msg("user", "old follow-up"));
let new_leaf = store.append_node(Some(c1), mk_msg("user", "new follow-up"));
// Second branch: single leaf.
let c2 = store.append_node(Some(root), mk_msg("assistant", "reply two"));
// Viewing the *older* leaf of branch 1: its option must anchor to
// that exact leaf, not the max-id one, so the client's
// "hide the branch I'm on" filter matches.
let opts = store.branch_options_at(root, old_leaf);
assert_eq!(opts.len(), 2);
assert_eq!(opts[0].id, old_leaf);
assert_eq!(opts[0].position, Some(1));
assert_eq!(opts[0].snippet, "reply one");
assert_eq!(opts[1].id, c2);
assert_eq!(opts[1].position, Some(2));
// Viewing branch 2: branch 1 falls back to its most recent leaf.
let opts = store.branch_options_at(root, c2);
assert_eq!(opts[0].id, new_leaf);
assert_eq!(opts[1].id, c2);
}
#[test]
fn branch_options_snippet_skips_tool_scaffolding() {
let mut store = ChatHistoryStore::empty();
let root = store.append_node(None, mk_msg("user", "root"));
// Branch whose diverging child is an empty tool-dispatch turn.
let dispatch = store.append_node(
Some(root),
ChatMessage {
role: "assistant".to_string(),
content: String::new(),
tool_calls: None,
images: None,
},
);
let tool = store.append_node(Some(dispatch), mk_msg("tool", "raw tool output"));
let reply = store.append_node(Some(tool), mk_msg("assistant", "final answer"));
let _other = store.append_node(Some(root), mk_msg("assistant", "direct reply"));
let opts = store.branch_options_at(root, reply);
assert_eq!(opts[0].snippet, "final answer");
assert_eq!(opts[1].snippet, "direct reply");
}
#[test]
fn store_append_node_sets_parent() {
let mut store = ChatHistoryStore::empty();