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 Cordes
2026-08-07 19:39:57 -04:00
parent 1512fc5bdc
commit ee8a11d421
3 changed files with 220 additions and 24 deletions
+47 -19
View File
@@ -1335,6 +1335,9 @@ pub struct ChatHistoryHttpResponse {
pub struct ChatForkInfo { pub struct ChatForkInfo {
pub position: usize, pub position: usize,
pub total: usize, pub total: usize,
/// The divergence tree node. Pass as `node_id` to the branches endpoint
/// for a list scoped to this exact fork.
pub node_id: u64,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -1443,6 +1446,7 @@ pub async fn chat_history_handler(
f.map(|fi| ChatForkInfo { f.map(|fi| ChatForkInfo {
position: fi.position, position: fi.position,
total: fi.total, total: fi.total,
node_id: fi.node_id,
}) })
}) })
.collect(); .collect();
@@ -1487,14 +1491,32 @@ pub async fn chat_history_handler(
} }
} }
/// GET /insights/chat/branches — return the list of branch leaf IDs for a /// Query for GET /insights/chat/branches.
/// photo's conversation tree. Each branch is a leaf node; the active branch #[derive(Debug, Deserialize)]
/// is the one matching `active_leaf_id`. Use with `branch_id` on the pub struct ChatBranchesQuery {
/// history endpoint to load an alternate conversation path. pub path: String,
#[serde(default)]
pub library: Option<String>,
/// When set (from `ForkInfo.node_id`), scope the list to the sibling
/// branches diverging at this tree node instead of every leaf.
#[serde(default)]
pub node_id: Option<u64>,
/// The leaf the client is currently viewing. Scoped options in the
/// same subtree anchor to it so the client can identify its own branch.
/// Defaults to the active leaf when absent.
#[serde(default)]
pub viewing_branch_id: Option<u64>,
}
/// GET /insights/chat/branches — return the branch list for a photo's
/// conversation tree. Without `node_id`, each entry is a leaf node;
/// with `node_id`, entries are the position-ranked sibling branches at
/// that divergence point. Use an entry's `id` with `branch_id` on the
/// history endpoint (or the switch-branch endpoint) to load that path.
#[get("/insights/chat/branches")] #[get("/insights/chat/branches")]
pub async fn chat_branches_handler( pub async fn chat_branches_handler(
_claims: Claims, _claims: Claims,
query: web::Query<ChatHistoryQuery>, query: web::Query<ChatBranchesQuery>,
app_state: web::Data<AppState>, app_state: web::Data<AppState>,
) -> impl Responder { ) -> impl Responder {
let library = libraries::resolve_library_param_state(&app_state, query.library.as_deref()) let library = libraries::resolve_library_param_state(&app_state, query.library.as_deref())
@@ -1502,21 +1524,27 @@ pub async fn chat_branches_handler(
.flatten() .flatten()
.unwrap_or_else(|| app_state.primary_library()); .unwrap_or_else(|| app_state.primary_library());
let (branches, active_leaf_id) = let (branches, active_leaf_id) = match app_state.insight_chat.get_branches(
match app_state.insight_chat.get_branches(library.id, &query.path) { library.id,
Ok(result) => result, &query.path,
Err(e) => { query.node_id,
let msg = format!("{}", e); query.viewing_branch_id,
if msg.contains("no insight found") { ) {
return HttpResponse::NotFound().json(serde_json::json!({ "error": msg })); Ok(result) => result,
} else if msg.contains("no chat history") { Err(e) => {
return HttpResponse::Conflict().json(serde_json::json!({ "error": msg })); let msg = format!("{}", e);
} else { if msg.contains("no insight found") {
return HttpResponse::InternalServerError() return HttpResponse::NotFound().json(serde_json::json!({ "error": msg }));
.json(serde_json::json!({ "error": msg })); } else if msg.contains("no chat history") {
} return HttpResponse::Conflict().json(serde_json::json!({ "error": msg }));
} else if msg.contains("not found in tree") {
return HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }));
} else {
return HttpResponse::InternalServerError()
.json(serde_json::json!({ "error": msg }));
} }
}; }
};
HttpResponse::Ok().json(serde_json::json!({ HttpResponse::Ok().json(serde_json::json!({
"branches": branches, "branches": branches,
+36 -5
View File
@@ -717,12 +717,20 @@ impl InsightChatService {
Ok(()) Ok(())
} }
/// Return branch leaf metadata (id, snippet, message_count) and the /// Return branch metadata and the active leaf ID for a photo's
/// active leaf ID for a photo's conversation tree. /// conversation tree.
///
/// With `node_id = None` this lists every leaf in the tree. With
/// `node_id = Some(fork)` (from `ForkInfo.node_id`) it lists only the
/// sibling branches diverging at that node, position-ranked; options in
/// the same subtree as `viewing_leaf` anchor to that leaf so the client
/// can recognise "the branch I'm on".
pub fn get_branches( pub fn get_branches(
&self, &self,
library_id: i32, library_id: i32,
file_path: &str, file_path: &str,
node_id: Option<u64>,
viewing_leaf: Option<u64>,
) -> Result<(Vec<BranchLeafInfo>, u64)> { ) -> Result<(Vec<BranchLeafInfo>, u64)> {
let normalized = normalize_path(file_path); let normalized = normalize_path(file_path);
let cx = opentelemetry::Context::new(); let cx = opentelemetry::Context::new();
@@ -745,7 +753,16 @@ impl InsightChatService {
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))? .map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?
}; };
Ok((store.leaves_with_info(), store.active_leaf_id)) let list = match node_id {
Some(fork_node) => {
if !store.nodes.iter().any(|n| n.id == fork_node) {
bail!("node_id {} not found in tree", fork_node);
}
store.branch_options_at(fork_node, viewing_leaf.unwrap_or(store.active_leaf_id))
}
None => store.leaves_with_info(),
};
Ok((list, store.active_leaf_id))
} }
/// Streaming variant of `chat_turn`. Emits user-facing events as the /// Streaming variant of `chat_turn`. Emits user-facing events as the
@@ -2465,6 +2482,10 @@ pub(crate) fn restore_system_prompt_override(
pub struct ForkInfo { pub struct ForkInfo {
pub position: usize, pub position: usize,
pub total: usize, pub total: usize,
/// The divergence node itself (the parent whose children fork). Pass as
/// `node_id` to the branches endpoint to list the sibling branches at
/// this exact point rather than every leaf in the tree.
pub node_id: u64,
} }
/// Render a path of tree nodes into the UI-friendly `RenderedMessage` format, /// Render a path of tree nodes into the UI-friendly `RenderedMessage` format,
@@ -2498,8 +2519,16 @@ fn render_tree_path(
// Update the carried fork BEFORE rendering: a non-rendered fork child // Update the carried fork BEFORE rendering: a non-rendered fork child
// (e.g. a tool-dispatch assistant) must colour the rendered message // (e.g. a tool-dispatch assistant) must colour the rendered message
// that follows it. // that follows it.
if let Some((position, total)) = store.fork_at_node(node.id) { // (fork_at_node only fires for nodes with a parent, so the pair
last_fork = Some(ForkInfo { position, total }); // pattern below can't drop a real fork.)
if let (Some(node_id), Some((position, total))) =
(node.parent_id, store.fork_at_node(node.id))
{
last_fork = Some(ForkInfo {
position,
total,
node_id,
});
} }
let msg = &node.message; let msg = &node.message;
match msg.role.as_str() { match msg.role.as_str() {
@@ -2839,6 +2868,7 @@ mod tests {
.as_ref() .as_ref()
.expect("fork indicator carried onto the rendered reply"); .expect("fork indicator carried onto the rendered reply");
assert_eq!((f.position, f.total), (2, 2)); assert_eq!((f.position, f.total), (2, 2));
assert_eq!(f.node_id, u, "divergence node is the forked user turn");
} }
#[test] #[test]
@@ -2859,6 +2889,7 @@ mod tests {
.as_ref() .as_ref()
.expect("fork indicator on the divergent rendered reply"); .expect("fork indicator on the divergent rendered reply");
assert_eq!((f.position, f.total), (2, 2)); assert_eq!((f.position, f.total), (2, 2));
assert_eq!(f.node_id, u, "divergence node is the forked user turn");
} }
#[test] #[test]
+137
View File
@@ -288,6 +288,67 @@ impl ChatHistoryStore {
Some((position, total)) 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). /// All leaf node IDs (nodes with no children).
pub fn leaves(&self) -> Vec<u64> { pub fn leaves(&self) -> Vec<u64> {
let child_ids: std::collections::HashSet<u64> = let child_ids: std::collections::HashSet<u64> =
@@ -328,6 +389,7 @@ impl ChatHistoryStore {
id: leaf_id, id: leaf_id,
snippet, snippet,
message_count, message_count,
position: None,
} }
}) })
.collect() .collect()
@@ -341,6 +403,11 @@ pub struct BranchLeafInfo {
#[serde(skip_serializing_if = "String::is_empty")] #[serde(skip_serializing_if = "String::is_empty")]
pub snippet: String, pub snippet: String,
pub message_count: usize, 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. /// Strip a leading `<think>…</think>` reasoning block from model output.
@@ -515,6 +582,76 @@ mod tests {
assert_eq!(store.next_id(), 3); 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] #[test]
fn store_append_node_sets_parent() { fn store_append_node_sets_parent() {
let mut store = ChatHistoryStore::empty(); let mut store = ChatHistoryStore::empty();