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