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