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, TuiMessage, WorkflowState,
19};
20
21/// Truncate a first-command string into a display session name.
22///
23/// Never slices by raw byte index: non-ASCII scripts (Persian, Arabic, CJK,
24/// emoji, …) are multi-byte in UTF-8 and a fixed byte cut can land mid-
25/// character, which PANICS ("byte index is not a char boundary") inside the
26/// tokio worker and poisons the session mutex (nghr 811ed903).
27///
28/// Semantics: ≤ 80 CHARS → returned unchanged (pure-ASCII behavior is
29/// byte-for-byte identical to the original implementation); > 80 chars →
30/// truncated at the last char boundary at or before byte 77, plus "...".
31pub fn truncate_session_name(command: &str) -> String {
32    if command.chars().count() <= 80 {
33        command.to_string()
34    } else {
35        let end = command
36            .char_indices()
37            .map(|(i, _)| i)
38            .take_while(|&i| i <= 77)
39            .last()
40            .unwrap_or(0);
41        format!("{}...", &command[..end])
42    }
43}
44
45/// Core session state for the Trustee agent.
46///
47/// One text/markdown file attachment on a user command.
48///
49/// The content is already decoded UTF-8 (the API layer validates before the
50/// session ever sees it). Rendered into the LLM turn as opencode-style
51/// synthetic text: a header line + a fenced block, so text files ride the
52/// same wire form as the user's own words — no provider-side file protocol
53/// needed.
54#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
55pub struct TextFileAttachment {
56    /// Declared MIME type ("text/plain" or "text/markdown").
57    pub mime: String,
58    /// Original file name (display + fence info string).
59    pub filename: String,
60    /// Decoded UTF-8 file content.
61    pub content: String,
62}
63
64/// Longest run of backticks in `content` — used to pick a closing fence
65/// longer than anything the file itself contains, so fenced content can
66/// never escape its own block.
67fn longest_backtick_run(content: &str) -> usize {
68    let mut max = 0;
69    let mut run = 0;
70    for c in content.chars() {
71        if c == '`' {
72            run += 1;
73            max = max.max(run);
74        } else {
75            run = 0;
76        }
77    }
78    max
79}
80
81/// Fence string for a fenced block wrapping `content`: at least three
82/// backticks, longer when the content itself contains fences.
83fn fence_for(content: &str) -> String {
84    "`".repeat(3.max(longest_backtick_run(content) + 1))
85}
86
87/// Compose the LLM-visible task text: the user's command plus each text
88/// file as an opencode-style synthetic block — header line (name, mime,
89/// size) + fenced content. The visible transcript keeps echoing the RAW
90/// command; only the model sees the composed form.
91pub fn compose_task_with_text_files(command: &str, files: &[TextFileAttachment]) -> String {
92    if files.is_empty() {
93        return command.to_string();
94    }
95    let mut composed = String::with_capacity(command.len() + 64);
96    composed.push_str(command);
97    for f in files {
98        let fence = fence_for(&f.content);
99        let info = match f.filename.rsplit_once('.') {
100            Some((stem, ext)) if !stem.is_empty() && !ext.is_empty() && ext.len() <= 8 => ext,
101            _ => "text",
102        };
103        composed.push_str(&format!(
104            "\n\n[Attached file: {} ({}, {} bytes)]\n{}{}\n{}\n{}",
105            f.filename,
106            f.mime,
107            f.content.len(),
108            fence,
109            info,
110            f.content,
111            fence,
112        ));
113    }
114    composed
115}
116
117/// Holds all state that is independent of the presentation layer (TUI, API, Web).
118/// Frontend crates compose this struct and add their own UI-specific fields.
119pub struct Session {
120    /// Input buffer for user commands
121    pub input: String,
122    /// Pre-loaded image attachments (multimodal) for the next command.
123    /// Consumed by `execute_command` and attached to the initial user turn;
124    /// callers (API/web) fill this before invoking `execute_command`.
125    pub input_images: Vec<umf::chatml::ImageAttachment>,
126    /// Pre-loaded text/markdown file attachments for the next command.
127    /// Same lifecycle as `input_images`: callers fill before
128    /// `execute_command`; consumed exactly once. Composed into the task
129    /// text as fenced synthetic blocks.
130    pub input_text_files: Vec<TextFileAttachment>,
131    /// Output log lines
132    pub output_lines: Vec<String>,
133    /// Sender for messages from async workflows (clone and pass to workflow runners)
134    pub workflow_tx: mpsc::UnboundedSender<TuiMessage>,
135    /// Current workflow lifecycle state
136    pub workflow_state: WorkflowState,
137    /// Configuration TOML for ABK workflows
138    pub config_toml: Option<String>,
139    /// Secrets for ABK workflows
140    pub secrets: Option<HashMap<String, String>>,
141    /// Build info for ABK workflows
142    pub build_info: Option<BuildInfo>,
143    /// Resume info from the last completed task for session continuity
144    pub resume_info: Option<ResumeInfo>,
145    /// Saved resume_info before execute_command consumes it; restored if task
146    /// is cancelled before producing a real checkpoint (mistake-ENTER recovery).
147    pub backup_resume_info: Option<ResumeInfo>,
148    /// Latest todo list from LLM todowrite tool
149    pub todo_lines: Vec<String>,
150    /// Cancellation token for aborting the current workflow
151    pub cancel_token: CancellationToken,
152    /// Command buffered by user during cancellation wind-down.
153    pub pending_command: Option<String>,
154    /// Whether a session handoff (Ctrl+H) should fire once the current workflow cancels.
155    pub handoff_pending: bool,
156    /// In-flight spinner entries: (tool_name, output_lines_index, hint).
157    pub pending_tool_lines: Vec<(String, usize, Option<String>)>,
158    /// Current context token count (updated from ApiCallStarted events).
159    pub current_context_tokens: usize,
160    /// Auto-handoff configuration parsed from [tui.auto_handoff].
161    pub auto_handoff: AutoHandoffConfig,
162    /// MCP server statuses received from agent init
163    pub mcp_servers: Vec<McpServerInfo>,
164    /// Whether the session should quit
165    pub should_quit: bool,
166    /// Whether auto-scroll is enabled (follows new output)
167    pub auto_scroll: bool,
168
169    // --- TMU Phase 1: Stateless Core ---
170    /// Agent name for checkpoint/token paths (replaces ABK_AGENT_NAME env var).
171    /// Defaults to "trustee". Set from config at startup.
172    pub agent_name: String,
173    /// Per-session token store (None = FileTokenStore fallback).
174    /// When set, MCP credential flows use this instead of file-based storage.
175    pub token_store: Option<Arc<dyn pep::token_store::TokenStore>>,
176
177    /// Per-user MCP loader (16C), resolved lazily by trustee-api at dispatch
178    /// time and injected into the workflow's RunContext so abk constructs the
179    /// Agent with `McpSource::Prebuilt` — zero per-task MCP reconnects.
180    /// `None` = not resolved (legacy per-task construction) or MCP disabled.
181    #[cfg(feature = "registry-mcp-token")]
182    pub mcp_loader: Option<std::sync::Arc<abk::agent::McpToolLoader>>,
183    #[cfg(not(feature = "registry-mcp-token"))]
184    pub mcp_loader: Option<()>,
185
186    // --- Project/Session Identity (backward compatible, all None = old behavior) ---
187    /// Storage partition key (replaces path hash). None = hash(working_dir)
188    pub project_id: Option<String>,
189    /// Human-readable project name. None = directory name
190    pub project_name: Option<String>,
191    /// Storage directory name (replaces timestamp slug). None = auto-generate
192    pub session_id: Option<String>,
193    /// Human-readable session name. None = no description
194    pub session_name: Option<String>,
195    /// Per-user home directory for checkpoint storage. None = default ~/.{agent_name}/
196    /// Set to ~/.trustee/users/{user_hash}/ for per-user isolation in web mode.
197    pub home_dir: Option<std::path::PathBuf>,
198
199    /// Concurrency permit — held while a workflow is running.
200    /// When the workflow completes (state → Idle), this is dropped,
201    /// releasing the permit back to the global semaphore.
202    /// None when no workflow is running or when concurrency limiting is disabled.
203    pub workflow_permit: Option<tokio::sync::OwnedSemaphorePermit>,
204
205    /// Optional agent identity content (e.g. fetched from Fame).
206    /// When set, prepended to `lifecycle.system_template` in the config
207    /// clone inside `execute_command()` and `trigger_handoff()`.
208    /// None = use the config's default system template.
209    pub identity: Option<String>,
210
211    /// Optional model override for the next command.
212    /// When set, `[llm.providers.{model}]` from the config TOML is injected
213    /// into `[llm.provider]` before creating the agent, switching the LLM
214    /// for that command. Consumed per-command (cleared after injection).
215    /// None = use the default `[llm.provider]`.
216    pub model: Option<String>,
217
218    // --- Handoff rotation (post 0.9.16 bugs) ---
219    /// Set to true immediately before the post-handoff briefing execute, so
220    /// `execute_command` treats that run as a rotation rather than a fresh
221    /// session: it derives a new chain identity but does NOT clear the
222    /// visible transcript (Bug 2). Cleared once the rotation is handled.
223    pub rotating_from_handoff: bool,
224    /// True while the current chain identity was born from a handoff briefing
225    /// and no user command has run in it yet (Bug 4). While true, handoff is
226    /// blocked entirely (manual + auto) to prevent briefing-of-briefing loops.
227    /// Cleared on the first user-initiated command.
228    pub briefing_born: bool,
229    /// Monotonic count of handoff rotations this session has performed.
230    /// Surfaced in live-session listings so the UI can show lineage
231    /// (Bug 5: "name rotated silently" → "name · ↻2").
232    pub handoff_count: u32,
233
234    /// Shared compaction-status handle (abk 0.21.4).
235    ///
236    /// The session owns the handle and threads a clone through the run's
237    /// `RunContext`; abk's compaction loop stamps `in_progress`/`started_at`
238    /// while a summarization run is in flight and records the durable `last`
239    /// trace on completion. trustee-api reads it in the live-session payload
240    /// so consoles can show "compacting…" state that survives a refresh
241    /// (the /compact command never enters the transcript — it is intercepted
242    /// before becoming a task message).
243    pub compaction_status: abk::resume::SharedCompactionStatus,
244}
245
246impl Session {
247    /// Create a new Session with default state and a fresh message channel.
248    ///
249    /// Returns `(Session, Receiver)` so the caller can own the receiver
250    /// without locking the session (prevents deadlock in async drain loops).
251    pub fn new() -> (Self, mpsc::UnboundedReceiver<TuiMessage>) {
252        let (workflow_tx, workflow_rx) = mpsc::unbounded_channel();
253        let session = Self {
254            input: String::new(),
255            output_lines: Vec::new(),
256            workflow_tx,
257            workflow_state: WorkflowState::Idle,
258            config_toml: None,
259            secrets: None,
260            build_info: None,
261            resume_info: None,
262            backup_resume_info: None,
263            todo_lines: Vec::new(),
264            cancel_token: CancellationToken::new(),
265            input_images: Vec::new(),
266            input_text_files: Vec::new(),
267            pending_command: None,
268            handoff_pending: false,
269            pending_tool_lines: Vec::new(),
270            current_context_tokens: 0,
271            auto_handoff: AutoHandoffConfig::default(),
272            mcp_servers: Vec::new(),
273            should_quit: false,
274            auto_scroll: true,
275            agent_name: "trustee".to_string(),
276            token_store: None,
277            mcp_loader: None,
278            project_id: None,
279            project_name: None,
280            session_id: None,
281            session_name: None,
282            home_dir: None,
283            workflow_permit: None,
284            identity: None,
285            model: None,
286            rotating_from_handoff: false,
287            briefing_born: false,
288            handoff_count: 0,
289            compaction_status: std::sync::Arc::new(std::sync::Mutex::new(
290                abk::resume::CompactionStatus::default(),
291            )),
292        };
293        (session, workflow_rx)
294    }
295
296    /// Parse auto-handoff configuration from the stored config TOML.
297    pub fn parse_auto_handoff_config(&mut self) {
298        if let Some(ref config_toml) = self.config_toml {
299            self.auto_handoff = crate::config::parse_auto_handoff_config(config_toml);
300        }
301    }
302
303    /// Handle messages from async workflows.
304    ///
305    /// This processes all workflow lifecycle events, output updates, and state transitions.
306    /// Returns `true` if the caller should check for pending commands/handoffs after.
307    pub fn handle_workflow_message(&mut self, msg: TuiMessage) {
308        match msg {
309            TuiMessage::WorkflowCancelled => {
310                self.output_lines.push("⏹ Workflow cancelled".to_string());
311                self.output_lines.push("".to_string());
312                self.workflow_state = WorkflowState::Cancelling;
313            }
314            TuiMessage::OutputLine(line) => {
315                self.output_lines.push(line);
316            }
317            TuiMessage::StreamDelta(delta) => {
318                if let Some(last) = self.output_lines.last_mut() {
319                    last.push_str(&delta);
320                } else {
321                    self.output_lines.push(delta);
322                }
323            }
324            TuiMessage::ReasoningDelta(delta) => {
325                if let Some(last) = self.output_lines.last_mut() {
326                    if !last.starts_with('\x01') {
327                        last.insert(0, '\x01');
328                    }
329                    last.push_str(&delta);
330                } else {
331                    self.output_lines.push(format!("\x01{}", delta));
332                }
333            }
334            TuiMessage::WorkflowCompleted => {
335                self.output_lines.push("✓ Workflow completed".to_string());
336                self.output_lines.push("".to_string());
337                if self.workflow_state == WorkflowState::Running {
338                    self.workflow_state = WorkflowState::Cancelling;
339                }
340                // abk 0.21.4 defensive clear: the compaction loop clears its
341                // own in_progress stamp, but a cancel-mid-compaction aborts
342                // the run task before that clear runs — the workflow-end
343                // boundary is the session-owned backstop (last is kept).
344                if let Ok(mut s) = self.compaction_status.lock() {
345                    s.in_progress = false;
346                    s.started_at = None;
347                    s.reason = None;
348                }
349            }
350            TuiMessage::WorkflowError(err) => {
351                self.output_lines.push(format!("✗ Error: {}", err));
352                self.output_lines.push("".to_string());
353                if self.workflow_state == WorkflowState::Running {
354                    self.workflow_state = WorkflowState::Cancelling;
355                }
356                // Same defensive clear as WorkflowCompleted (cancel/abort backstop).
357                if let Ok(mut s) = self.compaction_status.lock() {
358                    s.in_progress = false;
359                    s.started_at = None;
360                    s.reason = None;
361                }
362            }
363            TuiMessage::TodoUpdate(content) => {
364                self.todo_lines = content.lines().map(|l| l.to_string()).collect();
365            }
366            TuiMessage::ToolPending { tool_name, hint } => {
367                let label = match &hint {
368                    Some(h) => format!("⠋ {} {}", tool_name, h),
369                    None => format!("⠋ {}", tool_name),
370                };
371                let idx = self.output_lines.len();
372                self.output_lines.push(label);
373                self.pending_tool_lines.push((tool_name, idx, hint));
374            }
375            TuiMessage::ToolDone {
376                tool_name,
377                success,
378                hint,
379            } => {
380                let status = if success { "✓" } else { "✗" };
381                if let Some(pos) = self
382                    .pending_tool_lines
383                    .iter()
384                    .position(|(n, _, _)| *n == tool_name)
385                {
386                    let (_, idx, pending_hint) = self.pending_tool_lines.remove(pos);
387                    let h = hint.or(pending_hint);
388                    let label = match &h {
389                        Some(h) => format!("{} {} {}", status, tool_name, h),
390                        None => format!("{} {}", status, tool_name),
391                    };
392                    if idx < self.output_lines.len() {
393                        self.output_lines[idx] = label;
394                        return;
395                    }
396                    self.output_lines.push(label);
397                } else {
398                    let label = match &hint {
399                        Some(h) => format!("{} {} {}", status, tool_name, h),
400                        None => format!("{} {}", status, tool_name),
401                    };
402                    self.output_lines.push(label);
403                }
404            }
405            TuiMessage::ResumeInfo(info) => {
406                if self.workflow_state == WorkflowState::Cancelling && info.is_none() {
407                    self.resume_info = self.backup_resume_info.take();
408                } else if info.is_some() {
409                    // Only overwrite with a valid resume_info.
410                    // Don't let ResumeInfo(None) clobber a valid Some that was
411                    // set by an earlier incremental checkpoint message — this
412                    // happens when the error/cancel path fails to create a
413                    // final checkpoint but earlier checkpoints exist.
414                    self.resume_info = info;
415                    self.backup_resume_info = None;
416                }
417                // If info is None and we're not cancelling, keep existing resume_info.
418
419                // Capture session_id from resume_info so it persists for the
420                // lifetime of this conversation. This is immutable — once ABK
421                // assigns it, we never change it.
422                if let Some(ref ri) = self.resume_info {
423                    if self.session_id.is_none() {
424                        self.session_id = Some(ri.session_id.clone());
425                    }
426                }
427
428                if self.workflow_state == WorkflowState::Cancelling {
429                    self.workflow_state = WorkflowState::Idle;
430                    // Release the concurrency permit when the workflow finishes.
431                    self.workflow_permit = None;
432                }
433                if self.resume_info.is_some() {
434                    if std::env::var("RUST_LOG")
435                        .map(|v| v.to_lowercase().contains("debug"))
436                        .unwrap_or(false)
437                    {
438                        self.output_lines.push(
439                            "🔄 Session preserved — next command will continue this session"
440                                .to_string(),
441                        );
442                    }
443                }
444                if self.workflow_state == WorkflowState::Idle && self.handoff_pending {
445                    self.handoff_pending = false;
446                    self.trigger_handoff(String::new());
447                } else if let Some(cmd) = self.pending_command.take() {
448                    self.input = cmd;
449                    self.execute_command();
450                }
451            }
452            TuiMessage::ContextTokensUpdated(count) => {
453                self.current_context_tokens = count;
454                if self.auto_handoff.enabled
455                    && count >= self.auto_handoff.context_threshold
456                    && self.workflow_state == WorkflowState::Running
457                    && !self.handoff_pending
458                    && self.resume_info.is_some()
459                    // Bug 4: never auto-handoff a briefing-born chain — a large
460                    // briefing inflating the context would chain handoffs into a
461                    // briefing-of-briefing loop. Auto-handoff only re-arms after
462                    // a real user command has run in this chain.
463                    && !self.briefing_born
464                {
465                    self.handoff_pending = true;
466                    self.cancel_token.cancel();
467                    self.workflow_state = WorkflowState::Cancelling;
468                    self.output_lines.push(format!(
469                        "🔄 Auto-handoff: cancelling workflow, context tokens ({}) ≥ threshold ({})",
470                        count, self.auto_handoff.context_threshold
471                    ));
472                }
473            }
474            TuiMessage::McpServerStatus {
475                name,
476                connected,
477                tool_count,
478                error,
479            } => {
480                let status = if connected {
481                    McpServerStatus::Connected
482                } else {
483                    McpServerStatus::Failed
484                };
485                if let Some(existing) = self.mcp_servers.iter_mut().find(|s| s.name == name) {
486                    existing.status = status;
487                    existing.tool_count = tool_count;
488                    existing.error = error;
489                } else {
490                    self.mcp_servers.push(McpServerInfo {
491                        name,
492                        status,
493                        tool_count,
494                        error,
495                    });
496                }
497            }
498            TuiMessage::HandoffReady(briefing) => {
499                self.workflow_state = WorkflowState::Idle;
500                self.resume_info = None;
501                // Capture the pre-rotation chain id so the rotation event can
502                // tell clients exactly which chain was replaced (Bug 1).
503                let old_session_id = self.session_id.clone();
504                // Clear session identity so execute_command generates fresh
505                // session_id/session_name for the new post-handoff session.
506                self.session_id = None;
507                self.session_name = None;
508                // Rotation markers (Bugs 2/4): preserve the transcript across
509                // the identity switch, and mark the new chain as briefing-born
510                // so it can't immediately hand off again (briefing-of-briefing
511                // loop) until a real user command runs in it.
512                self.rotating_from_handoff = true;
513                self.briefing_born = true;
514                self.handoff_count += 1;
515                self.input = briefing;
516                self.execute_command();
517                // execute_command derives the new chain id synchronously —
518                // broadcast the explicit rotation event so clients adopt the
519                // new identity without inferring it from ResumeInfo (Bug 1).
520                let _ = self.workflow_tx.send(TuiMessage::SessionRotated {
521                    old: old_session_id,
522                    new: self.session_id.clone(),
523                });
524            }
525            TuiMessage::SessionRotated { .. } => {
526                // Identity rotation already applied in-place by the
527                // HandoffReady handler. This event is for external clients
528                // (web/torpi consoles) — nothing to do in session state.
529            }
530            TuiMessage::HandoffFailed => {
531                // Briefing unavailable — session continuity was already restored
532                // by the ResumeInfo(Some) that precedes this message. Return to
533                // Idle so the user can retry or continue manually (Bug 5/8).
534                self.workflow_state = WorkflowState::Idle;
535                self.output_lines.push(
536                    "✗ Handoff briefing unavailable — session preserved, try again.".to_string(),
537                );
538                self.output_lines.push("".to_string());
539            }
540            TuiMessage::SessionTitleUpdated(title) => {
541                // LLM-generated title has arrived — update the session name.
542                self.session_name = Some(title);
543            }
544        }
545        if self.auto_scroll {
546            // Signal to frontend that it should scroll to bottom.
547            // Frontend reads auto_scroll flag directly.
548        }
549    }
550
551    /// Execute the current command in the input buffer.
552    ///
553    /// Spawns an async ABK workflow task, clears the input buffer, and sets
554    /// workflow_state to Running.
555    pub fn execute_command(&mut self) {
556        let command = self.input.trim().to_string();
557
558        if self.workflow_state != WorkflowState::Idle {
559            self.pending_command = Some(command);
560            self.output_lines
561                .push("⏳ Previous workflow finishing — command queued".to_string());
562            self.input.clear();
563            return;
564        }
565
566        let is_continuation = self.resume_info.is_some();
567
568        // Rotation gate (Bug 2): the post-handoff briefing execute is a
569        // "fresh chain" run (resume_info == None) but NOT a fresh session —
570        // the user's visible transcript must survive the identity switch.
571        // Take the flag so it applies exactly once, to this run.
572        let rotating_from_handoff = self.rotating_from_handoff;
573        self.rotating_from_handoff = false;
574
575        // Bug 4: the first command that is NOT the briefing run itself is a
576        // real user command — the briefing-born lock lifts and handoff
577        // (manual and auto) re-arms for this chain.
578        if !rotating_from_handoff {
579            self.briefing_born = false;
580        }
581
582        // Transcript gate (Bug 2): never wipe the visible transcript for a
583        // session that has rotated its chain identity. This covers the
584        // briefing run itself (rotating_from_handoff) AND any later command
585        // that lands on the non-continuation path (e.g. after a failed
586        // checkpoint) — a rotated session is one logical conversation, not a
587        // fresh one.
588        if !is_continuation && !rotating_from_handoff && self.handoff_count == 0 {
589            self.output_lines.clear();
590        }
591        // Identity derivation: applies to EVERY non-continuation run (fresh
592        // session OR handoff rotation) — a rotated session must get its new
593        // chain id immediately so the SessionRotated event can carry it.
594        if !is_continuation {
595            // Auto-derive session_id and session_name from the first command
596            // if not explicitly set. The session_id uses timestamp + UUID suffix
597            // (session_YYYY_MM_DD_HH_MM_{uuid8}) for uniqueness across all
598            // interfaces. The session_name is a human-readable display name
599            // (truncated command text).
600            if self.session_id.is_none() {
601                let timestamp = chrono::Utc::now().format("%Y_%m_%d_%H_%M");
602                let uuid_suffix = uuid::Uuid::new_v4().simple().to_string();
603                let uuid8 = &uuid_suffix[..8];
604                self.session_id = Some(format!("session_{}_{}", timestamp, uuid8));
605            }
606            if self.session_name.is_none() {
607                self.session_name = Some(truncate_session_name(&command));
608            }
609        }
610
611        self.output_lines.push(format!("> {}", command));
612
613        // Multimodal: take (not clone) pending image attachments — they apply
614        // to THIS command only, like the command text itself.
615        let input_images = std::mem::take(&mut self.input_images);
616        if !input_images.is_empty() {
617            self.output_lines
618                .push(format!("📎 {} image attachment(s)", input_images.len()));
619        }
620
621        // Text/markdown file attachments: same one-command lifecycle.
622        // The transcript echoes the RAW command; the composed text (with
623        // fenced file blocks) is what the model receives.
624        let input_text_files = std::mem::take(&mut self.input_text_files);
625        if !input_text_files.is_empty() {
626            self.output_lines
627                .push(format!("📎 {} file attachment(s)", input_text_files.len()));
628        }
629        let task_text = compose_task_with_text_files(&command, &input_text_files);
630
631        let config_toml = match &self.config_toml {
632            Some(c) => c.clone(),
633            None => {
634                self.output_lines
635                    .push("✗ Error: Configuration not loaded".to_string());
636                self.output_lines.push("".to_string());
637                return;
638            }
639        };
640
641        // Inject identity into the config clone if present.
642        let config_toml = inject_identity(config_toml, &self.identity);
643
644        // Inject model override (select from [llm.providers.{model}]) if present.
645        let model_override = self.model.take();
646        let config_toml = inject_model(config_toml, model_override);
647
648        let secrets = self.secrets.clone().unwrap_or_default();
649        // Clone secrets for post-completion title generation (secrets are moved into run_task below)
650        let title_secrets = secrets.clone();
651        let build_info = self.build_info.clone();
652        let tx = self.workflow_tx.clone();
653
654        let agent_name = self.agent_name.clone();
655        let token_store = self.token_store.clone();
656        let project_id = self.project_id.clone();
657        let project_name = self.project_name.clone();
658        let session_id = self.session_id.clone();
659        let session_name = self.session_name.clone();
660        let home_dir = self.home_dir.clone();
661        let mcp_loader = self.mcp_loader.clone();
662        let compaction_status = self.compaction_status.clone();
663
664        self.backup_resume_info = self.resume_info.clone();
665        let resume_info = self.resume_info.take();
666
667        self.workflow_state = WorkflowState::Running;
668        self.auto_scroll = true;
669
670        self.cancel_token = CancellationToken::new();
671        let child_token = self.cancel_token.clone();
672
673        let (resume_tx, mut resume_rx) = mpsc::unbounded_channel();
674
675        let resume_forward_tx = tx.clone();
676        tokio::spawn(async move {
677            while let Some(info) = resume_rx.recv().await {
678                resume_forward_tx.send(TuiMessage::ResumeInfo(info)).ok();
679            }
680        });
681
682        tokio::spawn(async move {
683            let tui_sink: abk::orchestration::output::SharedSink =
684                Arc::new(crate::session::TuiForwardSink::new(tx.clone()));
685
686            // Build RunContext from session fields for stateless operation
687            let mut run_ctx = RunContext::new().with_agent_name(agent_name.clone());
688
689            // Set home_dir for per-user isolation if provided
690            if let Some(ref dir) = home_dir {
691                run_ctx = run_ctx.with_home_dir(dir.clone());
692            }
693
694            // Set project identity if any field is provided
695            if project_id.is_some() || project_name.is_some() {
696                run_ctx = run_ctx.with_project(abk::context::ProjectIdentity {
697                    id: project_id.unwrap_or_else(|| "default".to_string()),
698                    name: project_name,
699                });
700            }
701
702            // Set session identity if any field is provided
703            if session_id.is_some() || session_name.is_some() {
704                run_ctx = run_ctx.with_session(abk::context::SessionIdentity {
705                    id: session_id.unwrap_or_else(|| "default".to_string()),
706                    name: session_name,
707                });
708            }
709
710            #[cfg(feature = "registry-mcp-token")]
711            {
712                if let Some(ref ts) = token_store {
713                    run_ctx = run_ctx.with_token_store(ts.clone());
714                }
715            }
716
717            // 16C: inject the shared per-user MCP loader so Agent
718            // construction performs ZERO MCP network I/O (abk 0.16 McpSource::Prebuilt).
719            #[cfg(feature = "registry-mcp-token")]
720            if let Some(loader) = &mcp_loader {
721                run_ctx = run_ctx.with_mcp_loader(loader.clone());
722            }
723
724            // abk 0.21.4: thread the shared compaction-status handle through
725            // the run context — abk's compaction loop stamps in_progress/
726            // started_at while a summarization run is in flight and records
727            // the durable last trace on completion; trustee-api reads the
728            // same handle in the live payload (console "compacting…" banner).
729            run_ctx = run_ctx.with_compaction_status(compaction_status);
730
731            // Run the entire workflow inside a TUI-mode scope and a
732            // per-task logger scope. This replaces the old process-global
733            // set_tui_mode()/init_global_logger() mutations with task-local
734            // scopes, enabling safe concurrent multi-user operation.
735            let scope_logger = {
736                abk::observability::Logger::with_agent_name(
737                    None::<&std::path::Path>,
738                    Some("INFO"),
739                    Some(&agent_name),
740                )
741                .unwrap_or_else(|_| abk::observability::Logger::new(None, Some("INFO")).unwrap())
742            };
743
744            let result = abk::observability::with_logger(scope_logger, async {
745                abk::observability::with_tui_mode(true, async {
746                    abk::cli::run_task_from_raw_config(
747                        &config_toml,
748                        secrets,
749                        build_info,
750                        &task_text,
751                        // Multimodal: web/API callers pass pre-loaded sidecar
752                        // entries here; THQ-dispatched text-only commands
753                        // arrive with an empty vec.
754                        Vec::new(),
755                        input_images,
756                        Some(tui_sink),
757                        resume_info,
758                        Some(resume_tx),
759                        Some(child_token),
760                        Some(&run_ctx),
761                    )
762                    .await
763                })
764                .await
765            })
766            .await;
767
768            let task_result = result.unwrap_or_else(|e| abk::cli::TaskResult {
769                success: false,
770                error: Some(e.to_string()),
771                // resume_info will be None here, but the on_checkpoint channel
772                // may have already delivered a valid ResumeInfo via TuiMessage.
773                // The ResumeInfo handler now ignores None when a valid Some exists,
774                // so this None won't clobber the earlier incremental checkpoint.
775                resume_info: None,
776            });
777
778            let msg = if task_result.success {
779                TuiMessage::WorkflowCompleted
780            } else {
781                TuiMessage::WorkflowError(task_result.error.unwrap_or_default())
782            };
783
784            // Capture session_id from resume_info before it's moved into the channel
785            let title_session_id = task_result
786                .resume_info
787                .as_ref()
788                .map(|ri| ri.session_id.clone());
789
790            tx.send(msg).ok();
791            tx.send(TuiMessage::ResumeInfo(task_result.resume_info))
792                .ok();
793
794            // Solution B: After successful completion, spawn a lightweight LLM call
795            // to generate a descriptive session title. The title is:
796            // 1. Persisted to session_metadata.json on disk via persist_session_title
797            // 2. Sent via SessionTitleUpdated message (updates in-memory session_name)
798            // Only fires if the title hasn't been LLM-set yet.
799            // Fire-and-forget — errors don't affect the session.
800            if task_result.success {
801                let title_tx = tx.clone();
802                let title_config = config_toml.clone();
803                let title_command = command.clone();
804                let title_ctx = run_ctx.clone();
805
806                tokio::spawn(async move {
807                    // Only generate titles for truly fresh sessions.
808                    // Check the disk: if session already has checkpoints from prior
809                    // runs, or description is already set, skip.
810                    if let Some(ref sid) = title_session_id {
811                        if !abk::cli::should_generate_title(&title_ctx, sid, &title_command).await {
812                            return;
813                        }
814                    } else {
815                        return; // No session ID — can't safely persist
816                    }
817
818                    // Small delay to ensure checkpoint metadata writes complete first
819                    tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
820
821                    // Re-check after delay (checkpoint may have written metadata)
822                    if let Some(ref sid) = title_session_id {
823                        if !abk::cli::should_generate_title(&title_ctx, sid, &title_command).await {
824                            return;
825                        }
826                    }
827
828                    match abk::cli::generate_session_title(
829                        &title_config,
830                        title_secrets,
831                        &title_command,
832                    )
833                    .await
834                    {
835                        Ok(Some(title)) => {
836                            // Persist to disk + remote
837                            if let Some(ref sid) = title_session_id {
838                                if let Err(e) = abk::cli::persist_session_title(
839                                    &title_ctx,
840                                    &title_config,
841                                    sid,
842                                    &title,
843                                )
844                                .await
845                                {
846                                    let _ = e; // non-fatal
847                                }
848                            }
849                            // Update in-memory session name for live WS clients
850                            title_tx.send(TuiMessage::SessionTitleUpdated(title)).ok();
851                        }
852                        Ok(None) => {}
853                        Err(e) => {
854                            let _ = e;
855                        }
856                    }
857                });
858            }
859        });
860
861        self.input.clear();
862    }
863
864    /// Request a session handoff through the safe state-machine entry point.
865    ///
866    /// Mirrors the TUI Ctrl+H behavior:
867    /// - Idle → trigger the handoff immediately
868    /// - Running → cancel the workflow, fire handoff once it stops
869    /// - Cancelling → queue handoff for when it stops
870    ///
871    /// This prevents any caller (web button, API, future MCP tool) from
872    /// spawning a briefing task concurrently with a running workflow
873    /// (Bugs 1/2/5).
874    pub fn request_handoff(&mut self, hint: String) {
875        // Bug 4: a session born from a handoff briefing cannot hand off again
876        // before a user command has run in it. Manual double-triggers during
877        // handoff investigation spawned two extra chains in 3 minutes; the
878        // second briefing was literally about the first briefing. Explicit
879        // user action may re-arm it only after real work has happened.
880        if self.briefing_born {
881            self.workflow_tx
882                .send(TuiMessage::OutputLine(
883                    "ℹ Handoff unavailable — this session started from a handoff briefing. Run a command first.".to_string(),
884                ))
885                .ok();
886            return;
887        }
888        match self.workflow_state {
889            WorkflowState::Idle => self.trigger_handoff(hint),
890            WorkflowState::Running => {
891                self.cancel_token.cancel();
892                self.workflow_state = WorkflowState::Cancelling;
893                self.handoff_pending = true;
894                // Broadcast via the message channel so live UI (web/TUI) sees
895                // the status immediately. A direct output_lines.push() would
896                // only appear after a page refresh (output_lines is served via
897                // the /api/v1/session poll, not pushed over WebSocket).
898                self.workflow_tx
899                    .send(TuiMessage::OutputLine(
900                        "⏹ Cancelling before handoff...".to_string(),
901                    ))
902                    .ok();
903            }
904            WorkflowState::Cancelling => {
905                self.handoff_pending = true;
906            }
907        }
908    }
909
910    /// Trigger a session handoff.
911    ///
912    /// Runs a single LLM call using the current session's resume_info to generate
913    /// a briefing. On completion, sends `TuiMessage::HandoffReady(briefing)`.
914    ///
915    /// This is the internal Idle-only entry point. Callers that may be invoked
916    /// while a workflow is running should use [`Session::request_handoff`].
917    pub fn trigger_handoff(&mut self, hint: String) {
918        // Belt-and-braces guard: never run a briefing concurrently with a
919        // workflow (Bug 1). request_handoff handles the Running/Cancelling
920        // states; direct callers must be Idle.
921        if self.workflow_state != WorkflowState::Idle {
922            self.output_lines
923                .push("⏳ Workflow still running — handoff will fire after it stops".to_string());
924            return;
925        }
926        // Bug 4 (defense in depth): request_handoff already rejects this, but
927        // trigger_handoff is also reachable from the handoff_pending path —
928        // never hand off a chain that hasn't seen a user command yet.
929        if self.briefing_born {
930            self.output_lines
931                .push("ℹ Handoff unavailable — this session started from a handoff briefing. Run a command first.".to_string());
932            return;
933        }
934        if self.resume_info.is_none() {
935            self.output_lines
936                .push("ℹ Nothing to hand off — run a task first".to_string());
937            return;
938        }
939
940        let config_toml = match &self.config_toml {
941            Some(c) => c.clone(),
942            None => {
943                self.output_lines
944                    .push("✗ Error: Configuration not loaded".to_string());
945                return;
946            }
947        };
948
949        // Inject identity into the config clone if present.
950        let config_toml = inject_identity(config_toml, &self.identity);
951
952        // Inject model override for handoff briefing (use same model as the session).
953        let model_override = self.model.clone();
954        let config_toml = inject_model(config_toml, model_override);
955
956        let tx = self.workflow_tx.clone();
957        let agent_name = self.agent_name.clone();
958        let project_id = self.project_id.clone();
959        let project_name = self.project_name.clone();
960        let session_id = self.session_id.clone();
961        let session_name = self.session_name.clone();
962        let home_dir = self.home_dir.clone();
963
964        // Preserve resume_info so a failed briefing can restore the session
965        // instead of losing all continuity (Bug 4/8).
966        let backup_resume_info = self.resume_info.clone();
967        let resume_info = match self.resume_info.take() {
968            Some(ri) => ri,
969            None => {
970                // Already checked is_none() above — defensive only.
971                self.output_lines
972                    .push("ℹ Nothing to hand off — run a task first".to_string());
973                return;
974            }
975        };
976
977        self.workflow_state = WorkflowState::Running;
978        self.auto_scroll = true;
979        self.cancel_token = CancellationToken::new();
980
981        // Broadcast via the message channel so live UI (web/TUI) sees the
982        // status line immediately. A direct output_lines.push() would only
983        // appear after a page refresh (output_lines is served via the
984        // /api/v1/session poll, not pushed over WebSocket). The drain task
985        // pushes this line to output_lines (via handle_workflow_message) AND
986        // broadcasts StateChanged(Running) + OutputLine to WS clients.
987        self.workflow_tx
988            .send(TuiMessage::OutputLine(
989                "🔀 Generating session handoff briefing...".to_string(),
990            ))
991            .ok();
992
993        tokio::spawn(async move {
994            let base = "Output a session handoff briefing in at most 300 lines. \
995                 Do NOT use any tools. The FIRST line must be exactly: \
996                 \"Session Title: <a concise descriptive title of this work, maximum 50 characters>\" \
997                 — nothing else on that line. The rest of the briefing must include: \
998                 the FULL ABSOLUTE PATH of every \
999                 project/repository being worked on (e.g. /Projects/Foo/bar — never \
1000                 omit the leading path), all project/task/workstream UUIDs referenced, \
1001                 every file created or modified with its full absolute path, all \
1002                 commands run and their outcomes, the current state of the work, any \
1003                 blockers, and the exact next action to take. \
1004                 Output ONLY the briefing text — the title line first, then the body, \
1005                 with no other headers, preamble, or closing remarks.";
1006            let prompt = if hint.is_empty() {
1007                base.to_string()
1008            } else {
1009                format!("{base}\n\nIn the briefing also consider: {hint}")
1010            };
1011
1012            // Build RunContext for stateless operation — same fields as execute_command
1013            let mut run_ctx = RunContext::new().with_agent_name(agent_name.clone());
1014
1015            // Set home_dir for per-user isolation
1016            if let Some(ref dir) = home_dir {
1017                run_ctx = run_ctx.with_home_dir(dir.clone());
1018            }
1019
1020            // Set project identity if any field is provided
1021            if project_id.is_some() || project_name.is_some() {
1022                run_ctx = run_ctx.with_project(abk::context::ProjectIdentity {
1023                    id: project_id.unwrap_or_else(|| "default".to_string()),
1024                    name: project_name,
1025                });
1026            }
1027
1028            // Set session identity if any field is provided
1029            if session_id.is_some() || session_name.is_some() {
1030                run_ctx = run_ctx.with_session(abk::context::SessionIdentity {
1031                    id: session_id.unwrap_or_else(|| "default".to_string()),
1032                    name: session_name,
1033                });
1034            }
1035
1036            // Run inside per-task logger + TUI-mode scope (task-local, not process-global)
1037            let scope_logger =
1038                abk::observability::Logger::with_agent_name(None, Some("INFO"), Some(&agent_name))
1039                    .unwrap_or_else(|_| {
1040                        abk::observability::Logger::new(None, Some("INFO")).unwrap()
1041                    });
1042
1043            // Single direct LLM call — no agentic workflow loop. This is the fix
1044            // for the session-bricking bug: the full workflow ran tools, looped
1045            // on the large history, and never completed.
1046            let result = abk::observability::with_logger(scope_logger, async {
1047                abk::observability::with_tui_mode(true, async {
1048                    abk::cli::generate_handoff_briefing(
1049                        &config_toml,
1050                        &run_ctx,
1051                        &resume_info,
1052                        &prompt,
1053                    )
1054                    .await
1055                })
1056                .await
1057            })
1058            .await;
1059
1060            match result {
1061                Ok(briefing) if !briefing.trim().is_empty() => {
1062                    tx.send(TuiMessage::HandoffReady(briefing)).ok();
1063                }
1064                _ => {
1065                    // Briefing unavailable (LLM failure, truncation, cancel).
1066                    // Restore session continuity and surface the failure —
1067                    // NEVER auto-execute a garbage fallback string (Bug 8).
1068                    tx.send(TuiMessage::ResumeInfo(backup_resume_info)).ok();
1069                    tx.send(TuiMessage::HandoffFailed).ok();
1070                }
1071            }
1072        });
1073    }
1074}
1075
1076impl Default for Session {
1077    fn default() -> Self {
1078        Self::new().0
1079    }
1080}
1081
1082/// Prepend agent identity to `lifecycle.system_template` in the config TOML.
1083///
1084/// If `identity` is `None` or empty, returns the config unchanged.
1085/// On parse/serialize failure, returns the config unchanged (best-effort).
1086fn inject_identity(config_toml: String, identity: &Option<String>) -> String {
1087    let Some(identity) = identity.as_ref().filter(|s| !s.is_empty()) else {
1088        return config_toml;
1089    };
1090
1091    let Ok(mut table) = config_toml.parse::<toml::Value>() else {
1092        return config_toml;
1093    };
1094
1095    let lifecycle = table.get_mut("lifecycle").and_then(|v| v.as_table_mut());
1096
1097    if let Some(lifecycle) = lifecycle {
1098        let existing = lifecycle
1099            .get("system_template")
1100            .and_then(|v| v.as_str())
1101            .unwrap_or("");
1102        let combined = format!("{}\n\n{}", identity, existing);
1103        lifecycle.insert("system_template".to_string(), toml::Value::String(combined));
1104    } else {
1105        // No [lifecycle] section — create one with just the identity as template.
1106        let mut ltable = toml::value::Table::new();
1107        ltable.insert(
1108            "system_template".to_string(),
1109            toml::Value::String(identity.clone()),
1110        );
1111        if let Some(table) = table.as_table_mut() {
1112            table.insert("lifecycle".to_string(), toml::Value::Table(ltable));
1113        }
1114    }
1115
1116    toml::to_string(&table).unwrap_or(config_toml)
1117}
1118
1119/// Override the model for the next command.
1120///
1121/// `model` is a LITERAL model string (e.g. "GLM-4.7-Flash", "gpt-4o"),
1122/// not necessarily a provider key. Resolution order:
1123///
1124/// 1. If it matches a KEY in `[llm.providers.*]` → swap to that provider,
1125///    use its default model.
1126/// 2. Else if it appears in some named provider's `models` list (or equals
1127///    its `model`) → swap to that provider AND set that model on it.
1128/// 3. Else — set it as the `model` on the current provider. This lets users
1129///    pass any model name their endpoint accepts even if unlisted.
1130///
1131/// If `model` is `None`, the config is returned unchanged.
1132fn inject_model(config_toml: String, model: Option<String>) -> String {
1133    let Some(model_name) = model.as_ref().filter(|s| !s.is_empty()) else {
1134        return config_toml;
1135    };
1136
1137    let Ok(mut table) = config_toml.parse::<toml::Value>() else {
1138        return config_toml;
1139    };
1140
1141    let llm = match table.get_mut("llm").and_then(|v| v.as_table_mut()) {
1142        Some(l) => l,
1143        None => return config_toml,
1144    };
1145
1146    // Helper: is this named-provider entry a real provider (vs ambiguous)?
1147    let looks_like_provider = |v: &toml::Value| -> bool {
1148        v.as_table()
1149            .map(|t| {
1150                t.contains_key("base_url") || t.contains_key("api_key") || t.contains_key("models")
1151            })
1152            .unwrap_or(false)
1153    };
1154    // Helper: does this entry offer this model?
1155    let offers_model = |v: &toml::Value| -> bool {
1156        v.get("models")
1157            .and_then(|m| m.as_array())
1158            .map(|arr| arr.iter().any(|m| m.as_str() == Some(model_name)))
1159            .unwrap_or(false)
1160            || v.get("model").and_then(|m| m.as_str()) == Some(model_name.as_str())
1161    };
1162
1163    let providers = llm.get("providers").cloned();
1164
1165    // Case 1: model_name is a provider KEY → swap, keep provider's default model.
1166    if let Some(ref ps) = providers {
1167        if let Some(selected) = ps.get(model_name.as_str()) {
1168            if looks_like_provider(selected) {
1169                let mut selected = selected.clone();
1170                if let Some(st) = selected.as_table_mut() {
1171                    st.entry("provider_type".to_string())
1172                        .or_insert_with(|| toml::Value::String("openai".to_string()));
1173                }
1174                llm.insert("provider".to_string(), selected);
1175                return toml::to_string(&table).unwrap_or(config_toml);
1176            }
1177        }
1178    }
1179
1180    // Case 2: model_name is OFFERED by some named provider → swap + set model.
1181    if let Some(ref ps) = providers {
1182        if let Some(ptable) = ps.as_table() {
1183            for (_key, entry) in ptable {
1184                if looks_like_provider(entry) && offers_model(entry) {
1185                    let mut selected = entry.clone();
1186                    if let Some(st) = selected.as_table_mut() {
1187                        st.insert("model".to_string(), toml::Value::String(model_name.clone()));
1188                        st.entry("provider_type".to_string())
1189                            .or_insert_with(|| toml::Value::String("openai".to_string()));
1190                    }
1191                    llm.insert("provider".to_string(), selected);
1192                    return toml::to_string(&table).unwrap_or(config_toml);
1193                }
1194            }
1195        }
1196    }
1197
1198    // Case 3: literal model string — set it on the current provider.
1199    if let Some(provider) = llm.get_mut("provider").and_then(|p| p.as_table_mut()) {
1200        provider.insert("model".to_string(), toml::Value::String(model_name.clone()));
1201    }
1202
1203    toml::to_string(&table).unwrap_or(config_toml)
1204}
1205///
1206/// Includes a 3-state atomic state machine (IDLE/REASONING/CONTENT) that
1207/// inserts blank separator lines when transitioning between reasoning and
1208/// content streams, so the frontend can distinguish them visually.
1209pub struct TuiForwardSink {
1210    tx: mpsc::UnboundedSender<TuiMessage>,
1211    stream_state: AtomicU8,
1212}
1213
1214/// Stream state machine constants.
1215const STREAM_IDLE: u8 = 0;
1216const STREAM_REASONING: u8 = 1;
1217const STREAM_CONTENT: u8 = 2;
1218
1219impl TuiForwardSink {
1220    pub fn new(tx: mpsc::UnboundedSender<TuiMessage>) -> Self {
1221        Self {
1222            tx,
1223            stream_state: AtomicU8::new(STREAM_IDLE),
1224        }
1225    }
1226}
1227
1228impl abk::orchestration::output::OutputSink for TuiForwardSink {
1229    fn emit(&self, event: abk::orchestration::output::OutputEvent) {
1230        use abk::orchestration::output::OutputEvent;
1231
1232        let msg = match event {
1233            OutputEvent::StreamingChunk { delta } => {
1234                if delta.is_empty() {
1235                    return;
1236                }
1237                let prev = self.stream_state.swap(STREAM_CONTENT, Ordering::Relaxed);
1238                if prev != STREAM_CONTENT {
1239                    let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
1240                }
1241                let _ = self.tx.send(TuiMessage::StreamDelta(delta));
1242                return;
1243            }
1244
1245            OutputEvent::LlmResponse { text, model } => {
1246                TuiMessage::OutputLine(format!("[{}] {}", model, text))
1247            }
1248
1249            OutputEvent::Info { message } => {
1250                // Suppress noisy/no-value messages from ABK
1251                if message.contains("API call completed successfully") {
1252                    return;
1253                }
1254                TuiMessage::OutputLine(message)
1255            }
1256
1257            OutputEvent::WorkflowStarted { task_description } => {
1258                TuiMessage::OutputLine(format!("🚀 Workflow started: {}", task_description))
1259            }
1260
1261            OutputEvent::WorkflowCompleted { reason, iterations } => {
1262                TuiMessage::OutputLine(format!(
1263                    "✅ Workflow completed after {} iterations: {}",
1264                    iterations, reason
1265                ))
1266            }
1267
1268            OutputEvent::IterationStarted {
1269                iteration,
1270                context_tokens,
1271            } => {
1272                let _ = self
1273                    .tx
1274                    .send(TuiMessage::ContextTokensUpdated(context_tokens));
1275                TuiMessage::OutputLine(format!(
1276                    "📡 Iteration {} | Context = {} tokens",
1277                    iteration, context_tokens
1278                ))
1279            }
1280
1281            OutputEvent::ApiCallStarted {
1282                call_number,
1283                model,
1284                tool_count,
1285                streaming,
1286                context_tokens,
1287                tool_tokens,
1288            } => {
1289                let mode = if streaming {
1290                    "Streaming"
1291                } else {
1292                    "Non-streaming"
1293                };
1294                let total = context_tokens + tool_tokens;
1295                let _ = self.tx.send(TuiMessage::ContextTokensUpdated(total));
1296                // Blank line separator before each API call for readability
1297                let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
1298                TuiMessage::OutputLine(format!(
1299                    "🔥 API Call {} | Ctx={}({}+{}) | {} | Model: {} | Tools: {}",
1300                    call_number, total, context_tokens, tool_tokens, mode, model, tool_count
1301                ))
1302            }
1303
1304            OutputEvent::ToolsExecuting { tool_names, hints } => {
1305                for (name, hint) in tool_names.into_iter().zip(hints.into_iter()) {
1306                    let _ = self.tx.send(TuiMessage::ToolPending {
1307                        tool_name: name,
1308                        hint,
1309                    });
1310                }
1311                self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
1312                return;
1313            }
1314
1315            OutputEvent::ToolCompleted {
1316                tool_name,
1317                success,
1318                content,
1319                description,
1320            } => {
1321                if tool_name == "todowrite" && success {
1322                    let _ = self.tx.send(TuiMessage::TodoUpdate(content.clone()));
1323                }
1324                let hint = description;
1325                let _ = self.tx.send(TuiMessage::ToolDone {
1326                    tool_name,
1327                    success,
1328                    hint,
1329                });
1330                self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
1331                return;
1332            }
1333
1334            OutputEvent::Error { message, context } => {
1335                if let Some(ctx) = context {
1336                    TuiMessage::OutputLine(format!("❌ Error: {} — {}", message, ctx))
1337                } else {
1338                    TuiMessage::OutputLine(format!("❌ Error: {}", message))
1339                }
1340            }
1341
1342            OutputEvent::ReasoningChunk { delta } => {
1343                if delta.is_empty() {
1344                    return;
1345                }
1346                let prev = self.stream_state.swap(STREAM_REASONING, Ordering::Relaxed);
1347                if prev != STREAM_REASONING {
1348                    let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
1349                }
1350                let _ = self.tx.send(TuiMessage::ReasoningDelta(delta));
1351                return;
1352            }
1353
1354            OutputEvent::McpServerStatus {
1355                name,
1356                connected,
1357                tool_count,
1358                error,
1359            } => {
1360                let _ = self.tx.send(TuiMessage::McpServerStatus {
1361                    name,
1362                    connected,
1363                    tool_count,
1364                    error,
1365                });
1366                return;
1367            }
1368        };
1369
1370        self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
1371        let _ = self.tx.send(msg);
1372    }
1373}
1374
1375#[cfg(test)]
1376mod inject_model_tests {
1377    use super::*;
1378
1379    /// The abk 0.19 `danger_accept_invalid_certs` field must SURVIVE the
1380    /// model-override provider swap — inject_model clones raw toml tables,
1381    /// so any new provider field rides through untouched. This gate pins
1382    /// the pass-through contract: if a future refactor switches to typed
1383    /// struct rebuilds and drops unknown fields, this fails loudly.
1384    #[test]
1385    fn provider_swap_preserves_danger_accept_invalid_certs() {
1386        let config_toml = r#"
1387[llm.provider]
1388name = "cloud"
1389base_url = "https://api.openai.com/v1"
1390api_key = "${OPENAI_API_KEY}"
1391model = "gpt-4o"
1392
1393[llm.providers.ninfer]
1394name = "ninfer"
1395provider_type = "openai"
1396base_url = "https://10.253.1.9/v1"
1397api_key = "${NINFER_API_KEY}"
1398model = "qwen3.8-27b"
1399models = ["qwen3.8-27b"]
1400danger_accept_invalid_certs = true
1401"#;
1402
1403        // Case 1: model override = provider KEY → whole ninfer entry is
1404        // swapped into llm.provider — the TLS flag must ride along.
1405        let swapped = inject_model(config_toml.to_string(), Some("ninfer".to_string()));
1406        let table = swapped
1407            .parse::<toml::Value>()
1408            .expect("swapped config parses");
1409        let provider = table
1410            .get("llm")
1411            .and_then(|l| l.get("provider"))
1412            .and_then(|p| p.as_table())
1413            .expect("llm.provider present after key swap");
1414        assert_eq!(
1415            provider.get("base_url").and_then(|v| v.as_str()),
1416            Some("https://10.253.1.9/v1"),
1417            "swap selected the ninfer provider"
1418        );
1419        assert_eq!(
1420            provider
1421                .get("danger_accept_invalid_certs")
1422                .and_then(|v| v.as_bool()),
1423            Some(true),
1424            "TLS flag survives the provider swap"
1425        );
1426
1427        // Case 3: literal model string → current provider is edited in
1428        // place; the flag (and everything else) must stay untouched.
1429        let literal = inject_model(config_toml.to_string(), Some("qwen3.8-27b".to_string()));
1430        let table = literal
1431            .parse::<toml::Value>()
1432            .expect("literal config parses");
1433        let providers = table
1434            .get("llm")
1435            .and_then(|l| l.get("providers"))
1436            .and_then(|p| p.get("ninfer"))
1437            .and_then(|p| p.as_table())
1438            .expect("providers table untouched by literal override");
1439        assert_eq!(
1440            providers
1441                .get("danger_accept_invalid_certs")
1442                .and_then(|v| v.as_bool()),
1443            Some(true),
1444            "TLS flag untouched in the providers table on literal override"
1445        );
1446    }
1447}
1448
1449#[cfg(test)]
1450mod compose_task_tests {
1451    use super::*;
1452
1453    fn file(mime: &str, filename: &str, content: &str) -> TextFileAttachment {
1454        TextFileAttachment {
1455            mime: mime.to_string(),
1456            filename: filename.to_string(),
1457            content: content.to_string(),
1458        }
1459    }
1460
1461    #[test]
1462    fn no_files_returns_command_unchanged() {
1463        assert_eq!(
1464            compose_task_with_text_files("do the thing", &[]),
1465            "do the thing"
1466        );
1467    }
1468
1469    #[test]
1470    fn single_file_uses_standard_fence() {
1471        let out = compose_task_with_text_files(
1472            "summarize this",
1473            &[file("text/markdown", "notes.md", "# Hello\n\nworld")],
1474        );
1475        assert!(out.starts_with("summarize this"), "got: {}", out);
1476        assert!(
1477            out.contains("[Attached file: notes.md (text/markdown, 14 bytes)]"),
1478            "got: {}",
1479            out
1480        );
1481        assert!(out.contains("```md\n# Hello\n\nworld\n```"), "got: {}", out);
1482    }
1483
1484    #[test]
1485    fn content_with_fences_grows_the_fence() {
1486        let content = "```rust\nfn main() {}\n```\ndone";
1487        let out = compose_task_with_text_files("check", &[file("text/plain", "code.txt", content)]);
1488        // Content contains 3-backtick runs → fence must be 4 backticks so
1489        // the file content cannot escape its own block.
1490        assert!(out.contains("````txt\n"), "got: {}", out);
1491        assert!(out.ends_with("\n````"), "got: {}", out);
1492    }
1493
1494    #[test]
1495    fn extensionless_filename_falls_back_to_text_info_string() {
1496        let out = compose_task_with_text_files("read", &[file("text/plain", "Makefile", "all:")]);
1497        assert!(out.contains("```text\nall:\n```"), "got: {}", out);
1498    }
1499
1500    #[test]
1501    fn multiple_files_preserve_order() {
1502        let out = compose_task_with_text_files(
1503            "compare",
1504            &[
1505                file("text/plain", "a.txt", "AAA"),
1506                file("text/markdown", "b.md", "BBB"),
1507            ],
1508        );
1509        let a = out.find("[Attached file: a.txt").expect("a header");
1510        let b = out.find("[Attached file: b.md").expect("b header");
1511        assert!(a < b, "files must appear in attachment order");
1512    }
1513
1514    #[test]
1515    fn bytes_in_header_reflect_content_length() {
1516        let content = "héllo"; // 'é' is 2 bytes UTF-8 → 6 bytes total
1517        let out = compose_task_with_text_files("t", &[file("text/plain", "u.txt", content)]);
1518        assert!(out.contains("(text/plain, 6 bytes)"), "got: {}", out);
1519    }
1520}
1521
1522#[cfg(test)]
1523mod compaction_status_tests {
1524    use super::*;
1525
1526    /// abk 0.21.4: the session owns a shared compaction-status handle; the
1527    /// run's RunContext carries a CLONE of the same Arc (threaded in
1528    /// execute_command), so stamps made inside the run are visible to the
1529    /// session — and to trustee-api's live payload — without any polling.
1530    #[test]
1531    fn session_status_handle_is_shared_state() {
1532        let (mut session, _rx) = Session::new();
1533        assert!(!session.compaction_status.lock().unwrap().in_progress);
1534
1535        // Simulate the run-side stamp (what abk's compaction loop does via
1536        // the RunContext handle clone).
1537        let run_side = session.compaction_status.clone();
1538        abk::agent::compaction::stamp_compaction_begin(Some(&run_side), "manual");
1539        assert!(session.compaction_status.lock().unwrap().in_progress);
1540
1541        // The session-owned backstop on workflow end: in_progress clears,
1542        // the durable last trace survives.
1543        abk::agent::compaction::stamp_compaction_end(
1544            Some(&run_side),
1545            Some(&abk::agent::compaction::CompactionOutcome {
1546                tokens_before: 641_717,
1547                tokens_after: 169_970,
1548                summary_chars: 4_200,
1549                reason: "manual".to_string(),
1550            }),
1551        );
1552        {
1553            let s = session.compaction_status.lock().unwrap();
1554            assert!(!s.in_progress);
1555            let last = s.last.clone().expect("last trace recorded");
1556            assert_eq!(last.tokens_after, 169_970);
1557        }
1558
1559        // The defensive clear on WorkflowCompleted/WorkflowError: in_progress
1560        // drops even when the run-side clear never ran (cancel-mid-compaction
1561        // aborts the task), and last is PRESERVED.
1562        abk::agent::compaction::stamp_compaction_begin(Some(&run_side), "auto");
1563        assert!(session.compaction_status.lock().unwrap().in_progress);
1564        session.handle_workflow_message(TuiMessage::WorkflowError("cancelled".to_string()));
1565        let s = session.compaction_status.lock().unwrap();
1566        assert!(
1567            !s.in_progress,
1568            "workflow-end backstop must clear in_progress"
1569        );
1570        assert!(s.last.is_some(), "backstop keeps the durable last trace");
1571    }
1572}