Skip to main content

trustee_core/
session.rs

1//! Session state — core agent session without any UI concerns.
2//!
3//! This struct holds all the state shared between frontends (TUI, API, Web):
4//! output lines, input, workflow state, config, resume info, MCP servers, etc.
5
6use std::collections::HashMap;
7use std::sync::Arc;
8
9use tokio::sync::mpsc;
10use tokio_util::sync::CancellationToken;
11
12use abk::cli::ResumeInfo;
13use abk::context::RunContext;
14
15use std::sync::atomic::{AtomicU8, Ordering};
16
17use crate::types::{
18    AutoHandoffConfig, BuildInfo, CapturedText, HandoffCaptureSink, McpServerInfo, McpServerStatus,
19    TuiMessage, WorkflowState,
20};
21
22/// Core session state for the Trustee agent.
23///
24/// Holds all state that is independent of the presentation layer (TUI, API, Web).
25/// Frontend crates compose this struct and add their own UI-specific fields.
26pub struct Session {
27    /// Input buffer for user commands
28    pub input: String,
29    /// Output log lines
30    pub output_lines: Vec<String>,
31    /// Sender for messages from async workflows (clone and pass to workflow runners)
32    pub workflow_tx: mpsc::UnboundedSender<TuiMessage>,
33    /// Current workflow lifecycle state
34    pub workflow_state: WorkflowState,
35    /// Configuration TOML for ABK workflows
36    pub config_toml: Option<String>,
37    /// Secrets for ABK workflows
38    pub secrets: Option<HashMap<String, String>>,
39    /// Build info for ABK workflows
40    pub build_info: Option<BuildInfo>,
41    /// Resume info from the last completed task for session continuity
42    pub resume_info: Option<ResumeInfo>,
43    /// Saved resume_info before execute_command consumes it; restored if task
44    /// is cancelled before producing a real checkpoint (mistake-ENTER recovery).
45    pub backup_resume_info: Option<ResumeInfo>,
46    /// Latest todo list from LLM todowrite tool
47    pub todo_lines: Vec<String>,
48    /// Cancellation token for aborting the current workflow
49    pub cancel_token: CancellationToken,
50    /// Command buffered by user during cancellation wind-down.
51    pub pending_command: Option<String>,
52    /// Whether a session handoff (Ctrl+H) should fire once the current workflow cancels.
53    pub handoff_pending: bool,
54    /// In-flight spinner entries: (tool_name, output_lines_index, hint).
55    pub pending_tool_lines: Vec<(String, usize, Option<String>)>,
56    /// Current context token count (updated from ApiCallStarted events).
57    pub current_context_tokens: usize,
58    /// Auto-handoff configuration parsed from [tui.auto_handoff].
59    pub auto_handoff: AutoHandoffConfig,
60    /// MCP server statuses received from agent init
61    pub mcp_servers: Vec<McpServerInfo>,
62    /// Whether the session should quit
63    pub should_quit: bool,
64    /// Whether auto-scroll is enabled (follows new output)
65    pub auto_scroll: bool,
66
67    // --- TMU Phase 1: Stateless Core ---
68    /// Agent name for checkpoint/token paths (replaces ABK_AGENT_NAME env var).
69    /// Defaults to "trustee". Set from config at startup.
70    pub agent_name: String,
71    /// Per-session token store (None = FileTokenStore fallback).
72    /// When set, MCP credential flows use this instead of file-based storage.
73    pub token_store: Option<Arc<dyn pep::token_store::TokenStore>>,
74
75    // --- Project/Session Identity (backward compatible, all None = old behavior) ---
76    /// Storage partition key (replaces path hash). None = hash(working_dir)
77    pub project_id: Option<String>,
78    /// Human-readable project name. None = directory name
79    pub project_name: Option<String>,
80    /// Storage directory name (replaces timestamp slug). None = auto-generate
81    pub session_id: Option<String>,
82    /// Human-readable session name. None = no description
83    pub session_name: Option<String>,
84}
85
86impl Session {
87    /// Create a new Session with default state and a fresh message channel.
88    ///
89    /// Returns `(Session, Receiver)` so the caller can own the receiver
90    /// without locking the session (prevents deadlock in async drain loops).
91    pub fn new() -> (Self, mpsc::UnboundedReceiver<TuiMessage>) {
92        let (workflow_tx, workflow_rx) = mpsc::unbounded_channel();
93        let session = Self {
94            input: String::new(),
95            output_lines: Vec::new(),
96            workflow_tx,
97            workflow_state: WorkflowState::Idle,
98            config_toml: None,
99            secrets: None,
100            build_info: None,
101            resume_info: None,
102            backup_resume_info: None,
103            todo_lines: Vec::new(),
104            cancel_token: CancellationToken::new(),
105            pending_command: None,
106            handoff_pending: false,
107            pending_tool_lines: Vec::new(),
108            current_context_tokens: 0,
109            auto_handoff: AutoHandoffConfig::default(),
110            mcp_servers: Vec::new(),
111            should_quit: false,
112            auto_scroll: true,
113            agent_name: "trustee".to_string(),
114            token_store: None,
115            project_id: None,
116            project_name: None,
117            session_id: None,
118            session_name: None,
119        };
120        (session, workflow_rx)
121    }
122
123    /// Parse auto-handoff configuration from the stored config TOML.
124    pub fn parse_auto_handoff_config(&mut self) {
125        if let Some(ref config_toml) = self.config_toml {
126            self.auto_handoff = crate::config::parse_auto_handoff_config(config_toml);
127        }
128    }
129
130    /// Handle messages from async workflows.
131    ///
132    /// This processes all workflow lifecycle events, output updates, and state transitions.
133    /// Returns `true` if the caller should check for pending commands/handoffs after.
134    pub fn handle_workflow_message(&mut self, msg: TuiMessage) {
135        match msg {
136            TuiMessage::WorkflowCancelled => {
137                self.output_lines.push("⏹ Workflow cancelled".to_string());
138                self.output_lines.push("".to_string());
139                self.workflow_state = WorkflowState::Cancelling;
140            }
141            TuiMessage::OutputLine(line) => {
142                self.output_lines.push(line);
143            }
144            TuiMessage::StreamDelta(delta) => {
145                if let Some(last) = self.output_lines.last_mut() {
146                    last.push_str(&delta);
147                } else {
148                    self.output_lines.push(delta);
149                }
150            }
151            TuiMessage::ReasoningDelta(delta) => {
152                if let Some(last) = self.output_lines.last_mut() {
153                    if !last.starts_with('\x01') {
154                        last.insert(0, '\x01');
155                    }
156                    last.push_str(&delta);
157                } else {
158                    self.output_lines.push(format!("\x01{}", delta));
159                }
160            }
161            TuiMessage::WorkflowCompleted => {
162                self.output_lines.push("✓ Workflow completed".to_string());
163                self.output_lines.push("".to_string());
164                if self.workflow_state == WorkflowState::Running {
165                    self.workflow_state = WorkflowState::Cancelling;
166                }
167            }
168            TuiMessage::WorkflowError(err) => {
169                self.output_lines.push(format!("✗ Error: {}", err));
170                self.output_lines.push("".to_string());
171                if self.workflow_state == WorkflowState::Running {
172                    self.workflow_state = WorkflowState::Cancelling;
173                }
174            }
175            TuiMessage::TodoUpdate(content) => {
176                self.todo_lines = content.lines().map(|l| l.to_string()).collect();
177            }
178            TuiMessage::ToolPending { tool_name, hint } => {
179                let label = match &hint {
180                    Some(h) => format!("⠋ {} {}", tool_name, h),
181                    None => format!("⠋ {}", tool_name),
182                };
183                let idx = self.output_lines.len();
184                self.output_lines.push(label);
185                self.pending_tool_lines.push((tool_name, idx, hint));
186            }
187            TuiMessage::ToolDone { tool_name, success, hint } => {
188                let status = if success { "✓" } else { "✗" };
189                if let Some(pos) = self.pending_tool_lines.iter().position(|(n, _, _)| *n == tool_name) {
190                    let (_, idx, pending_hint) = self.pending_tool_lines.remove(pos);
191                    let h = hint.or(pending_hint);
192                    let label = match &h {
193                        Some(h) => format!("{} {} {}", status, tool_name, h),
194                        None => format!("{} {}", status, tool_name),
195                    };
196                    if idx < self.output_lines.len() {
197                        self.output_lines[idx] = label;
198                        return;
199                    }
200                    self.output_lines.push(label);
201                } else {
202                    let label = match &hint {
203                        Some(h) => format!("{} {} {}", status, tool_name, h),
204                        None => format!("{} {}", status, tool_name),
205                    };
206                    self.output_lines.push(label);
207                }
208            }
209            TuiMessage::ResumeInfo(info) => {
210                if self.workflow_state == WorkflowState::Cancelling && info.is_none() {
211                    self.resume_info = self.backup_resume_info.take();
212                } else if info.is_some() {
213                    // Only overwrite with a valid resume_info.
214                    // Don't let ResumeInfo(None) clobber a valid Some that was
215                    // set by an earlier incremental checkpoint message — this
216                    // happens when the error/cancel path fails to create a
217                    // final checkpoint but earlier checkpoints exist.
218                    self.resume_info = info;
219                    self.backup_resume_info = None;
220                }
221                // If info is None and we're not cancelling, keep existing resume_info.
222                if self.workflow_state == WorkflowState::Cancelling {
223                    self.workflow_state = WorkflowState::Idle;
224                }
225                if self.resume_info.is_some() {
226                    if std::env::var("RUST_LOG")
227                        .map(|v| v.to_lowercase().contains("debug"))
228                        .unwrap_or(false)
229                    {
230                        self.output_lines.push("🔄 Session preserved — next command will continue this session".to_string());
231                    }
232                }
233                if self.workflow_state == WorkflowState::Idle && self.handoff_pending {
234                    self.handoff_pending = false;
235                    self.trigger_handoff(String::new());
236                } else if let Some(cmd) = self.pending_command.take() {
237                    self.input = cmd;
238                    self.execute_command();
239                }
240            }
241            TuiMessage::ContextTokensUpdated(count) => {
242                self.current_context_tokens = count;
243                if self.auto_handoff.enabled
244                    && count >= self.auto_handoff.context_threshold
245                    && self.workflow_state == WorkflowState::Running
246                    && !self.handoff_pending
247                    && self.resume_info.is_some()
248                {
249                    self.handoff_pending = true;
250                    self.cancel_token.cancel();
251                    self.workflow_state = WorkflowState::Cancelling;
252                    self.output_lines.push(format!(
253                        "🔄 Auto-handoff: cancelling workflow, context tokens ({}) ≥ threshold ({})",
254                        count, self.auto_handoff.context_threshold
255                    ));
256                }
257            }
258            TuiMessage::McpServerStatus { name, connected, tool_count, error } => {
259                let status = if connected { McpServerStatus::Connected } else { McpServerStatus::Failed };
260                if let Some(existing) = self.mcp_servers.iter_mut().find(|s| s.name == name) {
261                    existing.status = status;
262                    existing.tool_count = tool_count;
263                    existing.error = error;
264                } else {
265                    self.mcp_servers.push(McpServerInfo { name, status, tool_count, error });
266                }
267            }
268            TuiMessage::HandoffReady(briefing) => {
269                self.workflow_state = WorkflowState::Idle;
270                self.resume_info = None;
271                self.input = briefing;
272                self.execute_command();
273            }
274        }
275        if self.auto_scroll {
276            // Signal to frontend that it should scroll to bottom.
277            // Frontend reads auto_scroll flag directly.
278        }
279    }
280
281    /// Execute the current command in the input buffer.
282    ///
283    /// Spawns an async ABK workflow task, clears the input buffer, and sets
284    /// workflow_state to Running.
285    pub fn execute_command(&mut self) {
286        let command = self.input.trim().to_string();
287
288        if self.workflow_state != WorkflowState::Idle {
289            self.pending_command = Some(command);
290            self.output_lines.push("⏳ Previous workflow finishing — command queued".to_string());
291            self.input.clear();
292            return;
293        }
294
295        let is_continuation = self.resume_info.is_some();
296
297        if !is_continuation {
298            self.output_lines.clear();
299            // Auto-derive session_name from the first command if not explicitly set.
300            // Truncate to 80 chars for a reasonable display name.
301            if self.session_name.is_none() {
302                let derived = if command.len() > 80 {
303                    format!("{}...", &command[..77])
304                } else {
305                    command.clone()
306                };
307                self.session_name = Some(derived);
308            }
309        }
310
311        self.output_lines.push(format!("> {}", command));
312
313        let config_toml = match &self.config_toml {
314            Some(c) => c.clone(),
315            None => {
316                self.output_lines.push("✗ Error: Configuration not loaded".to_string());
317                self.output_lines.push("".to_string());
318                return;
319            }
320        };
321
322        let secrets = self.secrets.clone().unwrap_or_default();
323        let build_info = self.build_info.clone();
324        let tx = self.workflow_tx.clone();
325
326        let agent_name = self.agent_name.clone();
327        let token_store = self.token_store.clone();
328        let project_id = self.project_id.clone();
329        let project_name = self.project_name.clone();
330        let session_id = self.session_id.clone();
331        let session_name = self.session_name.clone();
332
333        self.backup_resume_info = self.resume_info.clone();
334        let resume_info = self.resume_info.take();
335
336        self.workflow_state = WorkflowState::Running;
337        self.auto_scroll = true;
338
339        self.cancel_token = CancellationToken::new();
340        let child_token = self.cancel_token.clone();
341
342        let (resume_tx, mut resume_rx) = mpsc::unbounded_channel();
343
344        let resume_forward_tx = tx.clone();
345        tokio::spawn(async move {
346            while let Some(info) = resume_rx.recv().await {
347                resume_forward_tx.send(TuiMessage::ResumeInfo(info)).ok();
348            }
349        });
350
351        tokio::spawn(async move {
352            let tui_sink: abk::orchestration::output::SharedSink =
353                Arc::new(crate::session::TuiForwardSink::new(tx.clone()));
354
355            abk::observability::set_tui_mode(true);
356
357            // Build RunContext from session fields for stateless operation
358            let mut run_ctx = RunContext::new()
359                .with_agent_name(agent_name.clone());
360
361            // Set project identity if any field is provided
362            if project_id.is_some() || project_name.is_some() {
363                run_ctx = run_ctx.with_project(abk::context::ProjectIdentity {
364                    id: project_id.unwrap_or_else(|| "default".to_string()),
365                    name: project_name,
366                });
367            }
368
369            // Set session identity if any field is provided
370            if session_id.is_some() || session_name.is_some() {
371                run_ctx = run_ctx.with_session(abk::context::SessionIdentity {
372                    id: session_id.unwrap_or_else(|| "default".to_string()),
373                    name: session_name,
374                });
375            }
376
377            #[cfg(feature = "registry-mcp-token")]
378            {
379                if let Some(ref ts) = token_store {
380                    run_ctx = run_ctx.with_token_store(ts.clone());
381                }
382            }
383
384            let result = abk::cli::run_task_from_raw_config(
385                &config_toml,
386                secrets,
387                build_info,
388                &command,
389                Some(tui_sink),
390                resume_info,
391                Some(resume_tx),
392                Some(child_token),
393                Some(&run_ctx),
394            )
395            .await;
396
397            abk::observability::set_tui_mode(false);
398
399            let task_result = result.unwrap_or_else(|e| abk::cli::TaskResult {
400                success: false,
401                error: Some(e.to_string()),
402                // resume_info will be None here, but the on_checkpoint channel
403                // may have already delivered a valid ResumeInfo via TuiMessage.
404                // The ResumeInfo handler now ignores None when a valid Some exists,
405                // so this None won't clobber the earlier incremental checkpoint.
406                resume_info: None,
407            });
408
409            let msg = if task_result.success {
410                TuiMessage::WorkflowCompleted
411            } else {
412                TuiMessage::WorkflowError(task_result.error.unwrap_or_default())
413            };
414            tx.send(msg).ok();
415            tx.send(TuiMessage::ResumeInfo(task_result.resume_info)).ok();
416        });
417
418        self.input.clear();
419    }
420
421    /// Trigger a session handoff.
422    ///
423    /// Runs a single LLM call using the current session's resume_info to generate
424    /// a briefing. On completion, sends `TuiMessage::HandoffReady(briefing)`.
425    pub fn trigger_handoff(&mut self, hint: String) {
426        if self.resume_info.is_none() {
427            self.output_lines.push("ℹ Nothing to hand off — run a task first".to_string());
428            return;
429        }
430
431        let config_toml = match &self.config_toml {
432            Some(c) => c.clone(),
433            None => {
434                self.output_lines.push("✗ Error: Configuration not loaded".to_string());
435                return;
436            }
437        };
438
439        let secrets = self.secrets.clone().unwrap_or_default();
440        let build_info = self.build_info.clone();
441        let tx = self.workflow_tx.clone();
442
443        let agent_name = self.agent_name.clone();
444        let token_store = self.token_store.clone();
445
446        let resume_info = self.resume_info.take();
447
448        self.workflow_state = WorkflowState::Running;
449        self.auto_scroll = true;
450        self.cancel_token = CancellationToken::new();
451        let child_token = self.cancel_token.clone();
452
453        self.output_lines.push("🔀 Generating session handoff briefing...".to_string());
454
455        tokio::spawn(async move {
456            let (cap_tx, mut cap_rx) = mpsc::unbounded_channel::<CapturedText>();
457            let cap_sink: abk::orchestration::output::SharedSink =
458                Arc::new(HandoffCaptureSink::new(cap_tx, child_token.clone()));
459
460            abk::observability::set_tui_mode(true);
461
462            let base = "Output a session handoff briefing in at most 300 lines. \
463                 Do NOT use any tools. Include: the FULL ABSOLUTE PATH of every \
464                 project/repository being worked on (e.g. /Projects/Foo/bar — never \
465                 omit the leading path), all project/task/workstream UUIDs referenced, \
466                 every file created or modified with its full absolute path, all \
467                 commands run and their outcomes, the current state of the work, any \
468                 blockers, and the exact next action to take. \
469                 Output ONLY the briefing text — no preamble, headers, or closing remarks.";
470            let prompt = if hint.is_empty() {
471                base.to_string()
472            } else {
473                format!("{base}\n\nIn the briefing also consider: {hint}")
474            };
475
476            let (dummy_tx, _dummy_rx) = mpsc::unbounded_channel();
477
478            // Build RunContext for stateless operation
479            let run_ctx = RunContext::new()
480                .with_agent_name(agent_name.clone());
481            #[cfg(feature = "registry-mcp-token")]
482            {
483                // Note: token_store not available in handoff — handoffs don't
484                // need MCP credentials, so we skip it here.
485            }
486
487            let _res = abk::cli::run_task_from_raw_config(
488                &config_toml,
489                secrets,
490                build_info,
491                &prompt,
492                Some(cap_sink),
493                resume_info,
494                Some(dummy_tx),
495                Some(child_token),
496                Some(&run_ctx),
497            )
498            .await;
499
500            abk::observability::set_tui_mode(false);
501
502            let mut text_parts = String::new();
503            let mut reasoning_parts = String::new();
504            while let Ok(captured) = cap_rx.try_recv() {
505                match captured {
506                    CapturedText::Text(s) => text_parts.push_str(&s),
507                    CapturedText::Reasoning(s) => reasoning_parts.push_str(&s),
508                }
509            }
510
511            let briefing = if !text_parts.trim().is_empty() {
512                text_parts.trim().to_string()
513            } else if !reasoning_parts.trim().is_empty() {
514                reasoning_parts.trim().to_string()
515            } else {
516                "Session handoff: briefing unavailable — continue from previous context.".to_string()
517            };
518
519            tx.send(TuiMessage::HandoffReady(briefing)).ok();
520        });
521    }
522}
523
524impl Default for Session {
525    fn default() -> Self {
526        Self::new().0
527    }
528}
529
530/// A sink that forwards ABK `OutputEvent`s to the message channel.
531///
532/// Includes a 3-state atomic state machine (IDLE/REASONING/CONTENT) that
533/// inserts blank separator lines when transitioning between reasoning and
534/// content streams, so the frontend can distinguish them visually.
535pub struct TuiForwardSink {
536    tx: mpsc::UnboundedSender<TuiMessage>,
537    stream_state: AtomicU8,
538}
539
540/// Stream state machine constants.
541const STREAM_IDLE: u8 = 0;
542const STREAM_REASONING: u8 = 1;
543const STREAM_CONTENT: u8 = 2;
544
545impl TuiForwardSink {
546    pub fn new(tx: mpsc::UnboundedSender<TuiMessage>) -> Self {
547        Self {
548            tx,
549            stream_state: AtomicU8::new(STREAM_IDLE),
550        }
551    }
552}
553
554impl abk::orchestration::output::OutputSink for TuiForwardSink {
555    fn emit(&self, event: abk::orchestration::output::OutputEvent) {
556        use abk::orchestration::output::OutputEvent;
557
558        let msg = match event {
559            OutputEvent::StreamingChunk { delta } => {
560                if delta.is_empty() {
561                    return;
562                }
563                let prev = self.stream_state.swap(STREAM_CONTENT, Ordering::Relaxed);
564                if prev != STREAM_CONTENT {
565                    let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
566                }
567                let _ = self.tx.send(TuiMessage::StreamDelta(delta));
568                return;
569            }
570
571            OutputEvent::LlmResponse { text, model } => {
572                TuiMessage::OutputLine(format!("[{}] {}", model, text))
573            }
574
575            OutputEvent::Info { message } => {
576                // Suppress noisy/no-value messages from ABK
577                if message.contains("API call completed successfully") {
578                    return;
579                }
580                TuiMessage::OutputLine(message)
581            }
582
583            OutputEvent::WorkflowStarted { task_description } => {
584                TuiMessage::OutputLine(format!("🚀 Workflow started: {}", task_description))
585            }
586
587            OutputEvent::WorkflowCompleted { reason, iterations } => {
588                TuiMessage::OutputLine(format!(
589                    "✅ Workflow completed after {} iterations: {}",
590                    iterations, reason
591                ))
592            }
593
594            OutputEvent::IterationStarted { iteration, context_tokens } => {
595                let _ = self.tx.send(TuiMessage::ContextTokensUpdated(context_tokens));
596                TuiMessage::OutputLine(format!(
597                    "📡 Iteration {} | Context = {} tokens",
598                    iteration, context_tokens
599                ))
600            }
601
602            OutputEvent::ApiCallStarted {
603                call_number,
604                model,
605                tool_count,
606                streaming,
607                context_tokens,
608                tool_tokens,
609            } => {
610                let mode = if streaming { "Streaming" } else { "Non-streaming" };
611                let total = context_tokens + tool_tokens;
612                let _ = self.tx.send(TuiMessage::ContextTokensUpdated(total));
613                // Blank line separator before each API call for readability
614                let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
615                TuiMessage::OutputLine(format!(
616                    "🔥 API Call {} | Ctx={}({}+{}) | {} | Model: {} | Tools: {}",
617                    call_number, total, context_tokens, tool_tokens, mode, model, tool_count
618                ))
619            }
620
621            OutputEvent::ToolsExecuting { tool_names, hints } => {
622                for (name, hint) in tool_names.into_iter().zip(hints.into_iter()) {
623                    let _ = self.tx.send(TuiMessage::ToolPending { tool_name: name, hint });
624                }
625                self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
626                return;
627            }
628
629            OutputEvent::ToolCompleted {
630                tool_name,
631                success,
632                content,
633                description,
634            } => {
635                if tool_name == "todowrite" && success {
636                    let _ = self.tx.send(TuiMessage::TodoUpdate(content.clone()));
637                }
638                let hint = description;
639                let _ = self.tx.send(TuiMessage::ToolDone { tool_name, success, hint });
640                self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
641                return;
642            }
643
644            OutputEvent::Error { message, context } => {
645                if let Some(ctx) = context {
646                    TuiMessage::OutputLine(format!("❌ Error: {} — {}", message, ctx))
647                } else {
648                    TuiMessage::OutputLine(format!("❌ Error: {}", message))
649                }
650            }
651
652            OutputEvent::ReasoningChunk { delta } => {
653                if delta.is_empty() {
654                    return;
655                }
656                let prev = self.stream_state.swap(STREAM_REASONING, Ordering::Relaxed);
657                if prev != STREAM_REASONING {
658                    let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
659                }
660                let _ = self.tx.send(TuiMessage::ReasoningDelta(delta));
661                return;
662            }
663
664            OutputEvent::McpServerStatus { name, connected, tool_count, error } => {
665                let _ = self.tx.send(TuiMessage::McpServerStatus {
666                    name,
667                    connected,
668                    tool_count,
669                    error,
670                });
671                return;
672            }
673        };
674
675        self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
676        let _ = self.tx.send(msg);
677    }
678}