use crate::agent::*;
impl Agent {
#[allow(clippy::too_many_arguments)]
pub(super) async fn maybe_handle_stop_command(
&self,
session_id: &str,
user_text: &str,
user_role: UserRole,
channel_ctx: &ChannelContext,
status_tx: Option<mpsc::Sender<StatusUpdate>>,
task_id: &str,
emitter: &crate::events::EventEmitter,
) -> anyhow::Result<Option<String>> {
let lower_trimmed = user_text.trim().to_ascii_lowercase();
let is_stop_command = matches!(lower_trimmed.as_str(), "stop" | "cancel" | "abort");
if !is_stop_command {
return Ok(None);
}
let early_task_start = Instant::now();
if user_role != UserRole::Owner {
let reply = "Only the owner can cancel running work in this session.";
let reply = self
.emit_bootstrap_direct_reply(emitter, task_id, session_id, early_task_start, reply)
.await?;
return Ok(Some(reply));
}
let cancel_result = self
.execute_tool_with_watchdog(
"cli_agent",
r#"{"action": "cancel_all"}"#,
&tool_exec::ToolExecCtx {
session_id,
task_id: Some(task_id),
status_tx,
channel_visibility: channel_ctx.visibility,
channel_id: channel_ctx.channel_id.as_deref(),
project_scope: None,
trusted: channel_ctx.trusted,
user_role,
},
)
.await;
let cli_cancel_msg = cancel_result.ok();
let cancelled_goals = self.cancel_active_goals_for_session(session_id).await;
let cli_cancelled_any = cli_cancel_msg
.as_deref()
.is_some_and(|m| !m.contains("No running CLI agents"));
let reply = if cli_cancelled_any || !cancelled_goals.is_empty() {
let mut reply = String::new();
if cli_cancelled_any {
reply.push_str(cli_cancel_msg.as_deref().unwrap_or_default());
}
if !cancelled_goals.is_empty() {
if !reply.is_empty() {
reply.push('\n');
reply.push('\n');
}
if cancelled_goals.len() == 1 {
reply.push_str(&format!("cancelled goal: {}", cancelled_goals[0]));
} else {
reply.push_str(&format!(
"cancelled {} goals:\n{}",
cancelled_goals.len(),
cancelled_goals
.iter()
.map(|d| format!("- {}", d))
.collect::<Vec<_>>()
.join("\n")
));
}
}
info!(session_id, "Cancelled work on stop command");
reply
} else {
"No running task to cancel.".to_string()
};
let reply = self
.emit_bootstrap_direct_reply(emitter, task_id, session_id, early_task_start, &reply)
.await?;
Ok(Some(reply))
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn maybe_cancel_work_for_mid_task_pivot(
&self,
session_id: &str,
user_text: &str,
user_role: UserRole,
channel_ctx: &ChannelContext,
status_tx: Option<mpsc::Sender<StatusUpdate>>,
task_id: &str,
) {
if self.depth != 0 || user_role != UserRole::Owner || !looks_like_mid_task_pivot(user_text)
{
return;
}
let cancel_result = self
.execute_tool_with_watchdog(
"cli_agent",
r#"{"action": "cancel_all"}"#,
&tool_exec::ToolExecCtx {
session_id,
task_id: Some(task_id),
status_tx,
channel_visibility: channel_ctx.visibility,
channel_id: channel_ctx.channel_id.as_deref(),
project_scope: None,
trusted: channel_ctx.trusted,
user_role,
},
)
.await;
let cli_cancel_msg = match cancel_result {
Ok(msg) => Some(msg),
Err(e) => {
warn!(
session_id,
task_id = %task_id,
error = %e,
"Failed to cancel in-flight cli_agent work during pivot"
);
None
}
};
let cancelled_goals = self.cancel_active_goals_for_session(session_id).await;
let cli_cancelled_any = cli_cancel_msg
.as_deref()
.is_some_and(|m| !m.contains("No running CLI agents"));
if cli_cancelled_any || !cancelled_goals.is_empty() {
info!(
session_id,
task_id = %task_id,
cli_cancelled_any,
cancelled_goals = cancelled_goals.len(),
"Detected mid-task pivot; cancelled in-flight session work"
);
}
}
pub(super) async fn maybe_handle_pending_goal_confirmation(
&self,
session_id: &str,
user_text: &str,
user_role: UserRole,
task_id: &str,
emitter: &crate::events::EventEmitter,
) -> anyhow::Result<Option<String>> {
let early_task_start = Instant::now();
let pending_goals = self
.state
.get_pending_confirmation_goals(session_id)
.await
.unwrap_or_default();
if pending_goals.is_empty() {
return Ok(None);
}
if user_role == UserRole::Owner {
let lower_trimmed = user_text.trim().to_lowercase();
let is_confirm = ["confirm", "yes", "go ahead", "schedule it", "do it"]
.iter()
.any(|kw| contains_keyword_as_words(&lower_trimmed, kw));
let is_reject = ["no", "cancel", "never mind", "nevermind"]
.iter()
.any(|kw| contains_keyword_as_words(&lower_trimmed, kw));
if is_confirm {
let mut activated = Vec::new();
let mut activation_errors = Vec::new();
let tz_label = crate::cron_utils::system_timezone_display();
for goal in &pending_goals {
match self.state.activate_goal(&goal.id).await {
Ok(true) => {
if let Some(ref registry) = self.goal_token_registry {
registry.register(&goal.id).await;
}
let schedules = self
.state
.get_schedules_for_goal(&goal.id)
.await
.unwrap_or_default();
let next_run = schedules
.iter()
.filter_map(|s| {
chrono::DateTime::parse_from_rfc3339(&s.next_run_at).ok()
})
.min_by_key(|dt| dt.timestamp())
.map(|dt| {
dt.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M %Z")
.to_string()
})
.unwrap_or_else(|| "unscheduled".to_string());
activated.push(format!("{} (next: {})", goal.description, next_run));
}
Ok(false) => {}
Err(e) => activation_errors.push(e.to_string()),
}
}
let msg = if !activated.is_empty() && activation_errors.is_empty() {
if activated.len() == 1 {
format!(
"Scheduled: {}. I'll execute it when the time comes. System timezone: {}.",
activated[0], tz_label
)
} else {
format!(
"Scheduled {} goals:\n- {}\nSystem timezone: {}.",
activated.len(),
activated.join("\n- "),
tz_label
)
}
} else if !activated.is_empty() {
format!(
"Scheduled {} goals:\n- {}\nBut {} could not be activated: {}",
activated.len(),
activated.join("\n- "),
activation_errors.len(),
activation_errors.join("; ")
)
} else {
format!(
"I couldn't activate scheduled goals: {}",
activation_errors.join("; ")
)
};
let msg = self
.emit_bootstrap_direct_reply(
emitter,
task_id,
session_id,
early_task_start,
&msg,
)
.await?;
return Ok(Some(msg));
}
if is_reject {
let mut cancelled = 0usize;
for goal in &pending_goals {
let mut updated = goal.clone();
updated.status = "cancelled".to_string();
updated.completed_at = Some(chrono::Utc::now().to_rfc3339());
updated.updated_at = chrono::Utc::now().to_rfc3339();
if self.state.update_goal(&updated).await.is_ok() {
cancelled += 1;
}
if let Ok(schedules) = self.state.get_schedules_for_goal(&updated.id).await {
for s in &schedules {
let _ = self.state.delete_goal_schedule(&s.id).await;
}
}
}
let msg = if cancelled == 1 {
"OK, cancelled the scheduled goal.".to_string()
} else {
format!("OK, cancelled {} scheduled goals.", cancelled)
};
let msg = self
.emit_bootstrap_direct_reply(
emitter,
task_id,
session_id,
early_task_start,
&msg,
)
.await?;
return Ok(Some(msg));
}
for goal in &pending_goals {
let mut updated = goal.clone();
updated.status = "cancelled".to_string();
updated.completed_at = Some(chrono::Utc::now().to_rfc3339());
updated.updated_at = chrono::Utc::now().to_rfc3339();
let _ = self.state.update_goal(&updated).await;
if let Ok(schedules) = self.state.get_schedules_for_goal(&updated.id).await {
for s in &schedules {
let _ = self.state.delete_goal_schedule(&s.id).await;
}
}
}
return Ok(None);
}
let lower_trimmed = user_text.trim().to_lowercase();
let is_confirm_or_reject = [
"confirm",
"yes",
"go ahead",
"schedule it",
"do it",
"no",
"cancel",
"never mind",
"nevermind",
]
.iter()
.any(|kw| contains_keyword_as_words(&lower_trimmed, kw));
if is_confirm_or_reject {
let msg = "Only the owner can confirm or cancel scheduled goals.";
let msg = self
.emit_bootstrap_direct_reply(emitter, task_id, session_id, early_task_start, msg)
.await?;
return Ok(Some(msg));
}
Ok(None)
}
pub(super) async fn maybe_handle_non_resolving_confirmation_shortcut(
&self,
session_id: &str,
user_text: &str,
task_id: &str,
emitter: &crate::events::EventEmitter,
) -> anyhow::Result<Option<String>> {
if self.depth != 0 || !is_bare_confirmation(user_text) {
return Ok(None);
}
let history = self
.state
.get_history(session_id, 12)
.await
.unwrap_or_default();
let prev_assistant = history
.iter()
.rev()
.find(|msg| msg.role == "assistant")
.and_then(|msg| msg.content.as_deref());
let Some(prev_assistant) = prev_assistant else {
return Ok(None);
};
if !assistant_question_requires_specific_answer(prev_assistant) {
return Ok(None);
}
let reply = build_specific_answer_request(prev_assistant);
let reply = self
.emit_bootstrap_direct_reply(emitter, task_id, session_id, Instant::now(), &reply)
.await?;
Ok(Some(reply))
}
pub(super) async fn maybe_handle_trivial_ack_shortcut(
&self,
session_id: &str,
user_text: &str,
task_id: &str,
emitter: &crate::events::EventEmitter,
) -> anyhow::Result<Option<String>> {
if self.depth != 0 {
return Ok(None);
}
let trimmed = user_text.trim();
let normalized = trimmed
.trim_matches(|c: char| c.is_ascii_punctuation() || c.is_whitespace())
.to_ascii_lowercase();
let is_thanks = matches!(normalized.as_str(), "thanks" | "thank you" | "thx");
let is_ok = matches!(normalized.as_str(), "ok" | "okay");
let is_single_emoji_reaction = {
let char_count = trimmed.chars().count();
char_count > 0
&& char_count <= 4
&& !trimmed.is_ascii()
&& trimmed
.chars()
.all(|c| !c.is_ascii_alphanumeric() && !c.is_ascii_whitespace())
};
let ok_is_safe_to_short_circuit = if is_ok {
let history = self
.state
.get_history(session_id, 12)
.await
.unwrap_or_default();
let last_assistant = history.iter().rev().find(|m| m.role == "assistant");
!last_assistant
.and_then(|m| m.content.as_deref())
.is_some_and(|c| c.contains('?'))
} else {
true
};
let trivial_reply = if is_thanks {
Some("You're welcome.".to_string())
} else if is_single_emoji_reaction || (is_ok && ok_is_safe_to_short_circuit) {
Some("Got it.".to_string())
} else {
None
};
if let Some(reply) = trivial_reply {
let reply = self
.emit_bootstrap_direct_reply(emitter, task_id, session_id, Instant::now(), &reply)
.await?;
return Ok(Some(reply));
}
Ok(None)
}
pub(super) async fn maybe_handle_time_query_shortcut(
&self,
session_id: &str,
user_text: &str,
task_id: &str,
emitter: &crate::events::EventEmitter,
) -> anyhow::Result<Option<String>> {
if self.depth != 0 {
return Ok(None);
}
let trimmed = user_text.trim();
let normalized = trimmed
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c.is_whitespace() {
c.to_ascii_lowercase()
} else {
' '
}
})
.collect::<String>();
let normalized = normalized.split_whitespace().collect::<Vec<_>>().join(" ");
let is_time_query = matches!(
normalized.as_str(),
"what time is it"
| "what time is it now"
| "what time is it right now"
| "what is the time"
| "what s the time"
| "whats the time"
| "current time"
| "time now"
| "time"
);
if !is_time_query {
return Ok(None);
}
let now = chrono::Local::now();
let reply = format!("It is {}.", now.format("%Y-%m-%d %H:%M:%S %Z (UTC%:z)"));
let reply = self
.emit_bootstrap_direct_reply(emitter, task_id, session_id, Instant::now(), &reply)
.await?;
Ok(Some(reply))
}
pub(super) async fn emit_bootstrap_direct_reply(
&self,
emitter: &crate::events::EventEmitter,
task_id: &str,
session_id: &str,
task_start: Instant,
reply: &str,
) -> anyhow::Result<String> {
let reply_text = reply.to_string();
let assistant_msg = Message {
id: Uuid::new_v4().to_string(),
session_id: session_id.to_string(),
role: "assistant".to_string(),
content: Some(reply_text.clone()),
tool_call_id: None,
tool_name: None,
tool_calls_json: None,
created_at: Utc::now(),
importance: 0.5,
..Message::runtime_defaults()
};
self.append_assistant_message_with_event(emitter, &assistant_msg, "system", None, None)
.await?;
self.emit_task_end(
emitter,
task_id,
TaskStatus::Completed,
task_start,
0,
0,
None,
Some(reply_text.chars().take(200).collect()),
)
.await;
Ok(reply_text)
}
}
fn looks_like_mid_task_pivot(user_text: &str) -> bool {
let lower = user_text.trim().to_ascii_lowercase();
if lower.is_empty() {
return false;
}
if matches!(lower.as_str(), "stop" | "cancel" | "abort") {
return false;
}
let has_cancel_cue = [
"stop",
"cancel",
"abort",
"scratch that",
"forget that",
"never mind",
"nevermind",
]
.iter()
.any(|kw| contains_keyword_as_words(&lower, kw));
if !has_cancel_cue {
return false;
}
let has_pivot_cue = [
"actually",
"instead",
"rather",
"new plan",
"change of plan",
"let's",
"lets",
]
.iter()
.any(|kw| contains_keyword_as_words(&lower, kw));
has_pivot_cue && lower.split_whitespace().count() >= 5
}
fn is_bare_confirmation(user_text: &str) -> bool {
let normalized = user_text
.trim()
.trim_matches(|c: char| c.is_ascii_punctuation() || c.is_whitespace())
.to_ascii_lowercase();
matches!(
normalized.as_str(),
"yes"
| "yes please"
| "yep"
| "yep please"
| "yeah"
| "yeah please"
| "sure"
| "sure please"
| "ok"
| "okay"
| "go ahead"
| "please do"
| "do it"
| "sounds good"
| "confirm"
| "confirmed"
| "proceed"
)
}
fn extract_last_question_line(message: &str) -> Option<&str> {
message
.lines()
.rev()
.map(str::trim)
.find(|line| !line.is_empty() && line.contains('?'))
}
fn question_requires_specific_answer(question: &str) -> bool {
let lower = question.trim().to_ascii_lowercase();
if !lower.contains('?') {
return false;
}
if lower.contains(" or ") {
return true;
}
lower.starts_with("how ")
|| contains_keyword_as_words(&lower, "which")
|| contains_keyword_as_words(&lower, "what")
|| contains_keyword_as_words(&lower, "where")
|| contains_keyword_as_words(&lower, "when")
|| contains_keyword_as_words(&lower, "who")
|| lower.contains("any specific")
|| lower.contains("can you clarify")
|| lower.contains("could you clarify")
}
fn assistant_question_requires_specific_answer(message: &str) -> bool {
extract_last_question_line(message).is_some_and(question_requires_specific_answer)
}
fn build_specific_answer_request(prev_assistant: &str) -> String {
if let Some(question) = extract_last_question_line(prev_assistant) {
let question = question.trim();
if question.chars().count() <= 160 {
return format!(
"I still need the specific answer to my last question before I can continue. Please answer directly: {}",
question
);
}
}
"I still need the specific option or missing detail from my last question before I can continue. Please answer with the exact choice or value you want, not just a confirmation.".to_string()
}
#[cfg(test)]
mod tests {
use super::{
assistant_question_requires_specific_answer, build_specific_answer_request,
is_bare_confirmation, looks_like_mid_task_pivot,
};
#[test]
fn test_looks_like_mid_task_pivot_detects_explicit_pivot() {
assert!(looks_like_mid_task_pivot(
"Wait stop. Actually scratch React and do plain HTML/CSS/JS instead."
));
assert!(looks_like_mid_task_pivot(
"Cancel that and instead generate a static page."
));
}
#[test]
fn test_looks_like_mid_task_pivot_ignores_plain_stop() {
assert!(!looks_like_mid_task_pivot("stop"));
assert!(!looks_like_mid_task_pivot("cancel"));
assert!(!looks_like_mid_task_pivot("abort"));
}
#[test]
fn test_looks_like_mid_task_pivot_requires_both_cancel_and_pivot_cues() {
assert!(!looks_like_mid_task_pivot(
"Actually create a static page instead."
));
assert!(!looks_like_mid_task_pivot("Never mind."));
}
#[test]
fn test_bare_confirmation_detects_short_affirmations() {
assert!(is_bare_confirmation("Yes"));
assert!(is_bare_confirmation("go ahead"));
assert!(!is_bare_confirmation("yes, post it"));
}
#[test]
fn test_specific_answer_required_for_branching_question() {
assert!(assistant_question_requires_specific_answer(
"Want me to tweak this or post it?"
));
assert!(assistant_question_requires_specific_answer(
"What time should I schedule it?"
));
assert!(!assistant_question_requires_specific_answer(
"Should I post it now?"
));
}
#[test]
fn test_specific_answer_request_reuses_last_question_line() {
let reply = build_specific_answer_request(
"Persistent context. Not just chat.\n\nWant me to tweak this or post it?",
);
assert!(reply.contains("Please answer directly: Want me to tweak this or post it?"));
}
}