[APP-4285] Send slash commands as follow-ups with active AI streams (#9243)

## Description

Running `/pr-comments` (or any other slash command that targets the currently-selected conversation) while an AI response stream is in-flight crashed in debug builds. `BlocklistAIController::send_request_input` hits its in-flight invariant and fires `safe_assert!(false, ...)` (panics in debug, returns `Err` in release).

The user-query path (`send_query`) avoids this because it pre-cancels any active stream on the target conversation with `CancellationReason::FollowUpSubmitted` before calling `send_request_input`. Slash commands bypass that path and dispatch directly via `SlashCommandRequest::send_request`, so the cancel never happens.

This PR makes `send_slash_command_request` mirror that cancel-and-resend: if the target conversation has an in-flight stream, cancel it before dispatching. All `SlashCommandRequest` variants are conceptually a fresh user turn (a follow-up), so this matches the semantics users already get from typing a follow-up message.

`send_queued_slash_command_request` is unchanged — it's only invoked from `Input::submit_queued_prompt` once the conversation is idle, so no pre-cancel is needed.

## Testing

Verified locally. Demo [here](https://www.loom.com/share/a9638ac9a53b48349ce21d03eaba516a)!

- Manually reproduced the crash on a dogfood debug build by triggering `/pr-comments` mid-stream; confirmed the panic at the `safe_assert!` in `BlocklistAIController::send_request_input`.
- After the fix: same repro cancels the in-flight turn and dispatches `/pr-comments` cleanly. Same behavior verified for `/skills` (InvokeSkill), `/compact` (Summarize), and `/create-environment`.

## Agent Mode

- [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode

## Changelog Entries for Stable

CHANGELOG-BUG-FIX: Fixed an issue where slash commands sent while an agent was still responding were silently dropped. Now, slash commands like `/pr-comments` run as follow-ups, just like typed messages.

---

Run: https://staging.warp.dev/conversation/aeecfa2d-1456-440b-9961-c27295d82531
Plan: https://staging.warp.dev/drive/notebook/Ryj2w6xDSqDwNJYhpwwI0Vly
This commit is contained in:
Varoon Kodithala
2026-04-28 14:51:29 -04:00
committed by GitHub
parent bfdd0796ed
commit bc3fffa7e5
2 changed files with 34 additions and 10 deletions
+33 -9
View File
@@ -599,15 +599,8 @@ impl BlocklistAIController {
.remove(&conversation_id)
.unwrap_or_default();
let ai_history_model = BlocklistAIHistoryModel::as_ref(ctx);
let active_conversation_id = ai_history_model.active_conversation_id(self.terminal_view_id);
let cancellation_reason = CancellationReason::FollowUpSubmitted {
is_for_same_conversation: active_conversation_id
.is_some_and(|id| id == conversation_id),
};
if let Some(active_conversation_id) = active_conversation_id {
self.cancel_conversation_progress(active_conversation_id, cancellation_reason, ctx);
}
let cancellation_reason =
self.cancel_active_conversation_for_follow_up(conversation_id, ctx);
if let Some(slash_command_request) = SlashCommandRequest::from_query(query.as_str()) {
slash_command_request.send_request(self, is_queued_prompt, ctx);
@@ -1209,9 +1202,40 @@ impl BlocklistAIController {
slash_command: SlashCommandRequest,
ctx: &mut ModelContext<Self>,
) {
// Slash commands are a fresh user turn; mirror `send_query`'s
// cancel-and-resend so we don't trip `send_request_input`'s in-flight
// invariant.
if let Some(conversation_id) = slash_command.conversation_id(self, ctx) {
self.cancel_active_conversation_for_follow_up(conversation_id, ctx);
}
slash_command.send_request(self, /*is_queued_prompt*/ false, ctx);
}
/// Cancel any in-flight progress on the active conversation in preparation
/// for sending a follow-up turn that will land on `target_conversation_id`.
/// Without this pre-cancel, [`Self::send_request_input`] would trip its
/// in-flight invariant when the new turn re-uses an existing conversation.
///
/// Returns the [`CancellationReason::FollowUpSubmitted`] reason used so
/// callers can reuse it for downstream side effects (e.g. cancelling
/// pending actions on the target conversation).
fn cancel_active_conversation_for_follow_up(
&mut self,
target_conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) -> CancellationReason {
let active_conversation_id =
BlocklistAIHistoryModel::as_ref(ctx).active_conversation_id(self.terminal_view_id);
let reason = CancellationReason::FollowUpSubmitted {
is_for_same_conversation: active_conversation_id
.is_some_and(|id| id == target_conversation_id),
};
if let Some(active_conversation_id) = active_conversation_id {
self.cancel_conversation_progress(active_conversation_id, reason, ctx);
}
reason
}
/// Same as [`Self::send_slash_command_request`] but marks the emitted `SentRequest`
/// event as a queued prompt submission so UI subscribers (e.g. the input editor)
/// don't clear the input buffer on the auto-send.
@@ -168,7 +168,7 @@ impl SlashCommandRequest {
}
}
fn conversation_id(
pub(super) fn conversation_id(
&self,
controller: &BlocklistAIController,
app: &AppContext,