Harvest pausable custom command MVP
Harvested from PR #2732 by @aboimpinto. Parse pausable frontmatter for custom slash commands, add a narrow engine pause gate before tool execution, and preserve paused command state across separate messages until explicit resume, cancel, terminal completion, or a new command. Also centralize strict resume-message detection with negative coverage for deferred or negated phrases, and keep rollback/stash/worktree mutation behavior out of this slice. Co-authored-by: aboimpinto <1231687+aboimpinto@users.noreply.github.com>
This commit is contained in:
@@ -66,6 +66,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
TUI sidebar from the command line instead of relying on copy-hostile sidebar
|
||||
state during long transcript work (#2766, #2788). Thanks @mo-vic for the
|
||||
detailed report and @aboimpinto for the fix.
|
||||
- Added a pausable custom slash-command MVP: commands with `pausable: true`
|
||||
can pause before further tool execution, preserve the paused command while
|
||||
separate messages are handled, and resume only on explicit continue/resume
|
||||
wording. Harvested from #2732 with thanks to @aboimpinto.
|
||||
- Added Sofya (`provider = "sofya"`) as a search-tool backend with
|
||||
`SOFYA_API_KEY` fallback, while keeping Sofya scoped to web search rather
|
||||
than model-provider routing (#2790). Thanks @yusufgurdogan for the
|
||||
|
||||
@@ -66,6 +66,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
TUI sidebar from the command line instead of relying on copy-hostile sidebar
|
||||
state during long transcript work (#2766, #2788). Thanks @mo-vic for the
|
||||
detailed report and @aboimpinto for the fix.
|
||||
- Added a pausable custom slash-command MVP: commands with `pausable: true`
|
||||
can pause before further tool execution, preserve the paused command while
|
||||
separate messages are handled, and resume only on explicit continue/resume
|
||||
wording. Harvested from #2732 with thanks to @aboimpinto.
|
||||
- Added Sofya (`provider = "sofya"`) as a search-tool backend with
|
||||
`SOFYA_API_KEY` fallback, while keeping Sofya scoped to web search rather
|
||||
than model-provider routing (#2790). Thanks @yusufgurdogan for the
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! `/name`, the file contents are sent as a user message.
|
||||
//!
|
||||
//! Files may include optional YAML-like frontmatter between `---` markers.
|
||||
//! Supported fields are `description`, `argument-hint`, and `allowed-tools`.
|
||||
//! Supported fields are `description`, `argument-hint`, `allowed-tools`, and `pausable`.
|
||||
//! Frontmatter is stripped before the command body is sent to the model.
|
||||
//!
|
||||
//! ## Precedence
|
||||
@@ -206,6 +206,9 @@ pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option<CommandRe
|
||||
app.hunt.verdict = HuntVerdict::Hunting;
|
||||
app.hunt.token_budget = None;
|
||||
app.active_allowed_tools = None;
|
||||
app.pausable = false;
|
||||
app.paused = false;
|
||||
app.paused_quarry = None;
|
||||
for (key, value) in &metadata {
|
||||
match key.as_str() {
|
||||
"description" => {
|
||||
@@ -215,6 +218,9 @@ pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option<CommandRe
|
||||
"allowed-tools" => {
|
||||
app.active_allowed_tools = Some(parse_allowed_tools(value));
|
||||
}
|
||||
"pausable" => {
|
||||
app.pausable = value.trim().eq_ignore_ascii_case("true");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -561,6 +567,84 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pausable_frontmatter_sets_app_state_without_worktree_mutation() {
|
||||
use crate::config::Config;
|
||||
|
||||
if std::process::Command::new("git")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
let init = std::process::Command::new("git")
|
||||
.args(["-C", ws.to_str().unwrap(), "init"])
|
||||
.output()
|
||||
.expect("git init");
|
||||
assert!(
|
||||
init.status.success(),
|
||||
"git init failed: {}",
|
||||
String::from_utf8_lossy(&init.stderr)
|
||||
);
|
||||
std::fs::write(ws.join("user-work.txt"), "untracked user work").unwrap();
|
||||
write_command(
|
||||
&ws.join(".codewhale").join("commands"),
|
||||
"pause-scan",
|
||||
"---\ndescription: Scan repos\npausable: true\n---\nscan",
|
||||
);
|
||||
|
||||
let mut app = App::new(test_options(ws.clone()), &Config::default());
|
||||
let _ = try_dispatch_user_command(&mut app, "/pause-scan").unwrap();
|
||||
|
||||
assert!(app.pausable);
|
||||
assert!(!app.paused);
|
||||
assert!(app.paused_quarry.is_none());
|
||||
assert!(ws.join("user-work.txt").exists());
|
||||
let stash = std::process::Command::new("git")
|
||||
.args(["-C", ws.to_str().unwrap(), "stash", "list"])
|
||||
.output()
|
||||
.expect("git stash list");
|
||||
assert!(
|
||||
stash.status.success(),
|
||||
"git stash list failed: {}",
|
||||
String::from_utf8_lossy(&stash.stderr)
|
||||
);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&stash.stdout).trim().is_empty(),
|
||||
"pausable dispatch must not create git stash entries"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_user_command_clears_stale_paused_state() {
|
||||
use crate::config::Config;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
let commands_dir = ws.join(".codewhale").join("commands");
|
||||
write_command(
|
||||
&commands_dir,
|
||||
"pause-scan",
|
||||
"---\ndescription: Scan repos\npausable: true\n---\nscan",
|
||||
);
|
||||
write_command(&commands_dir, "plain", "plain command");
|
||||
|
||||
let mut app = App::new(test_options(ws), &Config::default());
|
||||
let _ = try_dispatch_user_command(&mut app, "/pause-scan").unwrap();
|
||||
app.paused = true;
|
||||
app.paused_quarry = Some("Scan repos".to_string());
|
||||
|
||||
let _ = try_dispatch_user_command(&mut app, "/plain").unwrap();
|
||||
|
||||
assert!(!app.pausable);
|
||||
assert!(!app.paused);
|
||||
assert!(app.paused_quarry.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_regression_empty_allowed_tools_blocks_all_tools() {
|
||||
use crate::config::Config;
|
||||
|
||||
@@ -484,6 +484,8 @@ pub struct EngineHandle {
|
||||
tx_user_input: mpsc::Sender<UserInputDecision>,
|
||||
/// Send steer input for an in-flight turn.
|
||||
tx_steer: mpsc::Sender<String>,
|
||||
/// Shared pause flag set by the TUI and read by the turn loop.
|
||||
shared_paused: Arc<StdMutex<bool>>,
|
||||
}
|
||||
|
||||
// `impl EngineHandle { ... }` moved to `engine/handle.rs` so the
|
||||
@@ -557,6 +559,8 @@ pub struct Engine {
|
||||
/// four TUI / command consumers; the cache turns N×O(messages) walks
|
||||
/// into a single recompute on a content change.
|
||||
token_estimate_cache: TokenEstimateCache,
|
||||
/// Shared pause flag set by the TUI and read before tool execution.
|
||||
shared_paused: Arc<StdMutex<bool>>,
|
||||
}
|
||||
|
||||
// === Internal tool helpers ===
|
||||
@@ -580,6 +584,10 @@ impl Engine {
|
||||
Ok(mut slot) => *slot = None,
|
||||
Err(poisoned) => *poisoned.into_inner() = None,
|
||||
}
|
||||
match self.shared_paused.lock() {
|
||||
Ok(mut paused) => *paused = false,
|
||||
Err(poisoned) => *poisoned.into_inner() = false,
|
||||
}
|
||||
}
|
||||
|
||||
fn env_only_api_key_recovery_hint(api_config: &Config) -> Option<String> {
|
||||
@@ -646,6 +654,7 @@ impl Engine {
|
||||
let cancel_token = CancellationToken::new();
|
||||
let shared_cancel_token = Arc::new(StdMutex::new(cancel_token.clone()));
|
||||
let cancel_reason: Arc<StdMutex<Option<CancelReason>>> = Arc::new(StdMutex::new(None));
|
||||
let shared_paused = Arc::new(StdMutex::new(false));
|
||||
let tool_exec_lock = Arc::new(RwLock::new(()));
|
||||
|
||||
// Create clients for both providers
|
||||
@@ -808,6 +817,7 @@ impl Engine {
|
||||
sandbox_backend,
|
||||
current_mode: AppMode::Agent,
|
||||
token_estimate_cache: TokenEstimateCache::new(),
|
||||
shared_paused: shared_paused.clone(),
|
||||
};
|
||||
engine.rehydrate_latest_canonical_state();
|
||||
|
||||
@@ -819,6 +829,7 @@ impl Engine {
|
||||
tx_approval,
|
||||
tx_user_input,
|
||||
tx_steer,
|
||||
shared_paused,
|
||||
};
|
||||
|
||||
(engine, handle)
|
||||
@@ -2791,6 +2802,7 @@ pub(crate) fn mock_engine_handle() -> MockEngineHandle {
|
||||
let cancel_token = CancellationToken::new();
|
||||
let shared_cancel_token = Arc::new(StdMutex::new(cancel_token.clone()));
|
||||
let cancel_reason: Arc<StdMutex<Option<CancelReason>>> = Arc::new(StdMutex::new(None));
|
||||
let shared_paused = Arc::new(StdMutex::new(false));
|
||||
let handle = EngineHandle {
|
||||
tx_op,
|
||||
rx_event: Arc::new(RwLock::new(rx_event)),
|
||||
@@ -2799,6 +2811,7 @@ pub(crate) fn mock_engine_handle() -> MockEngineHandle {
|
||||
tx_approval,
|
||||
tx_user_input,
|
||||
tx_steer,
|
||||
shared_paused,
|
||||
};
|
||||
|
||||
MockEngineHandle {
|
||||
|
||||
@@ -51,6 +51,24 @@ impl EngineHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pause or resume the current pausable command.
|
||||
pub fn set_paused(&self, paused: bool) {
|
||||
match self.shared_paused.lock() {
|
||||
Ok(mut slot) => *slot = paused,
|
||||
Err(poisoned) => *poisoned.into_inner() = paused,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the engine pause gate is set.
|
||||
#[cfg(test)]
|
||||
#[must_use]
|
||||
pub fn is_paused(&self) -> bool {
|
||||
match self.shared_paused.lock() {
|
||||
Ok(slot) => *slot,
|
||||
Err(poisoned) => *poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Approve a pending tool call
|
||||
pub async fn approve_tool_call(&self, id: impl Into<String>) -> Result<()> {
|
||||
self.tx_approval
|
||||
|
||||
@@ -1259,6 +1259,14 @@ impl Engine {
|
||||
}
|
||||
|
||||
// Execute tools
|
||||
if self.shared_paused.lock().is_ok_and(|paused| *paused) {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status("Request was Paused"))
|
||||
.await;
|
||||
return (TurnOutcomeStatus::Interrupted, None);
|
||||
}
|
||||
|
||||
let tool_exec_lock = self.tool_exec_lock.clone();
|
||||
let mcp_pool = if tool_uses
|
||||
.iter()
|
||||
|
||||
@@ -1242,6 +1242,12 @@ pub struct App {
|
||||
/// Active tool restriction from custom slash command frontmatter.
|
||||
/// `None` means the current turn may use the normal tool set.
|
||||
pub active_allowed_tools: Option<Vec<String>>,
|
||||
/// True when the active custom slash command opted into pause/resume.
|
||||
pub pausable: bool,
|
||||
/// True after Esc paused a pausable command and before it is resumed or cancelled.
|
||||
pub paused: bool,
|
||||
/// Saved custom-command objective while the command is paused.
|
||||
pub paused_quarry: Option<String>,
|
||||
pub history: Vec<HistoryCell>,
|
||||
pub history_version: u64,
|
||||
/// Per-cell revision counter, kept in lockstep with `history`.
|
||||
@@ -2065,6 +2071,9 @@ impl App {
|
||||
hunt: HuntState::default(),
|
||||
session: SessionState::default(),
|
||||
active_allowed_tools: None,
|
||||
pausable: false,
|
||||
paused: false,
|
||||
paused_quarry: None,
|
||||
history: Vec::new(),
|
||||
history_version: 0,
|
||||
history_revisions: Vec::new(),
|
||||
|
||||
@@ -8,6 +8,7 @@ const COMPOSER_ARROW_SCROLL_LINES: usize = 3;
|
||||
pub(crate) enum EscapeAction {
|
||||
CloseSlashMenu,
|
||||
CancelRequest,
|
||||
PauseCommand,
|
||||
DiscardQueuedDraft,
|
||||
ClearInput,
|
||||
Noop,
|
||||
@@ -18,6 +19,13 @@ pub(crate) fn next_escape_action(app: &App, slash_menu_open: bool) -> EscapeActi
|
||||
EscapeAction::CloseSlashMenu
|
||||
} else if app.queued_draft.is_some() {
|
||||
EscapeAction::DiscardQueuedDraft
|
||||
} else if app.paused || app.paused_quarry.is_some() {
|
||||
EscapeAction::CancelRequest
|
||||
} else if app.pausable
|
||||
&& !app.paused
|
||||
&& (app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")))
|
||||
{
|
||||
EscapeAction::PauseCommand
|
||||
} else if app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) {
|
||||
EscapeAction::CancelRequest
|
||||
} else if !app.input.is_empty() {
|
||||
|
||||
+183
-6
@@ -1697,6 +1697,10 @@ async fn run_event_loop(
|
||||
let was_locally_cancelled = app.suppress_stream_events_until_turn_complete;
|
||||
app.suppress_stream_events_until_turn_complete = false;
|
||||
app.active_allowed_tools = None;
|
||||
if app.paused_quarry.is_none() {
|
||||
app.pausable = false;
|
||||
app.paused = false;
|
||||
}
|
||||
if !matches!(status, crate::core::events::TurnOutcomeStatus::Completed)
|
||||
|| draws_since_last_full_repaint >= PERIODIC_FULL_REPAINT_EVERY_N
|
||||
{
|
||||
@@ -3539,10 +3543,31 @@ async fn run_event_loop(
|
||||
}
|
||||
EscapeAction::CancelRequest => {
|
||||
app.backtrack.reset();
|
||||
engine_handle.cancel();
|
||||
mark_active_turn_cancelled_locally(app);
|
||||
current_streaming_text.clear();
|
||||
app.status_message = Some("Request cancelled".to_string());
|
||||
if app.paused || app.paused_quarry.is_some() {
|
||||
clear_paused_command_state(app, &engine_handle);
|
||||
if app.is_loading
|
||||
|| matches!(
|
||||
app.runtime_turn_status.as_deref(),
|
||||
Some("in_progress")
|
||||
)
|
||||
{
|
||||
engine_handle.cancel();
|
||||
mark_active_turn_cancelled_locally(app);
|
||||
current_streaming_text.clear();
|
||||
}
|
||||
app.active_allowed_tools = None;
|
||||
app.hunt.quarry = None;
|
||||
app.status_message = Some("Paused command cancelled".to_string());
|
||||
} else {
|
||||
engine_handle.cancel();
|
||||
mark_active_turn_cancelled_locally(app);
|
||||
current_streaming_text.clear();
|
||||
app.status_message = Some("Request cancelled".to_string());
|
||||
}
|
||||
}
|
||||
EscapeAction::PauseCommand => {
|
||||
app.backtrack.reset();
|
||||
pause_pausable_command(app, &engine_handle);
|
||||
}
|
||||
EscapeAction::DiscardQueuedDraft => {
|
||||
app.backtrack.reset();
|
||||
@@ -4948,6 +4973,149 @@ fn queued_message_content_for_app(
|
||||
}
|
||||
}
|
||||
|
||||
fn paused_quarry_title(quarry: &str) -> &str {
|
||||
quarry
|
||||
.split(['\n', '\r'])
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.unwrap_or("the paused command")
|
||||
}
|
||||
|
||||
fn is_resume_message(message: &str) -> bool {
|
||||
let words: Vec<String> = message
|
||||
.to_ascii_lowercase()
|
||||
.split(|ch: char| !ch.is_ascii_alphanumeric())
|
||||
.filter(|word| !word.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
if words.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let text = words.join(" ");
|
||||
let has_resume_verb = words
|
||||
.iter()
|
||||
.any(|word| matches!(word.as_str(), "continue" | "resume"));
|
||||
if !has_resume_verb {
|
||||
return false;
|
||||
}
|
||||
|
||||
let blockers = [
|
||||
"do not continue",
|
||||
"do not resume",
|
||||
"don t continue",
|
||||
"don t resume",
|
||||
"dont continue",
|
||||
"dont resume",
|
||||
"not continue",
|
||||
"not resume",
|
||||
"continue yet",
|
||||
"resume yet",
|
||||
"will continue",
|
||||
"will resume",
|
||||
"continue tomorrow",
|
||||
"resume tomorrow",
|
||||
"continue later",
|
||||
"resume later",
|
||||
];
|
||||
if blockers.iter().any(|blocker| text.contains(blocker)) {
|
||||
return false;
|
||||
}
|
||||
if matches!(
|
||||
words.first().map(String::as_str),
|
||||
Some("how" | "what" | "when" | "where" | "why")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if words.len() == 1 {
|
||||
return true;
|
||||
}
|
||||
|
||||
let context_words = [
|
||||
"please", "now", "paused", "pause", "command", "task", "work", "request", "goal",
|
||||
"previous", "last", "same", "it", "that", "this", "go", "ahead",
|
||||
];
|
||||
if words
|
||||
.iter()
|
||||
.any(|word| context_words.contains(&word.as_str()))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
text.starts_with("can you continue")
|
||||
|| text.starts_with("can you resume")
|
||||
|| text.starts_with("could you continue")
|
||||
|| text.starts_with("could you resume")
|
||||
}
|
||||
|
||||
fn paused_command_note(title: &str, resume: bool) -> String {
|
||||
let instruction = if resume {
|
||||
"The user is resuming that paused command. Continue the paused command."
|
||||
} else {
|
||||
"The user is not resuming that paused command. Answer only the new message and do not continue the paused command."
|
||||
};
|
||||
format!(
|
||||
"\n\n<runtime_prompt visibility=\"internal\">\n\
|
||||
Paused custom slash command: {title}\n\
|
||||
{instruction}\n\
|
||||
</runtime_prompt>"
|
||||
)
|
||||
}
|
||||
|
||||
fn prepare_paused_command_message(
|
||||
app: &mut App,
|
||||
engine_handle: &EngineHandle,
|
||||
user_message: &str,
|
||||
) -> Option<String> {
|
||||
if !app.paused && app.paused_quarry.is_none() {
|
||||
engine_handle.set_paused(false);
|
||||
return None;
|
||||
}
|
||||
|
||||
engine_handle.set_paused(false);
|
||||
app.paused = false;
|
||||
|
||||
let Some(quarry) = app
|
||||
.paused_quarry
|
||||
.clone()
|
||||
.or_else(|| app.hunt.quarry.clone())
|
||||
else {
|
||||
app.pausable = false;
|
||||
return None;
|
||||
};
|
||||
let title = paused_quarry_title(&quarry).to_string();
|
||||
if is_resume_message(user_message) {
|
||||
app.hunt.quarry = Some(app.paused_quarry.take().unwrap_or(quarry));
|
||||
app.pausable = true;
|
||||
Some(paused_command_note(&title, true))
|
||||
} else {
|
||||
app.hunt.quarry = None;
|
||||
Some(paused_command_note(&title, false))
|
||||
}
|
||||
}
|
||||
|
||||
fn pause_pausable_command(app: &mut App, engine_handle: &EngineHandle) {
|
||||
app.paused_quarry = app
|
||||
.paused_quarry
|
||||
.clone()
|
||||
.or_else(|| app.hunt.quarry.clone());
|
||||
app.hunt.quarry = None;
|
||||
app.paused = true;
|
||||
app.pausable = true;
|
||||
engine_handle.set_paused(true);
|
||||
app.status_message = Some(
|
||||
"Request paused. Send `continue` or `resume` to continue, or Esc to cancel.".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
fn clear_paused_command_state(app: &mut App, engine_handle: &EngineHandle) {
|
||||
app.pausable = false;
|
||||
app.paused = false;
|
||||
app.paused_quarry = None;
|
||||
engine_handle.set_paused(false);
|
||||
}
|
||||
|
||||
async fn dispatch_user_message(
|
||||
app: &mut App,
|
||||
config: &Config,
|
||||
@@ -4984,6 +5152,8 @@ async fn dispatch_user_message(
|
||||
}
|
||||
}
|
||||
|
||||
let paused_note = prepare_paused_command_message(app, engine_handle, &message.display);
|
||||
|
||||
// Set immediately to prevent double-dispatch before TurnStarted event arrives.
|
||||
let dispatch_started_at = Instant::now();
|
||||
app.is_loading = true;
|
||||
@@ -5001,7 +5171,10 @@ async fn dispatch_user_message(
|
||||
&app.workspace,
|
||||
cwd.clone(),
|
||||
);
|
||||
let content = queued_message_content_for_app(app, &message, cwd);
|
||||
let mut content = queued_message_content_for_app(app, &message, cwd);
|
||||
if let Some(note) = paused_note.as_deref() {
|
||||
content.push_str(note);
|
||||
}
|
||||
let message_index = app.api_messages.len();
|
||||
app.system_prompt = Some(
|
||||
prompts::system_prompt_for_mode_with_context_skills_and_session(
|
||||
@@ -6405,13 +6578,17 @@ async fn steer_user_message(
|
||||
engine_handle: &EngineHandle,
|
||||
message: QueuedMessage,
|
||||
) -> Result<()> {
|
||||
let paused_note = prepare_paused_command_message(app, engine_handle, &message.display);
|
||||
let cwd = std::env::current_dir().ok();
|
||||
let references = crate::tui::file_mention::context_references_from_input(
|
||||
&message.display,
|
||||
&app.workspace,
|
||||
cwd.clone(),
|
||||
);
|
||||
let content = queued_message_content_for_app(app, &message, cwd);
|
||||
let mut content = queued_message_content_for_app(app, &message, cwd);
|
||||
if let Some(note) = paused_note.as_deref() {
|
||||
content.push_str(note);
|
||||
}
|
||||
let message_index = app.api_messages.len();
|
||||
|
||||
engine_handle.steer(content.clone()).await?;
|
||||
|
||||
@@ -2611,6 +2611,121 @@ exit 2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_message_helper_is_strict() {
|
||||
for message in [
|
||||
"continue",
|
||||
"resume",
|
||||
"please continue",
|
||||
"continue the paused command",
|
||||
"can you resume the paused task",
|
||||
"go ahead and resume",
|
||||
] {
|
||||
assert!(is_resume_message(message), "expected resume: {message}");
|
||||
}
|
||||
|
||||
for message in [
|
||||
"don't continue yet",
|
||||
"do not resume yet",
|
||||
"I will resume tomorrow",
|
||||
"we can continue tomorrow",
|
||||
"continue later",
|
||||
"how do I resume a git cherry-pick?",
|
||||
"please do not continue",
|
||||
] {
|
||||
assert!(
|
||||
!is_resume_message(message),
|
||||
"expected not resume: {message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_non_resume_message_preserves_paused_command_state() {
|
||||
let mut app = create_test_app();
|
||||
app.pausable = true;
|
||||
app.paused = true;
|
||||
app.paused_quarry = Some("Scan nested git repositories".to_string());
|
||||
app.hunt.quarry = Some("Scan nested git repositories".to_string());
|
||||
let mut engine = mock_engine_handle();
|
||||
engine.handle.set_paused(true);
|
||||
let config = Config::default();
|
||||
|
||||
dispatch_user_message(
|
||||
&mut app,
|
||||
&config,
|
||||
&engine.handle,
|
||||
QueuedMessage::new("how are you?".to_string(), None),
|
||||
)
|
||||
.await
|
||||
.expect("dispatch user message");
|
||||
|
||||
assert!(!app.paused);
|
||||
assert!(app.pausable);
|
||||
assert_eq!(
|
||||
app.paused_quarry.as_deref(),
|
||||
Some("Scan nested git repositories")
|
||||
);
|
||||
assert!(app.hunt.quarry.is_none());
|
||||
assert!(!engine.handle.is_paused());
|
||||
match engine.rx_op.recv().await.expect("send message op") {
|
||||
crate::core::ops::Op::SendMessage {
|
||||
content,
|
||||
goal_objective,
|
||||
..
|
||||
} => {
|
||||
assert!(goal_objective.is_none());
|
||||
assert!(content.contains("Paused custom slash command: Scan nested git repositories"));
|
||||
assert!(content.contains("do not continue the paused command"));
|
||||
}
|
||||
other => panic!("expected SendMessage, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_resume_message_restores_paused_command_goal() {
|
||||
let mut app = create_test_app();
|
||||
app.pausable = true;
|
||||
app.paused = true;
|
||||
app.paused_quarry = Some("Scan nested git repositories".to_string());
|
||||
let mut engine = mock_engine_handle();
|
||||
engine.handle.set_paused(true);
|
||||
let config = Config::default();
|
||||
|
||||
dispatch_user_message(
|
||||
&mut app,
|
||||
&config,
|
||||
&engine.handle,
|
||||
QueuedMessage::new("please continue the paused command".to_string(), None),
|
||||
)
|
||||
.await
|
||||
.expect("dispatch user message");
|
||||
|
||||
assert!(!app.paused);
|
||||
assert!(app.pausable);
|
||||
assert!(app.paused_quarry.is_none());
|
||||
assert_eq!(
|
||||
app.hunt.quarry.as_deref(),
|
||||
Some("Scan nested git repositories")
|
||||
);
|
||||
assert!(!engine.handle.is_paused());
|
||||
match engine.rx_op.recv().await.expect("send message op") {
|
||||
crate::core::ops::Op::SendMessage {
|
||||
content,
|
||||
goal_objective,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(
|
||||
goal_objective.as_deref(),
|
||||
Some("Scan nested git repositories")
|
||||
);
|
||||
assert!(content.contains("Paused custom slash command: Scan nested git repositories"));
|
||||
assert!(content.contains("Continue the paused command"));
|
||||
}
|
||||
other => panic!("expected SendMessage, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_liveness_watchdog_clears_stale_dispatch() {
|
||||
let mut app = create_test_app();
|
||||
@@ -4246,6 +4361,28 @@ fn test_esc_priority_order_matches_cancel_stack() {
|
||||
assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_escape_action_pauses_then_cancels_pausable_command() {
|
||||
let mut app = create_test_app();
|
||||
app.is_loading = true;
|
||||
app.pausable = true;
|
||||
app.paused = false;
|
||||
|
||||
assert_eq!(next_escape_action(&app, false), EscapeAction::PauseCommand);
|
||||
|
||||
app.paused = true;
|
||||
assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);
|
||||
|
||||
app.is_loading = false;
|
||||
app.paused = false;
|
||||
app.pausable = true;
|
||||
app.paused_quarry = Some("Scan repos".to_string());
|
||||
assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);
|
||||
|
||||
app.is_loading = true;
|
||||
assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_slash_menu_entries_respects_hide_flag() {
|
||||
let mut app = create_test_app();
|
||||
|
||||
Reference in New Issue
Block a user