Add conversation branching to insight chat (rewind preserves forks)
Store chat history as a tree in training_messages instead of a flat
array. Rewind now sets active_leaf_id to the target node rather than
truncating, so discarded paths survive as alternate branches. Fork
indicators ("X/Y") mark divergence points and let the client load or
switch to alternate paths.
- llm_client: StoredChatNode/ChatHistoryStore with tree traversal
helpers (path_to_leaf, children_of, fork_at_node, leaves*, etc.) and
from_flat_array for backward-compatible reads of the old flat format.
- insight_chat: load_history/chat_turn/rewind_history operate on the
tree; new switch_branch and get_branches. render_tree_path walks every
node (including non-rendered tool-dispatch nodes) when computing fork
info, so a fork whose diverging child is a tool call still surfaces on
the following rendered message.
- handlers: GET /insights/chat/branches, POST /insights/chat/switch-branch,
and a branch_id param on the history endpoint.
Backward-compatible: flat arrays are converted to a tree lazily on read.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+394
-114
@@ -8,7 +8,9 @@ use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
use crate::ai::backend::{BackendKind, ResolvedBackend, SamplingOverrides};
|
||||
use crate::ai::insight_generator::InsightGenerator;
|
||||
use crate::ai::llm_client::{ChatMessage, LlmStreamEvent, Tool};
|
||||
use crate::ai::llm_client::{
|
||||
BranchLeafInfo, ChatHistoryStore, ChatMessage, LlmStreamEvent, StoredChatNode, Tool,
|
||||
};
|
||||
use crate::ai::turn_registry::TurnEntry;
|
||||
use crate::ai::turn_registry::TurnRegistry;
|
||||
use crate::database::InsightDao;
|
||||
@@ -137,19 +139,20 @@ impl InsightChatService {
|
||||
&self.insight_dao
|
||||
}
|
||||
|
||||
/// Load the rendered transcript for chat-UI display. Filters internal
|
||||
/// scaffolding (system message, tool turns, tool-dispatch-only assistant
|
||||
/// messages) and drops base64 images from user turns to keep payloads
|
||||
/// small. The first remaining user message is flagged `is_initial`.
|
||||
/// Load the rendered transcript for chat-UI display. Deserializes the
|
||||
/// `training_messages` tree, traverses to `active_leaf_id`, and renders
|
||||
/// the path from root to that leaf. Backward-compatible: if the stored
|
||||
/// value is a flat array (old format), converts to a tree automatically.
|
||||
///
|
||||
/// `library_id` scopes the lookup to one library — without it, a
|
||||
/// regenerate on lib1 can be shadowed on the next refresh by an
|
||||
/// untouched `is_current=true` row in lib2 for the same rel_path.
|
||||
/// Falls back to the cross-library `get_insight` only when the
|
||||
/// scoped lookup misses, preserving the cross-library "show this
|
||||
/// photo's primary insight" merge for the case where the active
|
||||
/// library has no insight but another library does.
|
||||
pub fn load_history(&self, library_id: i32, file_path: &str) -> Result<HistoryView> {
|
||||
pub fn load_history(
|
||||
&self,
|
||||
library_id: i32,
|
||||
file_path: &str,
|
||||
branch_id: Option<u64>,
|
||||
) -> Result<HistoryView> {
|
||||
let normalized = normalize_path(file_path);
|
||||
let cx = opentelemetry::Context::new();
|
||||
let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao");
|
||||
@@ -168,98 +171,32 @@ impl InsightChatService {
|
||||
.training_messages
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("insight has no chat history (pre-agentic insight)"))?;
|
||||
let messages: Vec<ChatMessage> = serde_json::from_str(raw)
|
||||
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?;
|
||||
|
||||
let mut rendered = Vec::new();
|
||||
let mut user_turns_seen = 0usize;
|
||||
let mut assistant_turns_seen = 0usize;
|
||||
// Backward-compatible deserialization: flat array (old) vs tree (new).
|
||||
let store: ChatHistoryStore = if let Ok(arr) = serde_json::from_str::<Vec<ChatMessage>>(raw)
|
||||
{
|
||||
ChatHistoryStore::from_flat_array(arr)
|
||||
} else {
|
||||
serde_json::from_str(raw)
|
||||
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?
|
||||
};
|
||||
|
||||
// Accumulate tool invocations seen since the last user turn. An
|
||||
// invocation is: one assistant tool_call message (which may hold
|
||||
// multiple calls) + the N following tool-role messages (one per call,
|
||||
// in order). They attach to the next assistant-with-content, which
|
||||
// is the "final" reply for the current turn.
|
||||
//
|
||||
// Wire shape from the model:
|
||||
// assistant { tool_calls: [A, B], content: "" }
|
||||
// tool { content: "result of A" }
|
||||
// tool { content: "result of B" }
|
||||
// assistant { content: "here's the answer" } ← rendered as final
|
||||
let mut pending_tools: Vec<ToolInvocation> = Vec::new();
|
||||
// Queue of (name, arguments) awaiting a tool_result to pair with.
|
||||
let mut pending_calls: std::collections::VecDeque<(String, serde_json::Value)> =
|
||||
std::collections::VecDeque::new();
|
||||
// Use branch_id if provided, otherwise use active_leaf_id.
|
||||
let target_leaf = branch_id.unwrap_or(store.active_leaf_id);
|
||||
let path = store
|
||||
.path_to_leaf(target_leaf)
|
||||
.ok_or_else(|| anyhow!("branch_id {} not found in tree", target_leaf))?;
|
||||
|
||||
for msg in &messages {
|
||||
match msg.role.as_str() {
|
||||
"system" => continue,
|
||||
"tool" => {
|
||||
if let Some((name, arguments)) = pending_calls.pop_front() {
|
||||
let (result, result_truncated) = truncate_tool_result(&msg.content);
|
||||
pending_tools.push(ToolInvocation {
|
||||
name,
|
||||
arguments,
|
||||
result,
|
||||
result_truncated,
|
||||
});
|
||||
}
|
||||
// If there's no pending call, the tool message is an
|
||||
// orphan (shouldn't happen in practice) — skip silently.
|
||||
}
|
||||
"assistant" => {
|
||||
let has_tool_calls = msg
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.map(|c| !c.is_empty())
|
||||
.unwrap_or(false);
|
||||
if has_tool_calls && msg.content.trim().is_empty() {
|
||||
// Tool-dispatch turn: enqueue calls, wait for tool
|
||||
// results on subsequent messages.
|
||||
if let Some(ref tcs) = msg.tool_calls {
|
||||
for tc in tcs {
|
||||
pending_calls.push_back((
|
||||
tc.function.name.clone(),
|
||||
tc.function.arguments.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Final assistant reply for this turn — drain accumulated
|
||||
// tools into it.
|
||||
assistant_turns_seen += 1;
|
||||
let tools = std::mem::take(&mut pending_tools);
|
||||
pending_calls.clear(); // any leftover unpaired calls are dropped
|
||||
rendered.push(RenderedMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: msg.content.clone(),
|
||||
is_initial: false,
|
||||
tools,
|
||||
});
|
||||
}
|
||||
"user" => {
|
||||
let is_initial = user_turns_seen == 0;
|
||||
user_turns_seen += 1;
|
||||
// New user turn resets any in-flight tool state.
|
||||
pending_tools.clear();
|
||||
pending_calls.clear();
|
||||
rendered.push(RenderedMessage {
|
||||
role: "user".to_string(),
|
||||
content: msg.content.clone(),
|
||||
is_initial,
|
||||
tools: Vec::new(),
|
||||
});
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
let (rendered, turn_count, _node_ids, fork_info) = render_tree_path(&store, &path);
|
||||
|
||||
Ok(HistoryView {
|
||||
messages: rendered,
|
||||
turn_count: assistant_turns_seen,
|
||||
turn_count,
|
||||
model_version: insight.model_version,
|
||||
backend: insight.backend,
|
||||
active_leaf_id: store.active_leaf_id,
|
||||
viewing_branch_id: target_leaf,
|
||||
fork_info,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -315,8 +252,21 @@ impl InsightChatService {
|
||||
anyhow!("insight has no chat history; regenerate this insight in agentic mode")
|
||||
})?
|
||||
.clone();
|
||||
let mut messages: Vec<ChatMessage> = serde_json::from_str(&raw_history)
|
||||
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?;
|
||||
|
||||
// Backward-compatible: flat array (old) vs tree (new).
|
||||
let mut store: ChatHistoryStore =
|
||||
if let Ok(arr) = serde_json::from_str::<Vec<ChatMessage>>(&raw_history) {
|
||||
ChatHistoryStore::from_flat_array(arr)
|
||||
} else {
|
||||
serde_json::from_str(&raw_history)
|
||||
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?
|
||||
};
|
||||
|
||||
// Build messages from the path to the active leaf.
|
||||
let path = store
|
||||
.path_to_leaf(store.active_leaf_id)
|
||||
.ok_or_else(|| anyhow!("active_leaf_id {} not found in tree", store.active_leaf_id))?;
|
||||
let mut messages: Vec<ChatMessage> = path.iter().map(|n| n.message.clone()).collect();
|
||||
|
||||
// 3. Resolve effective backend. Reject the unsupported switch.
|
||||
let stored_backend = insight.backend.clone();
|
||||
@@ -509,8 +459,22 @@ impl InsightChatService {
|
||||
// 9. Persist. Append mode rewrites the JSON blob in place; amend
|
||||
// mode regenerates the title and inserts a new insight row,
|
||||
// relying on store_insight to flip prior rows' is_current=false.
|
||||
let json = serde_json::to_string(&messages)
|
||||
.map_err(|e| anyhow!("failed to serialize chat history: {}", e))?;
|
||||
// Append new messages (this turn's user + assistant exchanges) as
|
||||
// tree nodes chained from the previous active_leaf_id.
|
||||
let path_len = path.len();
|
||||
let new_messages = messages[path_len..].to_vec();
|
||||
let mut parent_id = Some(store.active_leaf_id);
|
||||
for msg in &new_messages {
|
||||
let new_id = store.append_node(parent_id, msg.clone());
|
||||
parent_id = Some(new_id);
|
||||
}
|
||||
// Update active_leaf_id to the last node appended this turn.
|
||||
if let Some(last_id) = parent_id {
|
||||
store.active_leaf_id = last_id;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string(&store)
|
||||
.map_err(|e| anyhow!("failed to serialize chat tree: {}", e))?;
|
||||
|
||||
let mut amended_insight_id: Option<i32> = None;
|
||||
if req.amend {
|
||||
@@ -597,11 +561,14 @@ impl InsightChatService {
|
||||
})
|
||||
}
|
||||
|
||||
/// Truncate the stored conversation so the rendered message at
|
||||
/// `discard_from_rendered_index` (and everything after it — including
|
||||
/// the tool-call scaffolding that produced a discarded assistant reply)
|
||||
/// is removed. The initial user turn cannot be discarded; attempting to
|
||||
/// do so returns an error.
|
||||
/// Rewind the conversation to the rendered message at
|
||||
/// `discard_from_rendered_index` by setting `active_leaf_id` to the
|
||||
/// node just before the discarded message. Unlike the legacy flat-array
|
||||
/// approach, this preserves all fork branches — the discarded path
|
||||
/// remains accessible as an alternate branch.
|
||||
///
|
||||
/// The initial user turn cannot be discarded; attempting to do so
|
||||
/// returns an error.
|
||||
///
|
||||
/// Holds the per-file chat mutex so it serialises with `chat_turn`.
|
||||
pub async fn rewind_history(
|
||||
@@ -636,15 +603,30 @@ impl InsightChatService {
|
||||
.training_messages
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("insight has no chat history"))?;
|
||||
let messages: Vec<ChatMessage> = serde_json::from_str(raw_history)
|
||||
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?;
|
||||
|
||||
let cut_at = find_raw_cut(&messages, discard_from_rendered_index)
|
||||
let mut store: ChatHistoryStore =
|
||||
if let Ok(arr) = serde_json::from_str::<Vec<ChatMessage>>(raw_history) {
|
||||
ChatHistoryStore::from_flat_array(arr)
|
||||
} else {
|
||||
serde_json::from_str(raw_history)
|
||||
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?
|
||||
};
|
||||
|
||||
let path = store
|
||||
.path_to_leaf(store.active_leaf_id)
|
||||
.ok_or_else(|| anyhow!("active_leaf_id not found in tree"))?;
|
||||
|
||||
let (_rendered, _turn_count, node_ids, _fork_info) = render_tree_path(&store, &path);
|
||||
|
||||
// The last kept rendered message is at index `discard_from_rendered_index - 1`.
|
||||
let last_kept_idx = discard_from_rendered_index - 1;
|
||||
let new_active_leaf_id = *node_ids
|
||||
.get(last_kept_idx)
|
||||
.ok_or_else(|| anyhow!("discard_from_rendered_index out of range"))?;
|
||||
|
||||
let truncated = &messages[..cut_at];
|
||||
let json = serde_json::to_string(truncated)
|
||||
.map_err(|e| anyhow!("failed to serialize truncated history: {}", e))?;
|
||||
store.active_leaf_id = new_active_leaf_id;
|
||||
let json = serde_json::to_string(&store)
|
||||
.map_err(|e| anyhow!("failed to serialize tree: {}", e))?;
|
||||
|
||||
let dao = self.insight_dao.clone();
|
||||
let rows = retry_with_backoff("update_training_messages", 3, || {
|
||||
@@ -652,7 +634,7 @@ impl InsightChatService {
|
||||
let mut d = dao.lock().expect("Unable to lock InsightDao");
|
||||
d.update_training_messages(&cx, library_id, &normalized, &json)
|
||||
})
|
||||
.map_err(|e| anyhow!("failed to persist truncated history: {:?}", e))?;
|
||||
.map_err(|e| anyhow!("failed to persist rewound history: {:?}", e))?;
|
||||
if rows == 0 {
|
||||
log::warn!(
|
||||
"update_training_messages (rewind) updated 0 rows for {} (lib {}), \
|
||||
@@ -664,6 +646,108 @@ impl InsightChatService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Switch the active branch to the given leaf ID. The new branch becomes
|
||||
/// the active conversation path; the previous active branch becomes a
|
||||
/// regular fork. If the leaf ID doesn't exist, returns an error.
|
||||
pub async fn switch_branch(
|
||||
&self,
|
||||
library_id: i32,
|
||||
file_path: &str,
|
||||
leaf_id: u64,
|
||||
) -> Result<()> {
|
||||
let normalized = normalize_path(file_path);
|
||||
|
||||
let lock_key = (library_id, normalized.clone());
|
||||
let entry_lock = {
|
||||
let mut locks = self.chat_locks.lock().await;
|
||||
locks
|
||||
.entry(lock_key.clone())
|
||||
.or_insert_with(|| Arc::new(TokioMutex::new(())))
|
||||
.clone()
|
||||
};
|
||||
let _guard = entry_lock.lock().await;
|
||||
|
||||
let insight = {
|
||||
let cx = opentelemetry::Context::new();
|
||||
let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao");
|
||||
dao.get_current_insight_for_library(&cx, library_id, &normalized)
|
||||
.map_err(|e| anyhow!("failed to load insight: {:?}", e))?
|
||||
.ok_or_else(|| anyhow!("no insight found for path"))?
|
||||
};
|
||||
let raw_history = insight
|
||||
.training_messages
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("insight has no chat history"))?;
|
||||
|
||||
let mut store: ChatHistoryStore =
|
||||
if let Ok(arr) = serde_json::from_str::<Vec<ChatMessage>>(raw_history) {
|
||||
ChatHistoryStore::from_flat_array(arr)
|
||||
} else {
|
||||
serde_json::from_str(raw_history)
|
||||
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?
|
||||
};
|
||||
|
||||
// Validate the leaf_id exists and is a leaf.
|
||||
if !store.nodes.iter().any(|n| n.id == leaf_id) {
|
||||
bail!("branch_id {} not found in tree", leaf_id);
|
||||
}
|
||||
if !store.children_of(leaf_id).is_empty() {
|
||||
bail!("branch_id {} is not a leaf node", leaf_id);
|
||||
}
|
||||
|
||||
store.active_leaf_id = leaf_id;
|
||||
let json = serde_json::to_string(&store)
|
||||
.map_err(|e| anyhow!("failed to serialize tree: {}", e))?;
|
||||
|
||||
let dao = self.insight_dao.clone();
|
||||
let rows = retry_with_backoff("update_training_messages", 3, || {
|
||||
let cx = opentelemetry::Context::new();
|
||||
let mut d = dao.lock().expect("Unable to lock InsightDao");
|
||||
d.update_training_messages(&cx, library_id, &normalized, &json)
|
||||
})
|
||||
.map_err(|e| anyhow!("failed to persist branch switch: {:?}", e))?;
|
||||
if rows == 0 {
|
||||
log::warn!(
|
||||
"update_training_messages (switch_branch) updated 0 rows for {} (lib {}), \
|
||||
concurrent regenerate likely flipped is_current",
|
||||
normalized,
|
||||
library_id
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return branch leaf metadata (id, snippet, message_count) and the
|
||||
/// active leaf ID for a photo's conversation tree.
|
||||
pub fn get_branches(
|
||||
&self,
|
||||
library_id: i32,
|
||||
file_path: &str,
|
||||
) -> Result<(Vec<BranchLeafInfo>, u64)> {
|
||||
let normalized = normalize_path(file_path);
|
||||
let cx = opentelemetry::Context::new();
|
||||
let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao");
|
||||
let insight = dao
|
||||
.get_current_insight_for_library(&cx, library_id, &normalized)
|
||||
.map_err(|e| anyhow!("failed to load insight: {:?}", e))?
|
||||
.ok_or_else(|| anyhow!("no insight found for path"))?;
|
||||
|
||||
let raw = insight
|
||||
.training_messages
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("insight has no chat history"))?;
|
||||
|
||||
let store: ChatHistoryStore = if let Ok(arr) = serde_json::from_str::<Vec<ChatMessage>>(raw)
|
||||
{
|
||||
ChatHistoryStore::from_flat_array(arr)
|
||||
} else {
|
||||
serde_json::from_str(raw)
|
||||
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?
|
||||
};
|
||||
|
||||
Ok((store.leaves_with_info(), store.active_leaf_id))
|
||||
}
|
||||
|
||||
/// Streaming variant of `chat_turn`. Emits user-facing events as the
|
||||
/// conversation progresses: iteration starts, tool dispatch + result,
|
||||
/// text deltas from the final assistant reply, and a terminal `Done`
|
||||
@@ -828,8 +912,21 @@ impl InsightChatService {
|
||||
let raw_history = insight.training_messages.as_ref().ok_or_else(|| {
|
||||
anyhow!("insight has no chat history; regenerate this insight in agentic mode")
|
||||
})?;
|
||||
let mut messages: Vec<ChatMessage> = serde_json::from_str(raw_history)
|
||||
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?;
|
||||
|
||||
// Backward-compatible: flat array (old) vs tree (new).
|
||||
let mut store: ChatHistoryStore =
|
||||
if let Ok(arr) = serde_json::from_str::<Vec<ChatMessage>>(raw_history) {
|
||||
ChatHistoryStore::from_flat_array(arr)
|
||||
} else {
|
||||
serde_json::from_str(raw_history)
|
||||
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?
|
||||
};
|
||||
|
||||
let path = store
|
||||
.path_to_leaf(store.active_leaf_id)
|
||||
.ok_or_else(|| anyhow!("active_leaf_id {} not found in tree", store.active_leaf_id))?;
|
||||
let path_len = path.len();
|
||||
let mut messages: Vec<ChatMessage> = path.iter().map(|n| n.message.clone()).collect();
|
||||
|
||||
let stored_backend = insight.backend.clone();
|
||||
let effective_backend = req
|
||||
@@ -931,8 +1028,19 @@ impl InsightChatService {
|
||||
restore_system_prompt_override(&mut messages, override_stash);
|
||||
}
|
||||
|
||||
let json = serde_json::to_string(&messages)
|
||||
.map_err(|e| anyhow!("failed to serialize chat history: {}", e))?;
|
||||
// Append new messages as tree nodes.
|
||||
let new_messages = messages[path_len..].to_vec();
|
||||
let mut parent_id = Some(store.active_leaf_id);
|
||||
for msg in &new_messages {
|
||||
let new_id = store.append_node(parent_id, msg.clone());
|
||||
parent_id = Some(new_id);
|
||||
}
|
||||
if let Some(last_id) = parent_id {
|
||||
store.active_leaf_id = last_id;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string(&store)
|
||||
.map_err(|e| anyhow!("failed to serialize chat tree: {}", e))?;
|
||||
|
||||
let mut amended_insight_id: Option<i32> = None;
|
||||
if req.amend {
|
||||
@@ -2164,6 +2272,7 @@ pub enum ChatStreamEvent {
|
||||
/// Is this raw message visible in the rendered transcript? Must match
|
||||
/// `load_history`'s filter exactly — `find_raw_cut` depends on it to map
|
||||
/// rendered indices back to raw positions.
|
||||
#[allow(dead_code)]
|
||||
fn is_rendered(m: &ChatMessage) -> bool {
|
||||
match m.role.as_str() {
|
||||
"user" => true,
|
||||
@@ -2189,6 +2298,7 @@ fn is_rendered(m: &ChatMessage) -> bool {
|
||||
/// regenerating after a failed turn — its optimistic user bubble lives at
|
||||
/// the index just past the server's persisted history. Strictly past the end
|
||||
/// (`discard > rendered_count`) returns `None`.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn find_raw_cut(
|
||||
messages: &[ChatMessage],
|
||||
discard_from_rendered_index: usize,
|
||||
@@ -2349,6 +2459,112 @@ pub(crate) fn restore_system_prompt_override(
|
||||
}
|
||||
}
|
||||
|
||||
/// Fork indicator for a rendered message: position and total branches at
|
||||
/// the nearest divergence point upstream in the conversation tree.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ForkInfo {
|
||||
pub position: usize,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
/// Render a path of tree nodes into the UI-friendly `RenderedMessage` format,
|
||||
/// matching the same filtering logic as the legacy flat-array `load_history`.
|
||||
/// Returns `(rendered_messages, turn_count, node_ids, fork_info)` where
|
||||
/// `node_ids[i]` is the tree node ID that produced `rendered_messages[i]` and
|
||||
/// `fork_info[i]` carries the nearest divergence point at or upstream of it.
|
||||
///
|
||||
/// Fork detection walks *every* node in the path — including the non-rendered
|
||||
/// tool-dispatch/tool/system nodes — so a fork whose diverging child is a
|
||||
/// tool-dispatch assistant (empty content + tool_calls) is still attributed to
|
||||
/// the next rendered message. Detecting forks only on rendered nodes would miss
|
||||
/// regenerations where the model replied with a tool call.
|
||||
fn render_tree_path(
|
||||
store: &ChatHistoryStore,
|
||||
path: &[&StoredChatNode],
|
||||
) -> (Vec<RenderedMessage>, usize, Vec<u64>, Vec<Option<ForkInfo>>) {
|
||||
let mut rendered = Vec::new();
|
||||
let mut user_turns_seen = 0usize;
|
||||
let mut assistant_turns_seen = 0usize;
|
||||
let mut pending_tools: Vec<ToolInvocation> = Vec::new();
|
||||
let mut pending_calls: std::collections::VecDeque<(String, serde_json::Value)> =
|
||||
std::collections::VecDeque::new();
|
||||
let mut node_ids: Vec<u64> = Vec::new();
|
||||
let mut fork_info: Vec<Option<ForkInfo>> = Vec::new();
|
||||
// Nearest divergence seen so far, carried forward across nodes (rendered or
|
||||
// not) so every message at/after a fork shows the indicator.
|
||||
let mut last_fork: Option<ForkInfo> = None;
|
||||
|
||||
for node in path {
|
||||
// Update the carried fork BEFORE rendering: a non-rendered fork child
|
||||
// (e.g. a tool-dispatch assistant) must colour the rendered message
|
||||
// that follows it.
|
||||
if let Some((position, total)) = store.fork_at_node(node.id) {
|
||||
last_fork = Some(ForkInfo { position, total });
|
||||
}
|
||||
let msg = &node.message;
|
||||
match msg.role.as_str() {
|
||||
"system" => continue,
|
||||
"tool" => {
|
||||
if let Some((name, arguments)) = pending_calls.pop_front() {
|
||||
let (result, result_truncated) = truncate_tool_result(&msg.content);
|
||||
pending_tools.push(ToolInvocation {
|
||||
name,
|
||||
arguments,
|
||||
result,
|
||||
result_truncated,
|
||||
});
|
||||
}
|
||||
}
|
||||
"assistant" => {
|
||||
let has_tool_calls = msg
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.map(|c| !c.is_empty())
|
||||
.unwrap_or(false);
|
||||
if has_tool_calls && msg.content.trim().is_empty() {
|
||||
if let Some(ref tcs) = msg.tool_calls {
|
||||
for tc in tcs {
|
||||
pending_calls.push_back((
|
||||
tc.function.name.clone(),
|
||||
tc.function.arguments.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
assistant_turns_seen += 1;
|
||||
let tools = std::mem::take(&mut pending_tools);
|
||||
pending_calls.clear();
|
||||
rendered.push(RenderedMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: msg.content.clone(),
|
||||
is_initial: false,
|
||||
tools,
|
||||
});
|
||||
node_ids.push(node.id);
|
||||
fork_info.push(last_fork.clone());
|
||||
}
|
||||
"user" => {
|
||||
let is_initial = user_turns_seen == 0;
|
||||
user_turns_seen += 1;
|
||||
pending_tools.clear();
|
||||
pending_calls.clear();
|
||||
rendered.push(RenderedMessage {
|
||||
role: "user".to_string(),
|
||||
content: msg.content.clone(),
|
||||
is_initial,
|
||||
tools: Vec::new(),
|
||||
});
|
||||
node_ids.push(node.id);
|
||||
fork_info.push(last_fork.clone());
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
(rendered, assistant_turns_seen, node_ids, fork_info)
|
||||
}
|
||||
|
||||
/// View returned to clients for chat-UI rendering.
|
||||
#[derive(Debug)]
|
||||
pub struct HistoryView {
|
||||
@@ -2356,6 +2572,15 @@ pub struct HistoryView {
|
||||
pub turn_count: usize,
|
||||
pub model_version: String,
|
||||
pub backend: String,
|
||||
/// ID of the leaf node the current view is anchored to.
|
||||
pub active_leaf_id: u64,
|
||||
/// ID of the branch leaf being viewed. Equals `active_leaf_id` when
|
||||
/// viewing the active branch, or the `branch_id` parameter when
|
||||
/// viewing an alternate branch.
|
||||
pub viewing_branch_id: u64,
|
||||
/// Fork info for each rendered message. `None` means no divergence at
|
||||
/// or before that point in the tree.
|
||||
pub fork_info: Vec<Option<ForkInfo>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -2581,6 +2806,61 @@ mod tests {
|
||||
assert!(!dropped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_tree_path_detects_fork_at_tool_dispatch_child() {
|
||||
// Regression: a regeneration where the model replies with a tool call
|
||||
// forks at the user turn, but the diverging children are non-rendered
|
||||
// tool-dispatch assistant nodes. The indicator must still surface on
|
||||
// the rendered final reply that follows — earlier code only inspected
|
||||
// rendered nodes and silently dropped the fork, orphaning the branch.
|
||||
let mut store = ChatHistoryStore::empty();
|
||||
let u = store.append_node(None, ChatMessage::user("q1"));
|
||||
// Original branch: tool-dispatch → tool result → final reply.
|
||||
let d1 = store.append_node(Some(u), assistant_with_tool_call("lookup"));
|
||||
let t1 = store.append_node(Some(d1), ChatMessage::tool_result("data1"));
|
||||
let _a1 = store.append_node(Some(t1), assistant_text("answer 1"));
|
||||
// Regenerated branch: also a tool reply, forking at the user turn.
|
||||
let d2 = store.append_node(Some(u), assistant_with_tool_call("lookup"));
|
||||
let t2 = store.append_node(Some(d2), ChatMessage::tool_result("data2"));
|
||||
let a2 = store.append_node(Some(t2), assistant_text("answer 2"));
|
||||
store.active_leaf_id = a2;
|
||||
|
||||
let path = store.path_to_leaf(a2).expect("path to active leaf");
|
||||
let (rendered, _turns, node_ids, fork_info) = render_tree_path(&store, &path);
|
||||
|
||||
assert_eq!(rendered.len(), 2, "user turn + final reply are rendered");
|
||||
assert_eq!(rendered[1].content, "answer 2");
|
||||
assert_eq!(node_ids, vec![u, a2]);
|
||||
assert!(
|
||||
fork_info[0].is_none(),
|
||||
"user turn sits before the divergence"
|
||||
);
|
||||
let f = fork_info[1]
|
||||
.as_ref()
|
||||
.expect("fork indicator carried onto the rendered reply");
|
||||
assert_eq!((f.position, f.total), (2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_tree_path_detects_fork_at_rendered_child() {
|
||||
// Non-tool case: two direct assistant replies to the same user turn.
|
||||
// The diverging child is itself rendered, so the indicator lands on it.
|
||||
let mut store = ChatHistoryStore::empty();
|
||||
let u = store.append_node(None, ChatMessage::user("q1"));
|
||||
let _a1 = store.append_node(Some(u), assistant_text("answer 1"));
|
||||
let a2 = store.append_node(Some(u), assistant_text("answer 2"));
|
||||
store.active_leaf_id = a2;
|
||||
|
||||
let path = store.path_to_leaf(a2).expect("path to active leaf");
|
||||
let (rendered, _turns, _node_ids, fork_info) = render_tree_path(&store, &path);
|
||||
|
||||
assert_eq!(rendered.len(), 2);
|
||||
let f = fork_info[1]
|
||||
.as_ref()
|
||||
.expect("fork indicator on the divergent rendered reply");
|
||||
assert_eq!((f.position, f.total), (2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewind_strips_assistant_and_tool_scaffolding() {
|
||||
// Rendered: [user1, asst1, user2, asst2] → cut at rendered index 3
|
||||
|
||||
Reference in New Issue
Block a user