1use std::collections::HashMap;
14
15use crate::protocol::{StepUpdate, StepUpdateSource, StepUpdateState, StepUpdateTarget};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22#[non_exhaustive]
23pub enum StepKind {
24 Message,
26 ListDirectory,
28 FindFile,
30 SearchDirectory,
32 ViewFile,
34 CreateFile,
36 EditFile,
38 RunCommand,
40 Compaction,
42 InvokeSubagent,
44 GenerateImage,
46 SearchWeb,
48 ReadUrlContent,
50 McpTool,
52 CustomTool,
54 Finish,
56 Error,
58 ToolConfirmationRequest,
60 QuestionsRequest,
62}
63
64#[derive(Debug, Clone)]
66pub struct Step {
67 pub trajectory_id: String,
70 pub step_index: u32,
72 pub state: StepUpdateState,
74 pub source: StepUpdateSource,
76 pub target: StepUpdateTarget,
78 pub kind: StepKind,
80 pub text: String,
82 pub thinking: String,
84 pub text_delta: String,
86 pub error_message: Option<String>,
88 pub update: StepUpdate,
90}
91
92impl Step {
93 pub fn id(&self) -> String {
95 format!("{}:{}", self.trajectory_id, self.step_index)
96 }
97
98 pub fn is_final(&self) -> bool {
100 matches!(self.state, StepUpdateState::Done | StepUpdateState::Error)
101 }
102
103 pub fn text(&self) -> Option<&str> {
105 Some(self.text.as_str()).filter(|t| !t.is_empty())
106 }
107
108 pub fn user_facing_text(&self) -> Option<&str> {
113 match self.target {
114 StepUpdateTarget::User => self.text(),
115 _ => None,
116 }
117 }
118}
119
120#[derive(Debug, Default)]
122pub struct StepAssembler {
123 buffers: HashMap<(String, u32), Buffer>,
124 main_trajectory: Option<String>,
125}
126
127#[derive(Debug, Default)]
128struct Buffer {
129 text: String,
130 thinking: String,
131}
132
133impl StepAssembler {
134 pub fn new(main_trajectory: Option<String>) -> Self {
139 Self {
140 buffers: HashMap::new(),
141 main_trajectory,
142 }
143 }
144
145 pub fn main_trajectory(&self) -> Option<&str> {
147 self.main_trajectory.as_deref()
148 }
149
150 pub fn is_main(&self, trajectory_id: &str) -> bool {
153 self.main_trajectory.as_deref() == Some(trajectory_id)
154 }
155
156 pub fn ingest(&mut self, update: StepUpdate) -> Step {
158 let trajectory_id = update.trajectory_id.clone().unwrap_or_default();
159 let step_index = update.step_index.unwrap_or_default();
160 if self.main_trajectory.is_none() && !trajectory_id.is_empty() {
161 self.main_trajectory = Some(trajectory_id.clone());
162 }
163
164 let buffer = self
165 .buffers
166 .entry((trajectory_id.clone(), step_index))
167 .or_default();
168
169 let text_delta = update.text_delta.clone().unwrap_or_default();
170 if !text_delta.is_empty() {
171 buffer.text.push_str(&text_delta);
172 }
173 if let Some(text) = update.text.as_deref().filter(|t| !t.is_empty()) {
176 buffer.text = text.to_string();
177 }
178
179 if let Some(delta) = update.thinking_delta.as_deref().filter(|t| !t.is_empty()) {
180 buffer.thinking.push_str(delta);
181 }
182 if let Some(thinking) = update.thinking.as_deref().filter(|t| !t.is_empty()) {
183 buffer.thinking = thinking.to_string();
184 }
185
186 let step = Step {
187 trajectory_id,
188 step_index,
189 state: update.state.clone().unwrap_or_default(),
190 source: update.source.clone().unwrap_or_default(),
191 target: update.target.clone().unwrap_or_default(),
192 kind: classify(&update),
193 text: buffer.text.clone(),
194 thinking: buffer.thinking.clone(),
195 text_delta,
196 error_message: update.error_message.clone().filter(|m| !m.is_empty()),
197 update,
198 };
199
200 if step.is_final() {
201 self.buffers
202 .remove(&(step.trajectory_id.clone(), step.step_index));
203 }
204 step
205 }
206}
207
208fn classify(update: &StepUpdate) -> StepKind {
209 if update.tool_confirmation_request.is_some() {
212 StepKind::ToolConfirmationRequest
213 } else if update.questions_request.is_some() {
214 StepKind::QuestionsRequest
215 } else if update.error.is_some() {
216 StepKind::Error
217 } else if update.finish.is_some() {
218 StepKind::Finish
219 } else if update.list_directory.is_some() {
220 StepKind::ListDirectory
221 } else if update.find_file.is_some() {
222 StepKind::FindFile
223 } else if update.search_directory.is_some() {
224 StepKind::SearchDirectory
225 } else if update.view_file.is_some() {
226 StepKind::ViewFile
227 } else if update.create_file.is_some() {
228 StepKind::CreateFile
229 } else if update.edit_file.is_some() {
230 StepKind::EditFile
231 } else if update.run_command.is_some() {
232 StepKind::RunCommand
233 } else if update.compaction.is_some() {
234 StepKind::Compaction
235 } else if update.invoke_subagent.is_some() {
236 StepKind::InvokeSubagent
237 } else if update.generate_image.is_some() {
238 StepKind::GenerateImage
239 } else if update.search_web.is_some() {
240 StepKind::SearchWeb
241 } else if update.read_url_content.is_some() {
242 StepKind::ReadUrlContent
243 } else if update.mcp_tool.is_some() {
244 StepKind::McpTool
245 } else if update.custom_tool.is_some() {
246 StepKind::CustomTool
247 } else {
248 StepKind::Message
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use crate::protocol::{ActionRunCommand, UserQuestionsRequest};
256
257 fn delta(trajectory: &str, index: u32, text: &str, state: StepUpdateState) -> StepUpdate {
258 StepUpdate {
259 trajectory_id: Some(trajectory.into()),
260 step_index: Some(index),
261 state: Some(state),
262 text_delta: Some(text.into()),
263 ..Default::default()
264 }
265 }
266
267 #[test]
268 fn deltas_accumulate_within_a_step() {
269 let mut a = StepAssembler::new(None);
270 a.ingest(delta("t1", 0, "Hel", StepUpdateState::Active));
271 let step = a.ingest(delta("t1", 0, "lo", StepUpdateState::Active));
272 assert_eq!(step.text, "Hello");
273 assert_eq!(step.text_delta, "lo");
274 assert!(!step.is_final());
275 }
276
277 #[test]
278 fn the_final_text_replaces_the_accumulated_deltas() {
279 let mut a = StepAssembler::new(None);
280 a.ingest(delta("t1", 0, "Hel", StepUpdateState::Active));
281 let step = a.ingest(StepUpdate {
282 trajectory_id: Some("t1".into()),
283 step_index: Some(0),
284 state: Some(StepUpdateState::Done),
285 text: Some("Hello, world".into()),
286 ..Default::default()
287 });
288 assert_eq!(step.text, "Hello, world");
289 assert!(step.is_final());
290 }
291
292 #[test]
293 fn concurrent_trajectories_do_not_bleed_into_each_other() {
294 let mut a = StepAssembler::new(Some("main".into()));
295 a.ingest(delta("main", 0, "main-", StepUpdateState::Active));
296 a.ingest(delta("sub", 0, "sub-", StepUpdateState::Active));
297 let main = a.ingest(delta("main", 0, "text", StepUpdateState::Active));
298 let sub = a.ingest(delta("sub", 0, "text", StepUpdateState::Active));
299 assert_eq!(main.text, "main-text");
300 assert_eq!(sub.text, "sub-text");
301 assert!(a.is_main("main"));
302 assert!(!a.is_main("sub"));
303 }
304
305 #[test]
306 fn the_first_trajectory_seen_becomes_the_main_one() {
307 let mut a = StepAssembler::new(None);
308 a.ingest(delta("first", 0, "x", StepUpdateState::Active));
309 assert_eq!(a.main_trajectory(), Some("first"));
310 a.ingest(delta("second", 0, "y", StepUpdateState::Active));
311 assert_eq!(a.main_trajectory(), Some("first"));
312 }
313
314 #[test]
315 fn a_settled_step_releases_its_buffer() {
316 let mut a = StepAssembler::new(None);
317 a.ingest(delta("t1", 0, "hi", StepUpdateState::Done));
318 assert!(a.buffers.is_empty());
319 }
320
321 #[test]
322 fn actions_classify_by_their_populated_member() {
323 let update = StepUpdate {
324 run_command: Some(ActionRunCommand {
325 command_line: Some("ls".into()),
326 ..Default::default()
327 }),
328 ..Default::default()
329 };
330 assert_eq!(classify(&update), StepKind::RunCommand);
331 assert_eq!(classify(&StepUpdate::default()), StepKind::Message);
332 }
333
334 #[test]
335 fn a_pending_question_outranks_its_action() {
336 let update = StepUpdate {
337 run_command: Some(ActionRunCommand::default()),
338 questions_request: Some(UserQuestionsRequest::default()),
339 ..Default::default()
340 };
341 assert_eq!(classify(&update), StepKind::QuestionsRequest);
342 }
343
344 #[test]
345 fn only_user_targeted_text_is_user_facing() {
346 let mut a = StepAssembler::new(None);
347 let to_model = a.ingest(StepUpdate {
348 trajectory_id: Some("t".into()),
349 target: Some(StepUpdateTarget::Model),
350 text: Some("echo of the prompt".into()),
351 ..Default::default()
352 });
353 assert_eq!(to_model.user_facing_text(), None);
354 assert_eq!(to_model.text(), Some("echo of the prompt"));
355 }
356}