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