Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion crates/jp_cli/src/cmd/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,14 @@ pub(crate) async fn acquire_lock(mut r: LockRequest<'_>) -> Result<LockOutcome>
// Wait for the next poll tick, but also listen for interrupts.
tokio::select! {
biased;
Some(()) = interrupt_rx.recv() => {
Some(notice) = interrupt_rx.recv() => {
cancel_timer(timer).await;
drop(interrupt_guard);
// The press is answered by the contention prompt below, so it
// no longer counts toward the escalation ladder: a press after
// the prompt is answered should reach a handler, not bypass
// the stack.
notice.handled();
return prompt_contention(r).await;
}
() = tokio::time::sleep(Duration::from_millis(500)) => {}
Expand Down
8 changes: 7 additions & 1 deletion crates/jp_cli/src/cmd/plugin/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,13 @@ pub(crate) fn run_plugin(
let shutdown_handle = thread::spawn(move || {
let interrupted = futures::executor::block_on(async {
tokio::select! {
notified = interrupt_rx.recv() => notified.is_some(),
// Asking the plugin to shut down acts on the press, so it
// leaves the escalation ladder; a further press then reaches
// the router with a fresh count.
notified = interrupt_rx.recv() => notified.is_some_and(|notice| {
notice.handled();
true
}),
() = shutdown_token.cancelled() => true,
}
});
Expand Down
71 changes: 68 additions & 3 deletions crates/jp_cli/src/cmd/query/interrupt/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

use std::sync::Arc;

use inquire::InquireError;
use jp_config::{
editor::InlineEditMode,
interrupt::{
Expand All @@ -41,6 +42,7 @@ use jp_inquire::{
prompt::{PromptBackend, TerminalPromptBackend},
};
use jp_printer::Printer;
use tracing::{debug, warn};

use crate::editor::report_editor_failure;

Expand All @@ -52,6 +54,36 @@ pub(crate) fn reply_edit_mode(mode: InlineEditMode) -> ReplyEditMode {
}
}

/// Why an interrupt prompt returned without a selection.
enum PromptExit {
/// The user pressed `Ctrl+C` on the prompt, asking to get past it.
Cancelled,

/// The prompt could not be rendered or read at all.
Unavailable,
}

/// Classify and record a prompt that returned without a selection.
///
/// The distinction decides whether the press escalates.
/// A cancelled prompt is the user asking out, so it does.
/// A prompt that could not run answered nothing, so escalating on it would end
/// the run on a decision the user never made; the press stays on the router's
/// escalation ladder instead, and a second one gets them out through the ladder
/// rather than through this prompt.
fn classify_prompt_exit(prompt: &str, error: &InquireError) -> PromptExit {
match error {
InquireError::OperationCanceled | InquireError::OperationInterrupted => {
debug!(prompt, %error, "Interrupt prompt cancelled.");
PromptExit::Cancelled
}
_ => {
warn!(prompt, %error, "Interrupt prompt unavailable.");
PromptExit::Unavailable
}
}
}

/// Actions that can be taken after an interrupt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InterruptAction {
Expand Down Expand Up @@ -111,6 +143,13 @@ pub enum InterruptAction {
/// The streaming path commits partial content before completing; the tool
/// path cancels the running tools.
Escalate,

/// The menu could not be shown or read, so nothing was decided.
///
/// The caller leaves the work as it was and leaves the press on the signal
/// router's escalation ladder, so a second press gets the user out through
/// the router instead of relying on this menu.
PromptFailed,
}

/// Outcome of collecting a reply from the user.
Expand Down Expand Up @@ -183,13 +222,22 @@ impl<P: PromptBackend> InterruptHandler<P> {
/// returns to the menu, while a configured (menu-less) `reply` resumes.
/// Cancelling the menu itself with `Ctrl+C` escalates: the caller should
/// commit partial content and begin a graceful shutdown.
/// A menu that cannot be shown at all yields
/// [`InterruptAction::PromptFailed`] rather than an escalation, leaving the
/// press on the router's escalation ladder.
pub fn handle_streaming_interrupt(
&self,
config: &StreamingInterruptConfig,
printer: &Printer,
stream_alive: bool,
) -> InterruptAction {
let menu = config.action == StreamingInterruptAction::Prompt;
debug!(
action = ?config.action,
menu,
stream_alive,
"Handling streaming interrupt."
);

loop {
let choice = match config.action {
Expand All @@ -212,7 +260,12 @@ impl<P: PromptBackend> InterruptHandler<P> {
// escalation, not a "continue".
match selected {
Ok(choice) => choice,
Err(_) => return InterruptAction::Escalate,
Err(error) => {
return match classify_prompt_exit("streaming_menu", &error) {
PromptExit::Cancelled => InterruptAction::Escalate,
PromptExit::Unavailable => InterruptAction::PromptFailed,
};
}
}
}
StreamingInterruptAction::Continue => 'c',
Expand Down Expand Up @@ -271,6 +324,7 @@ impl<P: PromptBackend> InterruptHandler<P> {
printer: &Printer,
) -> InterruptAction {
let menu = config.action == ToolInterruptAction::Prompt;
debug!(action = ?config.action, menu, "Handling tool interrupt.");

loop {
let choice = match config.action {
Expand All @@ -293,7 +347,12 @@ impl<P: PromptBackend> InterruptHandler<P> {
// escalation, not a "continue".
match selected {
Ok(choice) => choice,
Err(_) => return InterruptAction::Escalate,
Err(error) => {
return match classify_prompt_exit("tool_menu", &error) {
PromptExit::Cancelled => InterruptAction::Escalate,
PromptExit::Unavailable => InterruptAction::PromptFailed,
};
}
}
}
ToolInterruptAction::Continue => 'c',
Expand Down Expand Up @@ -456,7 +515,13 @@ impl<P: PromptBackend> InterruptHandler<P> {
// Whitespace counts as blank so the tool path reaches its canned
// rejection rather than sending a blank-looking reply.
Ok(ReplyOutcome::Submit(_)) => return ReplyResult::Empty,
Ok(ReplyOutcome::Cancelled) | Err(_) => return ReplyResult::Cancelled,
Ok(ReplyOutcome::Cancelled) => return ReplyResult::Cancelled,
Err(error) => {
// Both classifications back out to the caller's menu; only
// the log line differs.
let _unused = classify_prompt_exit("inline_reply", &error);
return ReplyResult::Cancelled;
}
}
}
}
Expand Down
91 changes: 90 additions & 1 deletion crates/jp_cli/src/cmd/query/interrupt/handler_tests.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
use std::sync::Arc;

use inquire::InquireError;
use jp_editor::MockEditorBackend;
use jp_inquire::{ReplyEditMode, ReplyOutcome, prompt::MockPromptBackend};
use jp_inquire::{
InlineOption, ReplyEditMode, ReplyOutcome,
prompt::{MockPromptBackend, PromptBackend},
};
use jp_printer::{OutputFormat, Printer};

use super::*;
Expand Down Expand Up @@ -40,6 +44,91 @@ fn handler_with_editor(
InterruptHandler::with_backend(backend, Some(Arc::new(editor)), ReplyEditMode::Emacs)
}

/// A backend whose prompts cannot run at all, as when there is no usable
/// terminal to read from.
///
/// Distinct from an empty [`MockPromptBackend`], which reports
/// `OperationCanceled` — the user pressing `Ctrl+C` on the prompt.
struct UnavailableBackend;

impl PromptBackend for UnavailableBackend {
fn inline_select(
&self,
_message: &str,
_options: Vec<InlineOption>,
_default: Option<char>,
_writer: &mut dyn std::io::Write,
) -> Result<char, InquireError> {
Err(InquireError::NotTTY)
}

fn inline_reply(
&self,
_message: &str,
_initial_text: &str,
_edit_mode: ReplyEditMode,
_editor_escape: bool,
_output: Box<dyn std::io::Write + Send>,
) -> Result<ReplyOutcome, InquireError> {
Err(InquireError::NotTTY)
}

fn text(
&self,
_message: &str,
_default: Option<&str>,
_writer: &mut dyn std::io::Write,
) -> Result<String, InquireError> {
Err(InquireError::NotTTY)
}

fn select(
&self,
_message: &str,
_options: Vec<String>,
_default: Option<usize>,
_writer: &mut dyn std::io::Write,
) -> Result<String, InquireError> {
Err(InquireError::NotTTY)
}
}

/// A menu the user cancels with `Ctrl+C` is a request to get past it, so it
/// escalates.
#[test]
fn streaming_menu_cancelled_by_user_escalates() {
// An empty mock queue reports `OperationCanceled`.
let handler = handler(MockPromptBackend::new());
let action = handler.handle_streaming_interrupt(
&streaming(StreamingInterruptAction::Prompt),
&make_printer(),
true,
);
assert_eq!(action, InterruptAction::Escalate);
}

/// A menu that cannot run answered nothing, so it must not escalate: doing so
/// would end the run on a decision the user never made.
/// The press stays on the router's escalation ladder, which is what gets the
/// user out on the next one.
#[test]
fn streaming_menu_that_cannot_run_does_not_escalate() {
let handler = InterruptHandler::with_backend(UnavailableBackend, None, ReplyEditMode::Emacs);
let action = handler.handle_streaming_interrupt(
&streaming(StreamingInterruptAction::Prompt),
&make_printer(),
true,
);
assert_eq!(action, InterruptAction::PromptFailed);
}

#[test]
fn tool_menu_that_cannot_run_does_not_escalate() {
let handler = InterruptHandler::with_backend(UnavailableBackend, None, ReplyEditMode::Emacs);
let action = handler.handle_tool_interrupt(&tool(ToolInterruptAction::Prompt), &make_printer());
assert_eq!(action, InterruptAction::PromptFailed);
}

#[test]
fn streaming_interrupt_stop() {
let handler = handler(MockPromptBackend::new().with_inline_responses(['s']));
Expand Down
43 changes: 38 additions & 5 deletions crates/jp_cli/src/cmd/query/interrupt/signals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use jp_inquire::{ReplyEditMode, prompt::PromptBackend};
use jp_llm::event::{Event, FinishReason, apply_patches};
use jp_printer::Printer;
use tokio_util::sync::CancellationToken;
use tracing::{info, trace};
use tracing::{debug, info, trace};

use super::handler::{InterruptAction, InterruptHandler};
use crate::cmd::query::{
Expand Down Expand Up @@ -58,6 +58,11 @@ pub enum StreamingInterruptResult {
/// and the turn is complete.
/// The caller should begin a graceful shutdown.
Escalate,

/// The menu could not be shown, so nothing was decided.
/// The caller leaves the stream as it was and leaves the press on the
/// signal router's escalation ladder.
PromptFailed,
}

/// Handle a Ctrl-C interrupt notification received during LLM streaming.
Expand Down Expand Up @@ -95,9 +100,19 @@ pub fn handle_streaming_interrupt(
// polling instead.
let is_resume = matches!(action, InterruptAction::Resume);
let is_escalate = matches!(action, InterruptAction::Escalate);
debug!(
?action,
llm_stream_finished, "Streaming interrupt resolved."
);

// A menu that never ran decided nothing, so the state machine is left
// untouched: no partial commit, no phase change.
if matches!(action, InterruptAction::PromptFailed) {
return StreamingInterruptResult::PromptFailed;
}

// Delegate state transition to the turn coordinator
match turn_coordinator.handle_streaming_interrupt(action, conversation_stream) {
let result = match turn_coordinator.handle_streaming_interrupt(action, conversation_stream) {
// Return without persisting this cycle (previous turn cycles
// are already persisted).
TurnPhase::Aborted => StreamingInterruptResult::Abort,
Expand All @@ -112,7 +127,10 @@ pub fn handle_streaming_interrupt(
// All other phases break from loop, persist, then outer loop
// decides.
_ => StreamingInterruptResult::Break,
}
};

debug!(?result, phase = ?turn_coordinator.current_phase(), "Streaming interrupt handled.");
result
}

/// Handle a successful event from the LLM stream.
Expand Down Expand Up @@ -214,6 +232,11 @@ pub enum ToolInterruptResult {
/// Cancel current execution and begin a graceful shutdown: the user
/// cancelled the interrupt menu itself with Ctrl-C.
Escalate,

/// The menu could not be shown, so nothing was decided.
/// The caller keeps waiting for the running tools and leaves the press on
/// the signal router's escalation ladder.
PromptFailed,
}

/// Handle a Ctrl-C interrupt notification received during tool execution.
Expand Down Expand Up @@ -249,11 +272,18 @@ pub fn handle_tool_interrupt(

let action = InterruptHandler::with_backend(backend, editor, edit_mode)
.handle_tool_interrupt(config, printer);
debug!(?action, "Tool interrupt resolved.");

// A menu that never ran decided nothing: the running tools are left alone
// and the state machine is not notified.
if matches!(action, InterruptAction::PromptFailed) {
return ToolInterruptResult::PromptFailed;
}

// Notify the state machine (reserved for future state transitions).
turn_coordinator.handle_tool_interrupt(&action);

match action {
let result = match action {
InterruptAction::RestartTool => {
info!("Restarting tool execution");
cancellation_token.cancel();
Expand All @@ -269,7 +299,10 @@ pub fn handle_tool_interrupt(
ToolInterruptResult::Escalate
}
_ => ToolInterruptResult::Continue,
}
};

debug!(?result, "Tool interrupt handled.");
result
}

#[cfg(test)]
Expand Down
Loading
Loading