1use 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
22pub 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
56pub struct TextFileAttachment {
57 pub mime: String,
59 pub filename: String,
61 pub content: String,
63}
64
65fn 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
82fn fence_for(content: &str) -> String {
85 "`".repeat(3.max(longest_backtick_run(content) + 1))
86}
87
88pub 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
121pub struct Session {
124 pub input: String,
126 pub input_images: Vec<umf::chatml::ImageAttachment>,
130 pub input_text_files: Vec<TextFileAttachment>,
135 pub output_lines: Vec<String>,
137 pub workflow_tx: mpsc::UnboundedSender<TuiMessage>,
139 pub workflow_state: WorkflowState,
141 pub config_toml: Option<String>,
143 pub secrets: Option<HashMap<String, String>>,
145 pub build_info: Option<BuildInfo>,
147 pub resume_info: Option<ResumeInfo>,
149 pub backup_resume_info: Option<ResumeInfo>,
152 pub todo_lines: Vec<String>,
154 pub cancel_token: CancellationToken,
156 pub pending_command: Option<String>,
158 pub handoff_pending: bool,
160 pub pending_tool_lines: Vec<(String, usize, Option<String>)>,
162 pub current_context_tokens: usize,
164 pub auto_handoff: AutoHandoffConfig,
166 pub mcp_servers: Vec<McpServerInfo>,
168 pub should_quit: bool,
170 pub auto_scroll: bool,
172
173 pub agent_name: String,
177 pub token_store: Option<Arc<dyn pep::token_store::TokenStore>>,
180
181 #[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 pub project_id: Option<String>,
193 pub project_name: Option<String>,
195 pub session_id: Option<String>,
197 pub session_name: Option<String>,
199 pub home_dir: Option<std::path::PathBuf>,
202
203 pub workflow_permit: Option<tokio::sync::OwnedSemaphorePermit>,
208
209 pub identity: Option<String>,
214
215 pub model: Option<String>,
221
222 pub rotating_from_handoff: bool,
228 pub briefing_born: bool,
233 pub handoff_count: u32,
237
238 pub compaction_status: abk::resume::SharedCompactionStatus,
248}
249
250impl Session {
251 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 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 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 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 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 self.resume_info = info;
411 self.backup_resume_info = None;
412 }
413 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 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 && !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 let old_session_id = self.session_id.clone();
483 self.session_id = None;
486 self.session_name = None;
487 self.rotating_from_handoff = true;
492 self.briefing_born = true;
493 self.handoff_count += 1;
494 self.input = briefing;
495 self.execute_command();
496 let _ = self.workflow_tx.send(TuiMessage::SessionRotated {
500 old: old_session_id,
501 new: self.session_id.clone(),
502 });
503 }
504 TuiMessage::SessionRotated { .. } => {
505 }
509 TuiMessage::HandoffFailed => {
510 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 self.session_name = Some(title);
522 }
523 }
524 if self.auto_scroll {
525 }
528 }
529
530 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 let rotating_from_handoff = self.rotating_from_handoff;
551 self.rotating_from_handoff = false;
552
553 if !rotating_from_handoff {
557 self.briefing_born = false;
558 }
559
560if !is_continuation && !rotating_from_handoff && self.handoff_count == 0 {
567 self.output_lines.clear();
568 }
569 if !is_continuation {
573 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 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 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 let config_toml = inject_identity(config_toml, &self.identity);
620
621 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 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 let mut run_ctx = RunContext::new()
665 .with_agent_name(agent_name.clone());
666
667 if let Some(ref dir) = home_dir {
669 run_ctx = run_ctx.with_home_dir(dir.clone());
670 }
671
672 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 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 #[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 run_ctx = run_ctx.with_compaction_status(compaction_status);
708
709 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 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: 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 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 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 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; }
792
793 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
795
796 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 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; }
821 }
822 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 pub fn request_handoff(&mut self, hint: String) {
846 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 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 pub fn trigger_handoff(&mut self, hint: String) {
887 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 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 let config_toml = inject_identity(config_toml, &self.identity);
918
919 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 let backup_resume_info = self.resume_info.clone();
934 let resume_info = match self.resume_info.take() {
935 Some(ri) => ri,
936 None => {
937 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 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 let mut run_ctx = RunContext::new()
978 .with_agent_name(agent_name.clone());
979
980 if let Some(ref dir) = home_dir {
982 run_ctx = run_ctx.with_home_dir(dir.clone());
983 }
984
985 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 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 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 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 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
1047fn 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 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
1089fn 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 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 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 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 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 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}
1183pub struct TuiForwardSink {
1188 tx: mpsc::UnboundedSender<TuiMessage>,
1189 stream_state: AtomicU8,
1190}
1191
1192const 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 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 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 #[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 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 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 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"; 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 #[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 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 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 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}