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