Merge pull request #2803 from Hmbown/codex/harvest-2732-pausable-command-mvp

Harvest pausable custom command MVP from #2732
This commit is contained in:
Hunter Bown
2026-06-05 10:08:58 -07:00
committed by GitHub
10 changed files with 469 additions and 7 deletions
+4
View File
@@ -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
+4
View File
@@ -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
+85 -1
View File
@@ -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;
+13
View File
@@ -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 {
+18
View File
@@ -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
+8
View File
@@ -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()
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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?;
+137
View File
@@ -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();