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