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