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