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