Skip to main content

bamboo_engine/session_app/execute/
sync.rs

1//! Client/server sync evaluation and the server execute snapshot.
2
3use bamboo_agent_core::Message;
4use bamboo_domain::Session;
5
6use super::super::types::{ExecuteClientSync, ExecuteSyncReason, ServerExecuteSnapshot};
7use super::resume_markers::has_pending_user_message;
8
9pub fn evaluate_client_sync(
10    client_sync: Option<&ExecuteClientSync>,
11    server_snapshot: &ServerExecuteSnapshot,
12) -> Option<ExecuteSyncReason> {
13    let client_sync = client_sync?;
14
15    let client_pending_question_tool_call_id = client_sync
16        .client_pending_question_tool_call_id
17        .as_deref()
18        .map(str::trim)
19        .filter(|value| !value.is_empty());
20    let server_pending_question_tool_call_id = server_snapshot
21        .pending_question_tool_call_id
22        .as_deref()
23        .map(str::trim)
24        .filter(|value| !value.is_empty());
25
26    if client_sync.client_has_pending_question != server_snapshot.has_pending_question {
27        return Some(ExecuteSyncReason::PendingQuestionMismatch);
28    }
29
30    if client_sync.client_has_pending_question
31        && client_pending_question_tool_call_id.is_some()
32        && client_pending_question_tool_call_id != server_pending_question_tool_call_id
33    {
34        return Some(ExecuteSyncReason::PendingQuestionMismatch);
35    }
36
37    if client_sync.client_message_count != server_snapshot.message_count {
38        return Some(ExecuteSyncReason::MessageCountMismatch);
39    }
40
41    let client_last_message_id = client_sync
42        .client_last_message_id
43        .as_deref()
44        .map(str::trim)
45        .filter(|value| !value.is_empty());
46    let server_last_message_id = server_snapshot
47        .last_message_id
48        .as_deref()
49        .map(str::trim)
50        .filter(|value| !value.is_empty());
51
52    if client_last_message_id != server_last_message_id {
53        return Some(ExecuteSyncReason::LastMessageIdMismatch);
54    }
55
56    None
57}
58
59/// Returns true if the message is flagged `metadata.hidden_from_ui == true`.
60///
61/// Such messages are runtime-injected (e.g. child-completion resume, retry
62/// resume, conclusion-with-options resume) and are filtered out of any
63/// client-facing view. Both `GET /history` and the execute sync snapshot
64/// must use this exact predicate so the client and server agree on the
65/// visible message_count and last_message_id.
66pub fn is_hidden_from_ui(message: &Message) -> bool {
67    message
68        .metadata
69        .as_ref()
70        .and_then(|metadata| metadata.get("hidden_from_ui"))
71        .and_then(|value| value.as_bool())
72        .unwrap_or(false)
73}
74
75impl ServerExecuteSnapshot {
76    pub fn from_session(session: &Session) -> Self {
77        // Mirror the GET /history filter so the client (which only ever sees
78        // visible messages) and the server agree on message_count /
79        // last_message_id. Hidden runtime resume messages stay internal and
80        // do not leak into the sync protocol.
81        let visible: Vec<&Message> = session
82            .messages
83            .iter()
84            .filter(|message| !is_hidden_from_ui(message))
85            .collect();
86        Self {
87            message_count: visible.len(),
88            last_message_id: visible.last().map(|message| message.id.clone()),
89            has_pending_question: session.pending_question.is_some(),
90            pending_question_tool_call_id: session
91                .pending_question
92                .as_ref()
93                .map(|pending| pending.tool_call_id.clone()),
94            has_pending_user_message: has_pending_user_message(session),
95        }
96    }
97}