1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
//! Streaming progress-event state machine for the Telegram handler
//! (#1086 seam 3). Owns the placeholder-edit lifecycle driven by agent
//! progress events; extracted verbatim from `handle_message`.
use std::sync::Arc;
use teloxide::Bot;
use teloxide::types::{ChatAction, ChatId, ThreadId};
use super::flow::{DisplayItem, StreamingState, ToolMsg, detach_flow_for_followup};
use super::handler::tool_context;
use super::send::fire_chat_action;
use crate::brain::agent::{ProgressCallback, ProgressEvent};
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_progress_cb(
streaming: &Arc<std::sync::Mutex<StreamingState>>,
bot: &Bot,
chat_id: ChatId,
thread_id: Option<ThreadId>,
) -> ProgressCallback {
let st = streaming.clone();
let bot_typing = bot.clone();
let chat_typing = chat_id;
Arc::new(move |_sid, event| {
match event {
// Auto-compaction produces zero streaming chunks for 10-60s.
// The 4s typing pinger upstream stays alive, but fire an
// immediate refresh on entry so the indicator visibly resets
// the moment compaction starts. No text — just the native
// "is typing" dots stay continuous through the silent window.
ProgressEvent::Compacting => {
let bot = bot_typing.clone();
let chat = chat_typing;
tokio::spawn(async move {
fire_chat_action(
&bot,
chat,
thread_id,
ChatAction::Typing,
"compacting typing refresh",
)
.await;
});
}
ProgressEvent::ReasoningChunk { text } => {
if let Ok(mut s) = st.lock() {
s.thinking.push_str(&text);
s.dirty = true;
}
}
ProgressEvent::StreamingChunk { text } => {
if let Ok(mut s) = st.lock() {
if !s.thinking.is_empty() {
s.thinking.clear();
}
s.response.push_str(&text);
s.dirty = true;
s.processing = false; // first real text = stop rolling messages
}
}
ProgressEvent::ToolStarted {
tool_name,
tool_input,
} => {
if let Ok(mut s) = st.lock() {
s.thinking.clear();
if s.tools_started_at.is_none() {
s.tools_started_at = Some(std::time::Instant::now());
}
let ctx = tool_context(&tool_name, &tool_input);
let raw_ctx = crate::utils::tool_status_source(&tool_name, &tool_input);
let idx = s.tool_msgs.len();
s.tool_msgs.push(ToolMsg {
msg_id: None,
name: tool_name,
context: ctx,
raw_context: raw_ctx,
completed: None,
dirty: true,
});
s.display_queue.push(DisplayItem::NewTool(idx));
}
}
ProgressEvent::ToolCompleted {
tool_name, success, ..
} => {
if let Ok(mut s) = st.lock() {
s.tool_round_count += 1;
if let Some(tool) = s
.tool_msgs
.iter_mut()
.rev()
.find(|t| t.name == tool_name && t.completed.is_none())
{
tool.completed = Some(success);
tool.dirty = true;
}
// No recreate here (#299): a completion only edits the
// open group block in place — nothing new lands below
// the placeholder. The re-post happens where a message
// is actually SENT (fresh group in append_tool_group,
// and the IntermediateText arm below).
}
}
ProgressEvent::QueuedUserMessage { .. } => {
// The user's own message is already visible in the chat;
// the block just has to stop growing above it (#404).
detach_flow_for_followup(&st);
}
ProgressEvent::IntermediateText { text, reasoning: _ } => {
if let Ok(mut s) = st.lock() {
s.thinking.clear();
// Clear accumulated streaming response — it's now captured
// as an intermediate message. Without this, text from
// consecutive tool rounds gets concatenated without spacing.
s.response.clear();
// Delete the streaming message so stale text doesn't linger
if s.msg_id.is_some() {
s.recreate = true;
}
// Never push reasoning as a standalone intermediate — it
// belongs in the streaming response's 💭 thinking block.
// Using reasoning as a fallback here causes duplicate
// messages on Telegram (reasoning intermediate + final
// response that doesn't contain the reasoning text, so
// dedup can't strip it).
if !text.is_empty() {
s.display_queue.push(DisplayItem::Intermediate(text));
}
}
}
ProgressEvent::SelfHealingAlert { message } => {
if let Ok(mut s) = st.lock() {
s.display_queue
.push(DisplayItem::Intermediate(format!("🔧 {}", message)));
}
}
ProgressEvent::RetryAttempt {
attempt,
max,
reason,
} => {
if let Ok(mut s) = st.lock() {
s.display_queue.push(DisplayItem::Intermediate(format!(
"⏳ Retry {}/{} — {}",
attempt, max, reason
)));
}
}
ProgressEvent::ProviderSwitched {
to_name, to_model, ..
} => {
if let Ok(mut s) = st.lock() {
s.display_queue.push(DisplayItem::Intermediate(format!(
"🔄 Now using {}/{}",
to_name, to_model
)));
}
}
// Optional follow-up suggestions (#597): post tap-to-send
// buttons under the response. Non-blocking — spawned like the
// other async arms; a tap injects the suggestion as a new turn.
ProgressEvent::SuggestedFollowups(options) => {
// Buffer the options and render AFTER the final delivery so the
// buttons are always the last thing in the chat, and the stash
// is set fresh at turn end (#724 / #723). Only the latest set
// is kept if the tool fires more than once.
if let Ok(mut s) = st.lock() {
s.pending_suggestions = Some(options);
}
}
_ => {}
}
})
}