use crate::agent::runloop::unified::turn::context::{TurnLoopResult, TurnOutcomeContext};
use crate::agent::runloop::unified::turn::turn_loop::TurnLoopOutcome;
use anyhow::Result;
use std::time::Duration;
use vtcode_core::core::agent::blocked_handoff::write_blocked_handoff;
use vtcode_core::core::agent::harness_artifacts::existing_harness_artifact_paths;
use vtcode_core::exec::events::HarnessEventKind;
use vtcode_core::llm::provider as uni;
use vtcode_core::utils::ansi::MessageStyle;
use vtcode_core::utils::session_archive::SessionMessage;
fn format_turn_elapsed_label(duration: Duration) -> String {
let total_seconds = duration.as_secs();
if total_seconds < 60 {
return format!("{total_seconds}s");
}
if total_seconds < 3600 {
let minutes = total_seconds / 60;
let seconds = total_seconds % 60;
return format!("{minutes}m {seconds}s");
}
let hours = total_seconds / 3600;
let minutes = (total_seconds % 3600) / 60;
format!("{hours}h {minutes}m")
}
pub(crate) async fn apply_turn_outcome(
outcome: TurnLoopOutcome,
ctx: TurnOutcomeContext<'_>,
) -> Result<()> {
match outcome.result {
TurnLoopResult::Cancelled => {
if ctx.ctrl_c_state.is_exit_requested() {
*ctx.session_end_reason = vtcode_core::hooks::SessionEndReason::Exit;
return Ok(());
}
ctx.renderer.line_if_not_empty(MessageStyle::Output)?;
ctx.renderer.line(
MessageStyle::Info,
"Interrupted current task. Press Esc, Ctrl+C, or /stop again to exit.",
)?;
ctx.handle.clear_input();
ctx.handle.set_placeholder(Some(
vtcode_config::constants::ui::CHAT_INPUT_PLACEHOLDER_INTERRUPTED.to_owned(),
));
ctx.ctrl_c_state.mark_cancel_handled();
*ctx.session_end_reason = vtcode_core::hooks::SessionEndReason::Cancelled;
Ok(())
}
TurnLoopResult::Exit => {
*ctx.session_end_reason = vtcode_core::hooks::SessionEndReason::Exit;
Ok(())
}
TurnLoopResult::Aborted => {
if let Some(last) = ctx.conversation_history.last() {
match last.role {
uni::MessageRole::Assistant | uni::MessageRole::Tool => {
let _ = ctx.conversation_history.pop();
}
_ => {}
}
}
ctx.ctrl_c_state.reset();
Ok(())
}
TurnLoopResult::Blocked { reason } => {
if let Some(reason) = reason.as_deref() {
let _ = ctx.renderer.line(MessageStyle::Info, reason);
}
match write_blocked_handoff(
ctx.workspace,
ctx.session_id,
"blocked",
reason
.as_deref()
.unwrap_or("Turn blocked due to repeated failing behavior."),
&existing_harness_artifact_paths(ctx.workspace),
) {
Ok(artifacts) => {
for path in [&artifacts.current_path, &artifacts.archive_path] {
let path_text = path.display().to_string();
let _ = ctx
.renderer
.line(MessageStyle::Info, &format!("Blocked handoff: {path_text}"));
if let Some(emitter) = ctx.harness_emitter {
let _ = emitter.emit(
crate::agent::runloop::unified::inline_events::harness::harness_event(
HarnessEventKind::BlockedHandoffWritten,
Some("Blocked handoff written".to_string()),
Some(path_text),
),
);
}
}
}
Err(err) => tracing::warn!("Failed to persist blocked handoff: {}", err),
}
ctx.handle.clear_input();
ctx.handle.set_placeholder(ctx.default_placeholder.clone());
ctx.ctrl_c_state.reset();
Ok(())
}
TurnLoopResult::Completed => {
if let Some(manager) = ctx.checkpoint_manager {
let conversation_snapshot: Vec<SessionMessage> = ctx
.conversation_history
.iter()
.map(SessionMessage::from)
.collect();
let turn_number = *ctx.next_checkpoint_turn;
match manager
.create_snapshot(
turn_number,
ctx.completed_turn_prompt.unwrap_or_default(),
&conversation_snapshot,
&outcome.turn_modified_files,
ctx.completed_turn_prompt,
ctx.completed_turn_prompt_message_index,
)
.await
{
Ok(Some(meta)) => {
*ctx.next_checkpoint_turn = meta.turn_number.saturating_add(1);
}
Ok(None) => {}
Err(err) => tracing::warn!(
"Failed to create checkpoint for turn {}: {}",
turn_number,
err
),
}
}
if ctx.show_turn_timer {
ctx.renderer.line(
MessageStyle::Info,
&format!("Worked for {}", format_turn_elapsed_label(ctx.turn_elapsed)),
)?;
}
ctx.ctrl_c_state.reset();
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::runloop::unified::state::CtrlCState;
use std::collections::BTreeSet;
use std::sync::Arc;
use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel};
use vtcode_core::utils::ansi::AnsiRenderer;
use vtcode_tui::app::{InlineCommand, InlineHandle};
fn renderer_with_channel() -> (InlineHandle, AnsiRenderer, UnboundedReceiver<InlineCommand>) {
let (tx, rx) = unbounded_channel();
let handle = InlineHandle::new_for_tests(tx);
let renderer = AnsiRenderer::with_inline_ui(handle.clone(), Default::default());
(handle, renderer, rx)
}
fn drain_appended_lines(receiver: &mut UnboundedReceiver<InlineCommand>) -> Vec<String> {
let mut lines = Vec::new();
while let Ok(command) = receiver.try_recv() {
if let InlineCommand::AppendLine { segments, .. } = command {
let line = segments
.into_iter()
.map(|segment| segment.text)
.collect::<String>();
if !line.trim().is_empty() {
lines.push(line);
}
}
}
lines
}
#[test]
fn format_turn_elapsed_label_mixed_adaptive() {
assert_eq!(format_turn_elapsed_label(Duration::from_secs(59)), "59s");
assert_eq!(format_turn_elapsed_label(Duration::from_secs(90)), "1m 30s");
assert_eq!(
format_turn_elapsed_label(Duration::from_secs(3600)),
"1h 0m"
);
}
#[tokio::test]
async fn completed_turn_emits_worked_for_divider() {
let (handle, mut renderer, mut receiver) = renderer_with_channel();
let ctrl_c_state = Arc::new(CtrlCState::new());
let default_placeholder = None;
let mut session_end_reason = vtcode_core::hooks::SessionEndReason::Completed;
let mut next_checkpoint_turn = 1usize;
let mut conversation_history = Vec::new();
let outcome = TurnLoopOutcome {
result: TurnLoopResult::Completed,
turn_modified_files: BTreeSet::new(),
};
conversation_history.push(uni::Message::assistant("done".to_string()));
apply_turn_outcome(
outcome,
TurnOutcomeContext {
conversation_history: &mut conversation_history,
completed_turn_prompt: None,
completed_turn_prompt_message_index: None,
renderer: &mut renderer,
handle: &handle,
ctrl_c_state: &ctrl_c_state,
default_placeholder: &default_placeholder,
checkpoint_manager: None,
next_checkpoint_turn: &mut next_checkpoint_turn,
session_end_reason: &mut session_end_reason,
turn_elapsed: Duration::from_secs(90),
show_turn_timer: true,
workspace: std::path::Path::new("."),
session_id: "session-test",
harness_emitter: None,
},
)
.await
.expect("apply completed outcome");
let lines = drain_appended_lines(&mut receiver);
assert!(lines.iter().any(|line| line == "Worked for 1m 30s"));
}
#[tokio::test]
async fn cancelled_turn_does_not_emit_worked_for_divider() {
let (handle, mut renderer, mut receiver) = renderer_with_channel();
let ctrl_c_state = Arc::new(CtrlCState::new());
let default_placeholder = None;
let mut session_end_reason = vtcode_core::hooks::SessionEndReason::Completed;
let mut next_checkpoint_turn = 1usize;
let mut conversation_history = Vec::new();
let outcome = TurnLoopOutcome {
result: TurnLoopResult::Cancelled,
turn_modified_files: BTreeSet::new(),
};
apply_turn_outcome(
outcome,
TurnOutcomeContext {
conversation_history: &mut conversation_history,
completed_turn_prompt: None,
completed_turn_prompt_message_index: None,
renderer: &mut renderer,
handle: &handle,
ctrl_c_state: &ctrl_c_state,
default_placeholder: &default_placeholder,
checkpoint_manager: None,
next_checkpoint_turn: &mut next_checkpoint_turn,
session_end_reason: &mut session_end_reason,
turn_elapsed: Duration::from_secs(90),
show_turn_timer: true,
workspace: std::path::Path::new("."),
session_id: "session-test",
harness_emitter: None,
},
)
.await
.expect("apply cancelled outcome");
let lines = drain_appended_lines(&mut receiver);
assert!(!lines.iter().any(|line| line.contains("Worked for")));
}
#[tokio::test]
async fn completed_turn_skips_timer_when_disabled() {
let (handle, mut renderer, mut receiver) = renderer_with_channel();
let ctrl_c_state = Arc::new(CtrlCState::new());
let default_placeholder = None;
let mut session_end_reason = vtcode_core::hooks::SessionEndReason::Completed;
let mut next_checkpoint_turn = 1usize;
let mut conversation_history = Vec::new();
let outcome = TurnLoopOutcome {
result: TurnLoopResult::Completed,
turn_modified_files: BTreeSet::new(),
};
conversation_history.push(uni::Message::assistant("done".to_string()));
apply_turn_outcome(
outcome,
TurnOutcomeContext {
conversation_history: &mut conversation_history,
completed_turn_prompt: None,
completed_turn_prompt_message_index: None,
renderer: &mut renderer,
handle: &handle,
ctrl_c_state: &ctrl_c_state,
default_placeholder: &default_placeholder,
checkpoint_manager: None,
next_checkpoint_turn: &mut next_checkpoint_turn,
session_end_reason: &mut session_end_reason,
turn_elapsed: Duration::from_secs(90),
show_turn_timer: false,
workspace: std::path::Path::new("."),
session_id: "session-test",
harness_emitter: None,
},
)
.await
.expect("apply completed outcome with timer disabled");
let lines = drain_appended_lines(&mut receiver);
assert!(!lines.iter().any(|line| line.contains("Worked for")));
}
}