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    /// Per-user home directory for checkpoint storage. None = default ~/.{agent_name}/
85    /// Set to ~/.trustee/users/{user_hash}/ for per-user isolation in web mode.
86    pub home_dir: Option<std::path::PathBuf>,
87
88    /// Concurrency permit — held while a workflow is running.
89    /// When the workflow completes (state → Idle), this is dropped,
90    /// releasing the permit back to the global semaphore.
91    /// None when no workflow is running or when concurrency limiting is disabled.
92    pub workflow_permit: Option<tokio::sync::OwnedSemaphorePermit>,
93}
94
95impl Session {
96    /// Create a new Session with default state and a fresh message channel.
97    ///
98    /// Returns `(Session, Receiver)` so the caller can own the receiver
99    /// without locking the session (prevents deadlock in async drain loops).
100    pub fn new() -> (Self, mpsc::UnboundedReceiver<TuiMessage>) {
101        let (workflow_tx, workflow_rx) = mpsc::unbounded_channel();
102        let session = Self {
103            input: String::new(),
104            output_lines: Vec::new(),
105            workflow_tx,
106            workflow_state: WorkflowState::Idle,
107            config_toml: None,
108            secrets: None,
109            build_info: None,
110            resume_info: None,
111            backup_resume_info: None,
112            todo_lines: Vec::new(),
113            cancel_token: CancellationToken::new(),
114            pending_command: None,
115            handoff_pending: false,
116            pending_tool_lines: Vec::new(),
117            current_context_tokens: 0,
118            auto_handoff: AutoHandoffConfig::default(),
119            mcp_servers: Vec::new(),
120            should_quit: false,
121            auto_scroll: true,
122            agent_name: "trustee".to_string(),
123            token_store: None,
124            project_id: None,
125            project_name: None,
126            session_id: None,
127            session_name: None,
128            home_dir: None,
129            workflow_permit: None,
130        };
131        (session, workflow_rx)
132    }
133
134    /// Parse auto-handoff configuration from the stored config TOML.
135    pub fn parse_auto_handoff_config(&mut self) {
136        if let Some(ref config_toml) = self.config_toml {
137            self.auto_handoff = crate::config::parse_auto_handoff_config(config_toml);
138        }
139    }
140
141    /// Handle messages from async workflows.
142    ///
143    /// This processes all workflow lifecycle events, output updates, and state transitions.
144    /// Returns `true` if the caller should check for pending commands/handoffs after.
145    pub fn handle_workflow_message(&mut self, msg: TuiMessage) {
146        match msg {
147            TuiMessage::WorkflowCancelled => {
148                self.output_lines.push("⏹ Workflow cancelled".to_string());
149                self.output_lines.push("".to_string());
150                self.workflow_state = WorkflowState::Cancelling;
151            }
152            TuiMessage::OutputLine(line) => {
153                self.output_lines.push(line);
154            }
155            TuiMessage::StreamDelta(delta) => {
156                if let Some(last) = self.output_lines.last_mut() {
157                    last.push_str(&delta);
158                } else {
159                    self.output_lines.push(delta);
160                }
161            }
162            TuiMessage::ReasoningDelta(delta) => {
163                if let Some(last) = self.output_lines.last_mut() {
164                    if !last.starts_with('\x01') {
165                        last.insert(0, '\x01');
166                    }
167                    last.push_str(&delta);
168                } else {
169                    self.output_lines.push(format!("\x01{}", delta));
170                }
171            }
172            TuiMessage::WorkflowCompleted => {
173                self.output_lines.push("✓ Workflow completed".to_string());
174                self.output_lines.push("".to_string());
175                if self.workflow_state == WorkflowState::Running {
176                    self.workflow_state = WorkflowState::Cancelling;
177                }
178            }
179            TuiMessage::WorkflowError(err) => {
180                self.output_lines.push(format!("✗ Error: {}", err));
181                self.output_lines.push("".to_string());
182                if self.workflow_state == WorkflowState::Running {
183                    self.workflow_state = WorkflowState::Cancelling;
184                }
185            }
186            TuiMessage::TodoUpdate(content) => {
187                self.todo_lines = content.lines().map(|l| l.to_string()).collect();
188            }
189            TuiMessage::ToolPending { tool_name, hint } => {
190                let label = match &hint {
191                    Some(h) => format!("⠋ {} {}", tool_name, h),
192                    None => format!("⠋ {}", tool_name),
193                };
194                let idx = self.output_lines.len();
195                self.output_lines.push(label);
196                self.pending_tool_lines.push((tool_name, idx, hint));
197            }
198            TuiMessage::ToolDone { tool_name, success, hint } => {
199                let status = if success { "✓" } else { "✗" };
200                if let Some(pos) = self.pending_tool_lines.iter().position(|(n, _, _)| *n == tool_name) {
201                    let (_, idx, pending_hint) = self.pending_tool_lines.remove(pos);
202                    let h = hint.or(pending_hint);
203                    let label = match &h {
204                        Some(h) => format!("{} {} {}", status, tool_name, h),
205                        None => format!("{} {}", status, tool_name),
206                    };
207                    if idx < self.output_lines.len() {
208                        self.output_lines[idx] = label;
209                        return;
210                    }
211                    self.output_lines.push(label);
212                } else {
213                    let label = match &hint {
214                        Some(h) => format!("{} {} {}", status, tool_name, h),
215                        None => format!("{} {}", status, tool_name),
216                    };
217                    self.output_lines.push(label);
218                }
219            }
220            TuiMessage::ResumeInfo(info) => {
221                if self.workflow_state == WorkflowState::Cancelling && info.is_none() {
222                    self.resume_info = self.backup_resume_info.take();
223                } else if info.is_some() {
224                    // Only overwrite with a valid resume_info.
225                    // Don't let ResumeInfo(None) clobber a valid Some that was
226                    // set by an earlier incremental checkpoint message — this
227                    // happens when the error/cancel path fails to create a
228                    // final checkpoint but earlier checkpoints exist.
229                    self.resume_info = info;
230                    self.backup_resume_info = None;
231                }
232                // If info is None and we're not cancelling, keep existing resume_info.
233
234                // Capture session_id from resume_info so it persists for the
235                // lifetime of this conversation. This is immutable — once ABK
236                // assigns it, we never change it.
237                if let Some(ref ri) = self.resume_info {
238                    if self.session_id.is_none() {
239                        self.session_id = Some(ri.session_id.clone());
240                    }
241                }
242
243                if self.workflow_state == WorkflowState::Cancelling {
244                    self.workflow_state = WorkflowState::Idle;
245                    // Release the concurrency permit when the workflow finishes.
246                    self.workflow_permit = None;
247                }
248                if self.resume_info.is_some() {
249                    if std::env::var("RUST_LOG")
250                        .map(|v| v.to_lowercase().contains("debug"))
251                        .unwrap_or(false)
252                    {
253                        self.output_lines.push("🔄 Session preserved — next command will continue this session".to_string());
254                    }
255                }
256                if self.workflow_state == WorkflowState::Idle && self.handoff_pending {
257                    self.handoff_pending = false;
258                    self.trigger_handoff(String::new());
259                } else if let Some(cmd) = self.pending_command.take() {
260                    self.input = cmd;
261                    self.execute_command();
262                }
263            }
264            TuiMessage::ContextTokensUpdated(count) => {
265                self.current_context_tokens = count;
266                if self.auto_handoff.enabled
267                    && count >= self.auto_handoff.context_threshold
268                    && self.workflow_state == WorkflowState::Running
269                    && !self.handoff_pending
270                    && self.resume_info.is_some()
271                {
272                    self.handoff_pending = true;
273                    self.cancel_token.cancel();
274                    self.workflow_state = WorkflowState::Cancelling;
275                    self.output_lines.push(format!(
276                        "🔄 Auto-handoff: cancelling workflow, context tokens ({}) ≥ threshold ({})",
277                        count, self.auto_handoff.context_threshold
278                    ));
279                }
280            }
281            TuiMessage::McpServerStatus { name, connected, tool_count, error } => {
282                let status = if connected { McpServerStatus::Connected } else { McpServerStatus::Failed };
283                if let Some(existing) = self.mcp_servers.iter_mut().find(|s| s.name == name) {
284                    existing.status = status;
285                    existing.tool_count = tool_count;
286                    existing.error = error;
287                } else {
288                    self.mcp_servers.push(McpServerInfo { name, status, tool_count, error });
289                }
290            }
291            TuiMessage::HandoffReady(briefing) => {
292                self.workflow_state = WorkflowState::Idle;
293                self.resume_info = None;
294                // Clear session identity so execute_command generates fresh
295                // session_id/session_name for the new post-handoff session.
296                self.session_id = None;
297                self.session_name = None;
298                self.input = briefing;
299                self.execute_command();
300            }
301            TuiMessage::HandoffFailed => {
302                // Briefing unavailable — session continuity was already restored
303                // by the ResumeInfo(Some) that precedes this message. Return to
304                // Idle so the user can retry or continue manually (Bug 5/8).
305                self.workflow_state = WorkflowState::Idle;
306                self.output_lines.push(
307                    "✗ Handoff briefing unavailable — session preserved, try again.".to_string(),
308                );
309                self.output_lines.push("".to_string());
310            }
311            TuiMessage::SessionTitleUpdated(title) => {
312                // LLM-generated title has arrived — update the session name.
313                self.session_name = Some(title);
314            }
315        }
316        if self.auto_scroll {
317            // Signal to frontend that it should scroll to bottom.
318            // Frontend reads auto_scroll flag directly.
319        }
320    }
321
322    /// Execute the current command in the input buffer.
323    ///
324    /// Spawns an async ABK workflow task, clears the input buffer, and sets
325    /// workflow_state to Running.
326    pub fn execute_command(&mut self) {
327        let command = self.input.trim().to_string();
328
329        if self.workflow_state != WorkflowState::Idle {
330            self.pending_command = Some(command);
331            self.output_lines.push("⏳ Previous workflow finishing — command queued".to_string());
332            self.input.clear();
333            return;
334        }
335
336        let is_continuation = self.resume_info.is_some();
337
338        if !is_continuation {
339            self.output_lines.clear();
340            // Auto-derive session_id and session_name from the first command
341            // if not explicitly set. The session_id uses timestamp + UUID suffix
342            // (session_YYYY_MM_DD_HH_MM_{uuid8}) for uniqueness across all
343            // interfaces. The session_name is a human-readable display name
344            // (truncated command text).
345            if self.session_id.is_none() {
346                let timestamp = chrono::Utc::now().format("%Y_%m_%d_%H_%M");
347                let uuid_suffix = uuid::Uuid::new_v4().simple().to_string();
348                let uuid8 = &uuid_suffix[..8];
349                self.session_id = Some(format!("session_{}_{}", timestamp, uuid8));
350            }
351            if self.session_name.is_none() {
352                let derived = if command.len() > 80 {
353                    format!("{}...", &command[..77])
354                } else {
355                    command.clone()
356                };
357                self.session_name = Some(derived);
358            }
359        }
360
361        self.output_lines.push(format!("> {}", command));
362
363        let config_toml = match &self.config_toml {
364            Some(c) => c.clone(),
365            None => {
366                self.output_lines.push("✗ Error: Configuration not loaded".to_string());
367                self.output_lines.push("".to_string());
368                return;
369            }
370        };
371
372        let secrets = self.secrets.clone().unwrap_or_default();
373        // Clone secrets for post-completion title generation (secrets are moved into run_task below)
374        let title_secrets = secrets.clone();
375        let build_info = self.build_info.clone();
376        let tx = self.workflow_tx.clone();
377
378        let agent_name = self.agent_name.clone();
379        let token_store = self.token_store.clone();
380        let project_id = self.project_id.clone();
381        let project_name = self.project_name.clone();
382        let session_id = self.session_id.clone();
383        let session_name = self.session_name.clone();
384        let home_dir = self.home_dir.clone();
385
386        self.backup_resume_info = self.resume_info.clone();
387        let resume_info = self.resume_info.take();
388
389        self.workflow_state = WorkflowState::Running;
390        self.auto_scroll = true;
391
392        self.cancel_token = CancellationToken::new();
393        let child_token = self.cancel_token.clone();
394
395        let (resume_tx, mut resume_rx) = mpsc::unbounded_channel();
396
397        let resume_forward_tx = tx.clone();
398        tokio::spawn(async move {
399            while let Some(info) = resume_rx.recv().await {
400                resume_forward_tx.send(TuiMessage::ResumeInfo(info)).ok();
401            }
402        });
403
404        tokio::spawn(async move {
405            let tui_sink: abk::orchestration::output::SharedSink =
406                Arc::new(crate::session::TuiForwardSink::new(tx.clone()));
407
408            // Build RunContext from session fields for stateless operation
409            let mut run_ctx = RunContext::new()
410                .with_agent_name(agent_name.clone());
411
412            // Set home_dir for per-user isolation if provided
413            if let Some(ref dir) = home_dir {
414                run_ctx = run_ctx.with_home_dir(dir.clone());
415            }
416
417            // Set project identity if any field is provided
418            if project_id.is_some() || project_name.is_some() {
419                run_ctx = run_ctx.with_project(abk::context::ProjectIdentity {
420                    id: project_id.unwrap_or_else(|| "default".to_string()),
421                    name: project_name,
422                });
423            }
424
425            // Set session identity if any field is provided
426            if session_id.is_some() || session_name.is_some() {
427                run_ctx = run_ctx.with_session(abk::context::SessionIdentity {
428                    id: session_id.unwrap_or_else(|| "default".to_string()),
429                    name: session_name,
430                });
431            }
432
433            #[cfg(feature = "registry-mcp-token")]
434            {
435                if let Some(ref ts) = token_store {
436                    run_ctx = run_ctx.with_token_store(ts.clone());
437                }
438            }
439
440            // Run the entire workflow inside a TUI-mode scope and a
441            // per-task logger scope. This replaces the old process-global
442            // set_tui_mode()/init_global_logger() mutations with task-local
443            // scopes, enabling safe concurrent multi-user operation.
444            let scope_logger = {
445                abk::observability::Logger::with_agent_name(
446                    None::<&std::path::Path>,
447                    Some("INFO"),
448                    Some(&agent_name),
449                ).unwrap_or_else(|_| abk::observability::Logger::new(None, Some("INFO")).unwrap())
450            };
451
452            let result = abk::observability::with_logger(scope_logger, async {
453                abk::observability::with_tui_mode(true, async {
454                    abk::cli::run_task_from_raw_config(
455                        &config_toml,
456                        secrets,
457                        build_info,
458                        &command,
459                        Some(tui_sink),
460                        resume_info,
461                        Some(resume_tx),
462                        Some(child_token),
463                        Some(&run_ctx),
464                    )
465                    .await
466                })
467                .await
468            })
469            .await;
470
471            let task_result = result.unwrap_or_else(|e| abk::cli::TaskResult {
472                success: false,
473                error: Some(e.to_string()),
474                // resume_info will be None here, but the on_checkpoint channel
475                // may have already delivered a valid ResumeInfo via TuiMessage.
476                // The ResumeInfo handler now ignores None when a valid Some exists,
477                // so this None won't clobber the earlier incremental checkpoint.
478                resume_info: None,
479            });
480
481            let msg = if task_result.success {
482                TuiMessage::WorkflowCompleted
483            } else {
484                TuiMessage::WorkflowError(task_result.error.unwrap_or_default())
485            };
486
487            // Capture session_id from resume_info before it's moved into the channel
488            let title_session_id = task_result.resume_info
489                .as_ref()
490                .map(|ri| ri.session_id.clone());
491
492            tx.send(msg).ok();
493            tx.send(TuiMessage::ResumeInfo(task_result.resume_info)).ok();
494
495            // Solution B: After successful completion, spawn a lightweight LLM call
496            // to generate a descriptive session title. The title is:
497            // 1. Persisted to session_metadata.json on disk via persist_session_title
498            // 2. Sent via SessionTitleUpdated message (updates in-memory session_name)
499            // Only fires if the title hasn't been LLM-set yet.
500            // Fire-and-forget — errors don't affect the session.
501            if task_result.success {
502                let title_tx = tx.clone();
503                let title_config = config_toml.clone();
504                let title_command = command.clone();
505                let title_ctx = run_ctx.clone();
506
507                tokio::spawn(async move {
508                    // Only generate titles for truly fresh sessions.
509                    // Check the disk: if session already has checkpoints from prior
510                    // runs, or description is already set, skip.
511                    if let Some(ref sid) = title_session_id {
512                        if !abk::cli::should_generate_title(&title_ctx, sid, &title_command).await {
513                            return;
514                        }
515                    } else {
516                        return; // No session ID — can't safely persist
517                    }
518
519                    // Small delay to ensure checkpoint metadata writes complete first
520                    tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
521
522                    // Re-check after delay (checkpoint may have written metadata)
523                    if let Some(ref sid) = title_session_id {
524                        if !abk::cli::should_generate_title(&title_ctx, sid, &title_command).await {
525                            return;
526                        }
527                    }
528
529                    match abk::cli::generate_session_title(
530                        &title_config,
531                        title_secrets,
532                        &title_command,
533                    )
534                    .await
535                    {
536                        Ok(Some(title)) => {
537                            // Persist to disk + remote
538                            if let Some(ref sid) = title_session_id {
539                                if let Err(e) = abk::cli::persist_session_title(
540                                    &title_ctx,
541                                    &title_config,
542                                    sid,
543                                    &title,
544                                ).await {
545                                    let _ = e; // non-fatal
546                                }
547                            }
548                            // Update in-memory session name for live WS clients
549                            title_tx.send(TuiMessage::SessionTitleUpdated(title)).ok();
550                        }
551                        Ok(None) => {}
552                        Err(e) => { let _ = e; }
553                    }
554                });
555            }
556        });
557
558        self.input.clear();
559    }
560
561    /// Request a session handoff through the safe state-machine entry point.
562    ///
563    /// Mirrors the TUI Ctrl+H behavior:
564    /// - Idle → trigger the handoff immediately
565    /// - Running → cancel the workflow, fire handoff once it stops
566    /// - Cancelling → queue handoff for when it stops
567    ///
568    /// This prevents any caller (web button, API, future MCP tool) from
569    /// spawning a briefing task concurrently with a running workflow
570    /// (Bugs 1/2/5).
571    pub fn request_handoff(&mut self, hint: String) {
572        match self.workflow_state {
573            WorkflowState::Idle => self.trigger_handoff(hint),
574            WorkflowState::Running => {
575                self.cancel_token.cancel();
576                self.workflow_state = WorkflowState::Cancelling;
577                self.handoff_pending = true;
578                self.output_lines
579                    .push("⏹ Cancelling before handoff...".to_string());
580            }
581            WorkflowState::Cancelling => {
582                self.handoff_pending = true;
583            }
584        }
585    }
586
587    /// Trigger a session handoff.
588    ///
589    /// Runs a single LLM call using the current session's resume_info to generate
590    /// a briefing. On completion, sends `TuiMessage::HandoffReady(briefing)`.
591    ///
592    /// This is the internal Idle-only entry point. Callers that may be invoked
593    /// while a workflow is running should use [`Session::request_handoff`].
594    pub fn trigger_handoff(&mut self, hint: String) {
595        // Belt-and-braces guard: never run a briefing concurrently with a
596        // workflow (Bug 1). request_handoff handles the Running/Cancelling
597        // states; direct callers must be Idle.
598        if self.workflow_state != WorkflowState::Idle {
599            self.output_lines
600                .push("⏳ Workflow still running — handoff will fire after it stops".to_string());
601            return;
602        }
603        if self.resume_info.is_none() {
604            self.output_lines.push("ℹ Nothing to hand off — run a task first".to_string());
605            return;
606        }
607
608        let config_toml = match &self.config_toml {
609            Some(c) => c.clone(),
610            None => {
611                self.output_lines.push("✗ Error: Configuration not loaded".to_string());
612                return;
613            }
614        };
615
616        // Disable checkpointing for the briefing run so it does NOT write new
617        // checkpoints into the OLD session's chain (Bug 6). Resume-from-checkpoint
618        // still works — SessionManager is always created — but no checkpoints are
619        // saved during the briefing, so history stays intact.
620        let briefing_config = {
621            let mut value: toml::Value = config_toml
622                .parse()
623                .unwrap_or_else(|_| toml::Value::Table(toml::Table::new()));
624            if let Some(ckpt) = value.get_mut("checkpointing").and_then(|c| c.as_table_mut()) {
625                ckpt.insert("enabled".to_string(), toml::Value::Boolean(false));
626            }
627            toml::to_string(&value).unwrap_or_else(|_| config_toml.clone())
628        };
629
630        let secrets = self.secrets.clone().unwrap_or_default();
631        let build_info = self.build_info.clone();
632        let tx = self.workflow_tx.clone();
633
634        let agent_name = self.agent_name.clone();
635        let token_store = self.token_store.clone();
636        let project_id = self.project_id.clone();
637        let project_name = self.project_name.clone();
638        let session_id = self.session_id.clone();
639        let session_name = self.session_name.clone();
640        let home_dir = self.home_dir.clone();
641
642        // Preserve resume_info so a failed/cancelled briefing can restore the
643        // session instead of losing all continuity (Bug 4/8).
644        let backup_resume_info = self.resume_info.clone();
645        let resume_info = self.resume_info.take();
646
647        self.workflow_state = WorkflowState::Running;
648        self.auto_scroll = true;
649        self.cancel_token = CancellationToken::new();
650        let child_token = self.cancel_token.clone();
651
652        self.output_lines.push("🔀 Generating session handoff briefing...".to_string());
653
654        tokio::spawn(async move {
655            let (cap_tx, mut cap_rx) = mpsc::unbounded_channel::<CapturedText>();
656            let cap_sink: abk::orchestration::output::SharedSink =
657                Arc::new(HandoffCaptureSink::new(cap_tx, child_token.clone()));
658
659            let base = "Output a session handoff briefing in at most 300 lines. \
660                 Do NOT use any tools. Include: the FULL ABSOLUTE PATH of every \
661                 project/repository being worked on (e.g. /Projects/Foo/bar — never \
662                 omit the leading path), all project/task/workstream UUIDs referenced, \
663                 every file created or modified with its full absolute path, all \
664                 commands run and their outcomes, the current state of the work, any \
665                 blockers, and the exact next action to take. \
666                 Output ONLY the briefing text — no preamble, headers, or closing remarks.";
667            let prompt = if hint.is_empty() {
668                base.to_string()
669            } else {
670                format!("{base}\n\nIn the briefing also consider: {hint}")
671            };
672
673            let (dummy_tx, _dummy_rx) = mpsc::unbounded_channel();
674
675            // Build RunContext for stateless operation — same fields as execute_command
676            let mut run_ctx = RunContext::new()
677                .with_agent_name(agent_name.clone());
678
679            // Set home_dir for per-user isolation
680            if let Some(ref dir) = home_dir {
681                run_ctx = run_ctx.with_home_dir(dir.clone());
682            }
683
684            // Set project identity if any field is provided
685            if project_id.is_some() || project_name.is_some() {
686                run_ctx = run_ctx.with_project(abk::context::ProjectIdentity {
687                    id: project_id.unwrap_or_else(|| "default".to_string()),
688                    name: project_name,
689                });
690            }
691
692            // Set session identity if any field is provided
693            if session_id.is_some() || session_name.is_some() {
694                run_ctx = run_ctx.with_session(abk::context::SessionIdentity {
695                    id: session_id.unwrap_or_else(|| "default".to_string()),
696                    name: session_name,
697                });
698            }
699            #[cfg(feature = "registry-mcp-token")]
700            {
701                // Note: token_store not available in handoff — handoffs don't
702                // need MCP credentials, so we skip it here.
703            }
704
705            // Run inside per-task logger + TUI-mode scope (task-local, not process-global)
706            let scope_logger = abk::observability::Logger::with_agent_name(
707                None,
708                Some("INFO"),
709                Some(&agent_name),
710            ).unwrap_or_else(|_| abk::observability::Logger::new(None, Some("INFO")).unwrap());
711
712            let _res = abk::observability::with_logger(scope_logger, async {
713                abk::observability::with_tui_mode(true, async {
714                    abk::cli::run_task_from_raw_config(
715                        &briefing_config,
716                        secrets,
717                        build_info,
718                        &prompt,
719                        Some(cap_sink),
720                        resume_info,
721                        Some(dummy_tx),
722                        Some(child_token),
723                        Some(&run_ctx),
724                    )
725                    .await
726                })
727                .await
728            })
729            .await;
730
731            let mut text_parts = String::new();
732            let mut reasoning_parts = String::new();
733            while let Ok(captured) = cap_rx.try_recv() {
734                match captured {
735                    CapturedText::Text(s) => text_parts.push_str(&s),
736                    CapturedText::Reasoning(s) => reasoning_parts.push_str(&s),
737                }
738            }
739
740            let briefing = if !text_parts.trim().is_empty() {
741                Some(text_parts.trim().to_string())
742            } else if !reasoning_parts.trim().is_empty() {
743                Some(reasoning_parts.trim().to_string())
744            } else {
745                None
746            };
747
748            match briefing {
749                Some(briefing) => {
750                    tx.send(TuiMessage::HandoffReady(briefing)).ok();
751                }
752                None => {
753                    // Briefing unavailable (LLM failure, truncation, or the
754                    // briefing was cancelled/tool-call aborted). Restore session
755                    // continuity and surface the failure — NEVER auto-execute a
756                    // garbage fallback string as a new task (Bug 8).
757                    tx.send(TuiMessage::ResumeInfo(backup_resume_info)).ok();
758                    tx.send(TuiMessage::HandoffFailed).ok();
759                }
760            }
761        });
762    }
763}
764
765impl Default for Session {
766    fn default() -> Self {
767        Self::new().0
768    }
769}
770
771/// A sink that forwards ABK `OutputEvent`s to the message channel.
772///
773/// Includes a 3-state atomic state machine (IDLE/REASONING/CONTENT) that
774/// inserts blank separator lines when transitioning between reasoning and
775/// content streams, so the frontend can distinguish them visually.
776pub struct TuiForwardSink {
777    tx: mpsc::UnboundedSender<TuiMessage>,
778    stream_state: AtomicU8,
779}
780
781/// Stream state machine constants.
782const STREAM_IDLE: u8 = 0;
783const STREAM_REASONING: u8 = 1;
784const STREAM_CONTENT: u8 = 2;
785
786impl TuiForwardSink {
787    pub fn new(tx: mpsc::UnboundedSender<TuiMessage>) -> Self {
788        Self {
789            tx,
790            stream_state: AtomicU8::new(STREAM_IDLE),
791        }
792    }
793}
794
795impl abk::orchestration::output::OutputSink for TuiForwardSink {
796    fn emit(&self, event: abk::orchestration::output::OutputEvent) {
797        use abk::orchestration::output::OutputEvent;
798
799        let msg = match event {
800            OutputEvent::StreamingChunk { delta } => {
801                if delta.is_empty() {
802                    return;
803                }
804                let prev = self.stream_state.swap(STREAM_CONTENT, Ordering::Relaxed);
805                if prev != STREAM_CONTENT {
806                    let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
807                }
808                let _ = self.tx.send(TuiMessage::StreamDelta(delta));
809                return;
810            }
811
812            OutputEvent::LlmResponse { text, model } => {
813                TuiMessage::OutputLine(format!("[{}] {}", model, text))
814            }
815
816            OutputEvent::Info { message } => {
817                // Suppress noisy/no-value messages from ABK
818                if message.contains("API call completed successfully") {
819                    return;
820                }
821                TuiMessage::OutputLine(message)
822            }
823
824            OutputEvent::WorkflowStarted { task_description } => {
825                TuiMessage::OutputLine(format!("🚀 Workflow started: {}", task_description))
826            }
827
828            OutputEvent::WorkflowCompleted { reason, iterations } => {
829                TuiMessage::OutputLine(format!(
830                    "✅ Workflow completed after {} iterations: {}",
831                    iterations, reason
832                ))
833            }
834
835            OutputEvent::IterationStarted { iteration, context_tokens } => {
836                let _ = self.tx.send(TuiMessage::ContextTokensUpdated(context_tokens));
837                TuiMessage::OutputLine(format!(
838                    "📡 Iteration {} | Context = {} tokens",
839                    iteration, context_tokens
840                ))
841            }
842
843            OutputEvent::ApiCallStarted {
844                call_number,
845                model,
846                tool_count,
847                streaming,
848                context_tokens,
849                tool_tokens,
850            } => {
851                let mode = if streaming { "Streaming" } else { "Non-streaming" };
852                let total = context_tokens + tool_tokens;
853                let _ = self.tx.send(TuiMessage::ContextTokensUpdated(total));
854                // Blank line separator before each API call for readability
855                let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
856                TuiMessage::OutputLine(format!(
857                    "🔥 API Call {} | Ctx={}({}+{}) | {} | Model: {} | Tools: {}",
858                    call_number, total, context_tokens, tool_tokens, mode, model, tool_count
859                ))
860            }
861
862            OutputEvent::ToolsExecuting { tool_names, hints } => {
863                for (name, hint) in tool_names.into_iter().zip(hints.into_iter()) {
864                    let _ = self.tx.send(TuiMessage::ToolPending { tool_name: name, hint });
865                }
866                self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
867                return;
868            }
869
870            OutputEvent::ToolCompleted {
871                tool_name,
872                success,
873                content,
874                description,
875            } => {
876                if tool_name == "todowrite" && success {
877                    let _ = self.tx.send(TuiMessage::TodoUpdate(content.clone()));
878                }
879                let hint = description;
880                let _ = self.tx.send(TuiMessage::ToolDone { tool_name, success, hint });
881                self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
882                return;
883            }
884
885            OutputEvent::Error { message, context } => {
886                if let Some(ctx) = context {
887                    TuiMessage::OutputLine(format!("❌ Error: {} — {}", message, ctx))
888                } else {
889                    TuiMessage::OutputLine(format!("❌ Error: {}", message))
890                }
891            }
892
893            OutputEvent::ReasoningChunk { delta } => {
894                if delta.is_empty() {
895                    return;
896                }
897                let prev = self.stream_state.swap(STREAM_REASONING, Ordering::Relaxed);
898                if prev != STREAM_REASONING {
899                    let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
900                }
901                let _ = self.tx.send(TuiMessage::ReasoningDelta(delta));
902                return;
903            }
904
905            OutputEvent::McpServerStatus { name, connected, tool_count, error } => {
906                let _ = self.tx.send(TuiMessage::McpServerStatus {
907                    name,
908                    connected,
909                    tool_count,
910                    error,
911                });
912                return;
913            }
914        };
915
916        self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
917        let _ = self.tx.send(msg);
918    }
919}