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, CapturedText, HandoffCaptureSink, McpServerInfo, McpServerStatus,
19 TuiMessage, WorkflowState,
20};
21
22pub struct Session {
27 pub input: String,
29 pub output_lines: Vec<String>,
31 pub workflow_tx: mpsc::UnboundedSender<TuiMessage>,
33 pub workflow_state: WorkflowState,
35 pub config_toml: Option<String>,
37 pub secrets: Option<HashMap<String, String>>,
39 pub build_info: Option<BuildInfo>,
41 pub resume_info: Option<ResumeInfo>,
43 pub backup_resume_info: Option<ResumeInfo>,
46 pub todo_lines: Vec<String>,
48 pub cancel_token: CancellationToken,
50 pub pending_command: Option<String>,
52 pub handoff_pending: bool,
54 pub pending_tool_lines: Vec<(String, usize, Option<String>)>,
56 pub current_context_tokens: usize,
58 pub auto_handoff: AutoHandoffConfig,
60 pub mcp_servers: Vec<McpServerInfo>,
62 pub should_quit: bool,
64 pub auto_scroll: bool,
66
67 pub agent_name: String,
71 pub token_store: Option<Arc<dyn pep::token_store::TokenStore>>,
74
75 pub project_id: Option<String>,
78 pub project_name: Option<String>,
80 pub session_id: Option<String>,
82 pub session_name: Option<String>,
84
85 pub workflow_permit: Option<tokio::sync::OwnedSemaphorePermit>,
90}
91
92impl Session {
93 pub fn new() -> (Self, mpsc::UnboundedReceiver<TuiMessage>) {
98 let (workflow_tx, workflow_rx) = mpsc::unbounded_channel();
99 let session = Self {
100 input: String::new(),
101 output_lines: Vec::new(),
102 workflow_tx,
103 workflow_state: WorkflowState::Idle,
104 config_toml: None,
105 secrets: None,
106 build_info: None,
107 resume_info: None,
108 backup_resume_info: None,
109 todo_lines: Vec::new(),
110 cancel_token: CancellationToken::new(),
111 pending_command: None,
112 handoff_pending: false,
113 pending_tool_lines: Vec::new(),
114 current_context_tokens: 0,
115 auto_handoff: AutoHandoffConfig::default(),
116 mcp_servers: Vec::new(),
117 should_quit: false,
118 auto_scroll: true,
119 agent_name: "trustee".to_string(),
120 token_store: None,
121 project_id: None,
122 project_name: None,
123 session_id: None,
124 session_name: None,
125 workflow_permit: None,
126 };
127 (session, workflow_rx)
128 }
129
130 pub fn parse_auto_handoff_config(&mut self) {
132 if let Some(ref config_toml) = self.config_toml {
133 self.auto_handoff = crate::config::parse_auto_handoff_config(config_toml);
134 }
135 }
136
137 pub fn handle_workflow_message(&mut self, msg: TuiMessage) {
142 match msg {
143 TuiMessage::WorkflowCancelled => {
144 self.output_lines.push("⏹ Workflow cancelled".to_string());
145 self.output_lines.push("".to_string());
146 self.workflow_state = WorkflowState::Cancelling;
147 }
148 TuiMessage::OutputLine(line) => {
149 self.output_lines.push(line);
150 }
151 TuiMessage::StreamDelta(delta) => {
152 if let Some(last) = self.output_lines.last_mut() {
153 last.push_str(&delta);
154 } else {
155 self.output_lines.push(delta);
156 }
157 }
158 TuiMessage::ReasoningDelta(delta) => {
159 if let Some(last) = self.output_lines.last_mut() {
160 if !last.starts_with('\x01') {
161 last.insert(0, '\x01');
162 }
163 last.push_str(&delta);
164 } else {
165 self.output_lines.push(format!("\x01{}", delta));
166 }
167 }
168 TuiMessage::WorkflowCompleted => {
169 self.output_lines.push("✓ Workflow completed".to_string());
170 self.output_lines.push("".to_string());
171 if self.workflow_state == WorkflowState::Running {
172 self.workflow_state = WorkflowState::Cancelling;
173 }
174 }
175 TuiMessage::WorkflowError(err) => {
176 self.output_lines.push(format!("✗ Error: {}", err));
177 self.output_lines.push("".to_string());
178 if self.workflow_state == WorkflowState::Running {
179 self.workflow_state = WorkflowState::Cancelling;
180 }
181 }
182 TuiMessage::TodoUpdate(content) => {
183 self.todo_lines = content.lines().map(|l| l.to_string()).collect();
184 }
185 TuiMessage::ToolPending { tool_name, hint } => {
186 let label = match &hint {
187 Some(h) => format!("⠋ {} {}", tool_name, h),
188 None => format!("⠋ {}", tool_name),
189 };
190 let idx = self.output_lines.len();
191 self.output_lines.push(label);
192 self.pending_tool_lines.push((tool_name, idx, hint));
193 }
194 TuiMessage::ToolDone { tool_name, success, hint } => {
195 let status = if success { "✓" } else { "✗" };
196 if let Some(pos) = self.pending_tool_lines.iter().position(|(n, _, _)| *n == tool_name) {
197 let (_, idx, pending_hint) = self.pending_tool_lines.remove(pos);
198 let h = hint.or(pending_hint);
199 let label = match &h {
200 Some(h) => format!("{} {} {}", status, tool_name, h),
201 None => format!("{} {}", status, tool_name),
202 };
203 if idx < self.output_lines.len() {
204 self.output_lines[idx] = label;
205 return;
206 }
207 self.output_lines.push(label);
208 } else {
209 let label = match &hint {
210 Some(h) => format!("{} {} {}", status, tool_name, h),
211 None => format!("{} {}", status, tool_name),
212 };
213 self.output_lines.push(label);
214 }
215 }
216 TuiMessage::ResumeInfo(info) => {
217 if self.workflow_state == WorkflowState::Cancelling && info.is_none() {
218 self.resume_info = self.backup_resume_info.take();
219 } else if info.is_some() {
220 self.resume_info = info;
226 self.backup_resume_info = None;
227 }
228 if let Some(ref ri) = self.resume_info {
234 if self.session_id.is_none() {
235 self.session_id = Some(ri.session_id.clone());
236 }
237 }
238
239 if self.workflow_state == WorkflowState::Cancelling {
240 self.workflow_state = WorkflowState::Idle;
241 self.workflow_permit = None;
243 }
244 if self.resume_info.is_some() {
245 if std::env::var("RUST_LOG")
246 .map(|v| v.to_lowercase().contains("debug"))
247 .unwrap_or(false)
248 {
249 self.output_lines.push("🔄 Session preserved — next command will continue this session".to_string());
250 }
251 }
252 if self.workflow_state == WorkflowState::Idle && self.handoff_pending {
253 self.handoff_pending = false;
254 self.trigger_handoff(String::new());
255 } else if let Some(cmd) = self.pending_command.take() {
256 self.input = cmd;
257 self.execute_command();
258 }
259 }
260 TuiMessage::ContextTokensUpdated(count) => {
261 self.current_context_tokens = count;
262 if self.auto_handoff.enabled
263 && count >= self.auto_handoff.context_threshold
264 && self.workflow_state == WorkflowState::Running
265 && !self.handoff_pending
266 && self.resume_info.is_some()
267 {
268 self.handoff_pending = true;
269 self.cancel_token.cancel();
270 self.workflow_state = WorkflowState::Cancelling;
271 self.output_lines.push(format!(
272 "🔄 Auto-handoff: cancelling workflow, context tokens ({}) ≥ threshold ({})",
273 count, self.auto_handoff.context_threshold
274 ));
275 }
276 }
277 TuiMessage::McpServerStatus { name, connected, tool_count, error } => {
278 let status = if connected { McpServerStatus::Connected } else { McpServerStatus::Failed };
279 if let Some(existing) = self.mcp_servers.iter_mut().find(|s| s.name == name) {
280 existing.status = status;
281 existing.tool_count = tool_count;
282 existing.error = error;
283 } else {
284 self.mcp_servers.push(McpServerInfo { name, status, tool_count, error });
285 }
286 }
287 TuiMessage::HandoffReady(briefing) => {
288 self.workflow_state = WorkflowState::Idle;
289 self.resume_info = None;
290 self.input = briefing;
291 self.execute_command();
292 }
293 }
294 if self.auto_scroll {
295 }
298 }
299
300 pub fn execute_command(&mut self) {
305 let command = self.input.trim().to_string();
306
307 if self.workflow_state != WorkflowState::Idle {
308 self.pending_command = Some(command);
309 self.output_lines.push("⏳ Previous workflow finishing — command queued".to_string());
310 self.input.clear();
311 return;
312 }
313
314 let is_continuation = self.resume_info.is_some();
315
316 if !is_continuation {
317 self.output_lines.clear();
318 if self.session_id.is_none() {
324 let timestamp = chrono::Utc::now().format("%Y_%m_%d_%H_%M");
325 let slug = command
326 .chars()
327 .take(30)
328 .filter(|c| c.is_alphanumeric() || *c == ' ')
329 .collect::<String>()
330 .split_whitespace()
331 .take(3)
332 .collect::<Vec<&str>>()
333 .join("_")
334 .to_lowercase();
335 self.session_id = Some(format!("session_{}_{}", timestamp, slug));
336 }
337 if self.session_name.is_none() {
338 let derived = if command.len() > 80 {
339 format!("{}...", &command[..77])
340 } else {
341 command.clone()
342 };
343 self.session_name = Some(derived);
344 }
345 }
346
347 self.output_lines.push(format!("> {}", command));
348
349 let config_toml = match &self.config_toml {
350 Some(c) => c.clone(),
351 None => {
352 self.output_lines.push("✗ Error: Configuration not loaded".to_string());
353 self.output_lines.push("".to_string());
354 return;
355 }
356 };
357
358 let secrets = self.secrets.clone().unwrap_or_default();
359 let build_info = self.build_info.clone();
360 let tx = self.workflow_tx.clone();
361
362 let agent_name = self.agent_name.clone();
363 let token_store = self.token_store.clone();
364 let project_id = self.project_id.clone();
365 let project_name = self.project_name.clone();
366 let session_id = self.session_id.clone();
367 let session_name = self.session_name.clone();
368
369 self.backup_resume_info = self.resume_info.clone();
370 let resume_info = self.resume_info.take();
371
372 self.workflow_state = WorkflowState::Running;
373 self.auto_scroll = true;
374
375 self.cancel_token = CancellationToken::new();
376 let child_token = self.cancel_token.clone();
377
378 let (resume_tx, mut resume_rx) = mpsc::unbounded_channel();
379
380 let resume_forward_tx = tx.clone();
381 tokio::spawn(async move {
382 while let Some(info) = resume_rx.recv().await {
383 resume_forward_tx.send(TuiMessage::ResumeInfo(info)).ok();
384 }
385 });
386
387 tokio::spawn(async move {
388 let tui_sink: abk::orchestration::output::SharedSink =
389 Arc::new(crate::session::TuiForwardSink::new(tx.clone()));
390
391 let mut run_ctx = RunContext::new()
393 .with_agent_name(agent_name.clone());
394
395 if project_id.is_some() || project_name.is_some() {
397 run_ctx = run_ctx.with_project(abk::context::ProjectIdentity {
398 id: project_id.unwrap_or_else(|| "default".to_string()),
399 name: project_name,
400 });
401 }
402
403 if session_id.is_some() || session_name.is_some() {
405 run_ctx = run_ctx.with_session(abk::context::SessionIdentity {
406 id: session_id.unwrap_or_else(|| "default".to_string()),
407 name: session_name,
408 });
409 }
410
411 #[cfg(feature = "registry-mcp-token")]
412 {
413 if let Some(ref ts) = token_store {
414 run_ctx = run_ctx.with_token_store(ts.clone());
415 }
416 }
417
418 let result = abk::observability::with_tui_mode(true, async {
422 abk::cli::run_task_from_raw_config(
423 &config_toml,
424 secrets,
425 build_info,
426 &command,
427 Some(tui_sink),
428 resume_info,
429 Some(resume_tx),
430 Some(child_token),
431 Some(&run_ctx),
432 )
433 .await
434 })
435 .await;
436
437 let task_result = result.unwrap_or_else(|e| abk::cli::TaskResult {
438 success: false,
439 error: Some(e.to_string()),
440 resume_info: None,
445 });
446
447 let msg = if task_result.success {
448 TuiMessage::WorkflowCompleted
449 } else {
450 TuiMessage::WorkflowError(task_result.error.unwrap_or_default())
451 };
452 tx.send(msg).ok();
453 tx.send(TuiMessage::ResumeInfo(task_result.resume_info)).ok();
454 });
455
456 self.input.clear();
457 }
458
459 pub fn trigger_handoff(&mut self, hint: String) {
464 if self.resume_info.is_none() {
465 self.output_lines.push("ℹ Nothing to hand off — run a task first".to_string());
466 return;
467 }
468
469 let config_toml = match &self.config_toml {
470 Some(c) => c.clone(),
471 None => {
472 self.output_lines.push("✗ Error: Configuration not loaded".to_string());
473 return;
474 }
475 };
476
477 let secrets = self.secrets.clone().unwrap_or_default();
478 let build_info = self.build_info.clone();
479 let tx = self.workflow_tx.clone();
480
481 let agent_name = self.agent_name.clone();
482 let token_store = self.token_store.clone();
483
484 let resume_info = self.resume_info.take();
485
486 self.workflow_state = WorkflowState::Running;
487 self.auto_scroll = true;
488 self.cancel_token = CancellationToken::new();
489 let child_token = self.cancel_token.clone();
490
491 self.output_lines.push("🔀 Generating session handoff briefing...".to_string());
492
493 tokio::spawn(async move {
494 let (cap_tx, mut cap_rx) = mpsc::unbounded_channel::<CapturedText>();
495 let cap_sink: abk::orchestration::output::SharedSink =
496 Arc::new(HandoffCaptureSink::new(cap_tx, child_token.clone()));
497
498 let base = "Output a session handoff briefing in at most 300 lines. \
499 Do NOT use any tools. Include: the FULL ABSOLUTE PATH of every \
500 project/repository being worked on (e.g. /Projects/Foo/bar — never \
501 omit the leading path), all project/task/workstream UUIDs referenced, \
502 every file created or modified with its full absolute path, all \
503 commands run and their outcomes, the current state of the work, any \
504 blockers, and the exact next action to take. \
505 Output ONLY the briefing text — no preamble, headers, or closing remarks.";
506 let prompt = if hint.is_empty() {
507 base.to_string()
508 } else {
509 format!("{base}\n\nIn the briefing also consider: {hint}")
510 };
511
512 let (dummy_tx, _dummy_rx) = mpsc::unbounded_channel();
513
514 let run_ctx = RunContext::new()
516 .with_agent_name(agent_name.clone());
517 #[cfg(feature = "registry-mcp-token")]
518 {
519 }
522
523 let _res = abk::observability::with_tui_mode(true, async {
525 abk::cli::run_task_from_raw_config(
526 &config_toml,
527 secrets,
528 build_info,
529 &prompt,
530 Some(cap_sink),
531 resume_info,
532 Some(dummy_tx),
533 Some(child_token),
534 Some(&run_ctx),
535 )
536 .await
537 })
538 .await;
539
540 let mut text_parts = String::new();
541 let mut reasoning_parts = String::new();
542 while let Ok(captured) = cap_rx.try_recv() {
543 match captured {
544 CapturedText::Text(s) => text_parts.push_str(&s),
545 CapturedText::Reasoning(s) => reasoning_parts.push_str(&s),
546 }
547 }
548
549 let briefing = if !text_parts.trim().is_empty() {
550 text_parts.trim().to_string()
551 } else if !reasoning_parts.trim().is_empty() {
552 reasoning_parts.trim().to_string()
553 } else {
554 "Session handoff: briefing unavailable — continue from previous context.".to_string()
555 };
556
557 tx.send(TuiMessage::HandoffReady(briefing)).ok();
558 });
559 }
560}
561
562impl Default for Session {
563 fn default() -> Self {
564 Self::new().0
565 }
566}
567
568pub struct TuiForwardSink {
574 tx: mpsc::UnboundedSender<TuiMessage>,
575 stream_state: AtomicU8,
576}
577
578const STREAM_IDLE: u8 = 0;
580const STREAM_REASONING: u8 = 1;
581const STREAM_CONTENT: u8 = 2;
582
583impl TuiForwardSink {
584 pub fn new(tx: mpsc::UnboundedSender<TuiMessage>) -> Self {
585 Self {
586 tx,
587 stream_state: AtomicU8::new(STREAM_IDLE),
588 }
589 }
590}
591
592impl abk::orchestration::output::OutputSink for TuiForwardSink {
593 fn emit(&self, event: abk::orchestration::output::OutputEvent) {
594 use abk::orchestration::output::OutputEvent;
595
596 let msg = match event {
597 OutputEvent::StreamingChunk { delta } => {
598 if delta.is_empty() {
599 return;
600 }
601 let prev = self.stream_state.swap(STREAM_CONTENT, Ordering::Relaxed);
602 if prev != STREAM_CONTENT {
603 let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
604 }
605 let _ = self.tx.send(TuiMessage::StreamDelta(delta));
606 return;
607 }
608
609 OutputEvent::LlmResponse { text, model } => {
610 TuiMessage::OutputLine(format!("[{}] {}", model, text))
611 }
612
613 OutputEvent::Info { message } => {
614 if message.contains("API call completed successfully") {
616 return;
617 }
618 TuiMessage::OutputLine(message)
619 }
620
621 OutputEvent::WorkflowStarted { task_description } => {
622 TuiMessage::OutputLine(format!("🚀 Workflow started: {}", task_description))
623 }
624
625 OutputEvent::WorkflowCompleted { reason, iterations } => {
626 TuiMessage::OutputLine(format!(
627 "✅ Workflow completed after {} iterations: {}",
628 iterations, reason
629 ))
630 }
631
632 OutputEvent::IterationStarted { iteration, context_tokens } => {
633 let _ = self.tx.send(TuiMessage::ContextTokensUpdated(context_tokens));
634 TuiMessage::OutputLine(format!(
635 "📡 Iteration {} | Context = {} tokens",
636 iteration, context_tokens
637 ))
638 }
639
640 OutputEvent::ApiCallStarted {
641 call_number,
642 model,
643 tool_count,
644 streaming,
645 context_tokens,
646 tool_tokens,
647 } => {
648 let mode = if streaming { "Streaming" } else { "Non-streaming" };
649 let total = context_tokens + tool_tokens;
650 let _ = self.tx.send(TuiMessage::ContextTokensUpdated(total));
651 let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
653 TuiMessage::OutputLine(format!(
654 "🔥 API Call {} | Ctx={}({}+{}) | {} | Model: {} | Tools: {}",
655 call_number, total, context_tokens, tool_tokens, mode, model, tool_count
656 ))
657 }
658
659 OutputEvent::ToolsExecuting { tool_names, hints } => {
660 for (name, hint) in tool_names.into_iter().zip(hints.into_iter()) {
661 let _ = self.tx.send(TuiMessage::ToolPending { tool_name: name, hint });
662 }
663 self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
664 return;
665 }
666
667 OutputEvent::ToolCompleted {
668 tool_name,
669 success,
670 content,
671 description,
672 } => {
673 if tool_name == "todowrite" && success {
674 let _ = self.tx.send(TuiMessage::TodoUpdate(content.clone()));
675 }
676 let hint = description;
677 let _ = self.tx.send(TuiMessage::ToolDone { tool_name, success, hint });
678 self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
679 return;
680 }
681
682 OutputEvent::Error { message, context } => {
683 if let Some(ctx) = context {
684 TuiMessage::OutputLine(format!("❌ Error: {} — {}", message, ctx))
685 } else {
686 TuiMessage::OutputLine(format!("❌ Error: {}", message))
687 }
688 }
689
690 OutputEvent::ReasoningChunk { delta } => {
691 if delta.is_empty() {
692 return;
693 }
694 let prev = self.stream_state.swap(STREAM_REASONING, Ordering::Relaxed);
695 if prev != STREAM_REASONING {
696 let _ = self.tx.send(TuiMessage::OutputLine(String::new()));
697 }
698 let _ = self.tx.send(TuiMessage::ReasoningDelta(delta));
699 return;
700 }
701
702 OutputEvent::McpServerStatus { name, connected, tool_count, error } => {
703 let _ = self.tx.send(TuiMessage::McpServerStatus {
704 name,
705 connected,
706 tool_count,
707 error,
708 });
709 return;
710 }
711 };
712
713 self.stream_state.store(STREAM_IDLE, Ordering::Relaxed);
714 let _ = self.tx.send(msg);
715 }
716}