fix(interrupt): carry image attachments through soft interrupts (fixes #623) (#627)

This commit is contained in:
Jeremy Huang
2026-07-28 01:27:51 -07:00
committed by GitHub
parent b0c8a96623
commit 524032dfd6
30 changed files with 264 additions and 39 deletions
+1
View File
@@ -4,6 +4,7 @@ use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct SoftInterruptMessage {
pub content: String,
pub images: Vec<(String, String)>,
/// If true, can skip remaining tools when injected at point C.
pub urgent: bool,
pub source: SoftInterruptSource,
+44 -12
View File
@@ -119,34 +119,44 @@ impl Agent {
/// Queue a soft interrupt message to be injected at the next safe point.
/// This method can be called even while the agent is processing (uses separate lock).
pub fn queue_soft_interrupt(&self, content: String, urgent: bool, source: SoftInterruptSource) {
pub fn queue_soft_interrupt(
&self,
content: String,
images: Vec<(String, String)>,
urgent: bool,
source: SoftInterruptSource,
) {
let content_bytes = content.len();
let content_chars = content.chars().count();
let image_count = images.len();
if let Ok(mut queue) = self.soft_interrupt_queue.lock() {
let pending_before = queue.len();
queue.push(SoftInterruptMessage {
content,
images,
urgent,
source,
});
logging::info(&format!(
"AGENT_SOFT_INTERRUPT_QUEUE_PUSH session={} source={:?} urgent={} content_bytes={} content_chars={} pending_before={} pending_after={}",
"AGENT_SOFT_INTERRUPT_QUEUE_PUSH session={} source={:?} urgent={} content_bytes={} content_chars={} image_count={} pending_before={} pending_after={}",
self.session_id(),
source,
urgent,
content_bytes,
content_chars,
image_count,
pending_before,
queue.len()
));
} else {
logging::warn(&format!(
"AGENT_SOFT_INTERRUPT_QUEUE_PUSH_FAILED session={} source={:?} urgent={} content_bytes={} content_chars={} reason=queue_lock_poisoned",
"AGENT_SOFT_INTERRUPT_QUEUE_PUSH_FAILED session={} source={:?} urgent={} content_bytes={} content_chars={} image_count={} reason=queue_lock_poisoned",
self.session_id(),
source,
urgent,
content_bytes,
content_chars
content_chars,
image_count
));
}
}
@@ -348,22 +358,31 @@ impl Agent {
let mut injected = Vec::new();
let mut current_source: Option<SoftInterruptSource> = None;
let mut current_parts: Vec<String> = Vec::new();
let mut current_images: Vec<(String, String)> = Vec::new();
let flush_group = |agent: &mut Self,
injected: &mut Vec<InjectedSoftInterrupt>,
source: SoftInterruptSource,
parts: &mut Vec<String>| {
if parts.is_empty() {
parts: &mut Vec<String>,
images: &mut Vec<(String, String)>| {
if parts.is_empty() && images.is_empty() {
return;
}
let content = parts.join("\n\n");
parts.clear();
agent.add_message_with_display_role(
Role::User,
vec![ContentBlock::Text {
let mut blocks: Vec<ContentBlock> = std::mem::take(images)
.into_iter()
.map(|(media_type, data)| ContentBlock::Image { media_type, data })
.collect();
if !content.is_empty() {
blocks.push(ContentBlock::Text {
text: content.clone(),
cache_control: None,
}],
});
}
agent.add_message_with_display_role(
Role::User,
blocks,
soft_interrupt_session_display_role(source),
);
injected.push(InjectedSoftInterrupt { content, source });
@@ -372,17 +391,30 @@ impl Agent {
for message in messages {
match current_source {
Some(source) if source != message.source => {
flush_group(self, &mut injected, source, &mut current_parts);
flush_group(
self,
&mut injected,
source,
&mut current_parts,
&mut current_images,
);
current_source = Some(message.source);
}
None => current_source = Some(message.source),
_ => {}
}
current_parts.push(message.content);
current_images.extend(message.images);
}
if let Some(source) = current_source {
flush_group(self, &mut injected, source, &mut current_parts);
flush_group(
self,
&mut injected,
source,
&mut current_parts,
&mut current_images,
);
}
self.persist_session_best_effort("soft interrupt injection");
+34
View File
@@ -191,6 +191,38 @@ fn tool_output_to_content_blocks_preserves_labeled_images() {
}
}
#[tokio::test]
async fn queued_soft_interrupt_images_are_injected_as_image_blocks() {
let provider: Arc<dyn Provider> = Arc::new(NativeAutoCompactionProvider);
let registry = Registry::new(provider.clone()).await;
let _guard = crate::storage::lock_test_env();
let mut agent = Agent::new(provider, registry);
agent.queue_soft_interrupt(
"look at this".to_string(),
vec![("image/png".to_string(), "ZmFrZQ==".to_string())],
false,
SoftInterruptSource::User,
);
let injected = agent.inject_soft_interrupts();
assert_eq!(injected.len(), 1);
let message = agent
.session
.messages
.last()
.expect("soft interrupt should append a user message");
assert!(matches!(
&message.content[0],
ContentBlock::Image { media_type, data }
if media_type == "image/png" && data == "ZmFrZQ=="
));
assert!(matches!(
&message.content[1],
ContentBlock::Text { text, .. } if text == "look at this"
));
}
#[tokio::test]
async fn run_turn_streaming_mpsc_emits_keepalive_while_provider_is_quiet() {
let _guard = crate::storage::lock_test_env();
@@ -747,6 +779,7 @@ fn seed_transient_session_state(agent: &mut Agent) {
agent.push_alert("pending alert".to_string());
agent.queue_soft_interrupt(
"queued interrupt".to_string(),
Vec::new(),
true,
SoftInterruptSource::User,
);
@@ -1014,6 +1047,7 @@ async fn mark_closed_persists_soft_interrupts_for_restore_after_reload() {
agent.session.save().expect("save active session");
agent.queue_soft_interrupt(
"resume me after reload".to_string(),
Vec::new(),
true,
SoftInterruptSource::System,
);
@@ -106,6 +106,7 @@ impl AmbientRunnerHandle {
{
q.push(SoftInterruptMessage {
content: format!("[{} message from user]\n{}", source, text),
images: Vec::new(),
urgent: false,
source: SoftInterruptSource::User,
});
@@ -1146,11 +1146,13 @@ pub(super) async fn handle_client(
Request::SoftInterrupt {
id,
content,
images,
urgent,
} => {
queue_soft_interrupt(
id,
content,
images,
urgent,
SoftInterruptSource::User,
&session_control,
@@ -3078,6 +3080,7 @@ fn names_only_available_models_event(event: &ServerEvent) -> Option<ServerEvent>
fn queue_soft_interrupt(
id: u64,
content: String,
images: Vec<(String, String)>,
urgent: bool,
source: SoftInterruptSource,
session_control: &SessionControlHandle,
@@ -3089,7 +3092,7 @@ fn queue_soft_interrupt(
"SERVER_SOFT_INTERRUPT_QUEUE_REQUEST id={} session={} source={:?} urgent={} content_bytes={} content_chars={}",
id, session_control.session_id, source, urgent, content_bytes, content_chars
));
let queued = session_control.queue_soft_interrupt(content, urgent, source);
let queued = session_control.queue_soft_interrupt(content, images, urgent, source);
let ack_queued = client_event_tx.send(ServerEvent::Ack { id }).is_ok();
crate::logging::info(&format!(
"SERVER_SOFT_INTERRUPT_QUEUE_RESULT id={} session={} queued={} ack_queued={}",
@@ -21,6 +21,7 @@ pub(super) fn interrupt_request_log_fields(
id,
content,
urgent,
..
} => Some(format!(
"{} urgent={} content_bytes={} content_chars={}",
base("soft_interrupt", *id),
@@ -41,6 +41,7 @@ async fn session_control_handle_does_not_wait_for_busy_agent_lock() {
tokio::time::timeout(Duration::from_millis(100), async {
assert!(control.queue_soft_interrupt(
"please stop".to_string(),
Vec::new(),
true,
SoftInterruptSource::User,
));
@@ -107,6 +107,7 @@ async fn handle_clear_session_replaces_runtime_handles_and_updates_shutdown_regi
.map_err(|_| anyhow!("old queue lock"))?
.push(jcode_agent_runtime::SoftInterruptMessage {
content: "stale queued message".to_string(),
images: Vec::new(),
urgent: false,
source: jcode_agent_runtime::SoftInterruptSource::User,
});
@@ -158,7 +158,12 @@ pub(super) async fn execute_debug_command(
return Err(anyhow::anyhow!("queue_interrupt: requires content"));
}
let agent = agent.lock().await;
agent.queue_soft_interrupt(content.to_string(), false, SoftInterruptSource::User);
agent.queue_soft_interrupt(
content.to_string(),
Vec::new(),
false,
SoftInterruptSource::User,
);
return Ok("queued".to_string());
}
@@ -171,7 +176,12 @@ pub(super) async fn execute_debug_command(
return Err(anyhow::anyhow!("queue_interrupt_urgent: requires content"));
}
let agent = agent.lock().await;
agent.queue_soft_interrupt(content.to_string(), true, SoftInterruptSource::User);
agent.queue_soft_interrupt(
content.to_string(),
Vec::new(),
true,
SoftInterruptSource::User,
);
return Ok("queued (urgent)".to_string());
}
@@ -330,15 +340,19 @@ pub(super) async fn execute_debug_command(
Some(ctx) => ctx.control_handle().await,
None => None,
} {
let _queued =
control.queue_soft_interrupt(content.clone(), true, SoftInterruptSource::User);
let _queued = control.queue_soft_interrupt(
content.clone(),
Vec::new(),
true,
SoftInterruptSource::User,
);
control.request_cancel();
delivered_without_agent_lock = true;
}
if !delivered_without_agent_lock {
let agent = agent.lock().await;
agent.queue_soft_interrupt(content, true, SoftInterruptSource::User);
agent.queue_soft_interrupt(content, Vec::new(), true, SoftInterruptSource::User);
agent.request_graceful_shutdown();
}
return Ok(serde_json::json!({
@@ -512,7 +512,8 @@ impl RelayClient {
queue,
stop_signal,
);
if !control.queue_soft_interrupt(interrupt, true, SoftInterruptSource::User) {
if !control.queue_soft_interrupt(interrupt, Vec::new(), true, SoftInterruptSource::User)
{
anyhow::bail!(
"session '{}' could not accept cancel interrupt",
self.config.session_id
+7 -3
View File
@@ -481,6 +481,7 @@ pub(super) fn session_event_fanout_sender_with_fallback(
pub(super) fn enqueue_soft_interrupt(
queue: &SoftInterruptQueue,
content: String,
images: Vec<(String, String)>,
urgent: bool,
source: SoftInterruptSource,
) -> bool {
@@ -490,6 +491,7 @@ pub(super) fn enqueue_soft_interrupt(
let pending_before = pending.len();
pending.push(SoftInterruptMessage {
content,
images,
urgent,
source,
});
@@ -563,10 +565,11 @@ impl SessionControlHandle {
pub fn queue_soft_interrupt(
&self,
content: String,
images: Vec<(String, String)>,
urgent: bool,
source: SoftInterruptSource,
) -> bool {
enqueue_soft_interrupt(&self.soft_interrupt_queue, content, urgent, source)
enqueue_soft_interrupt(&self.soft_interrupt_queue, content, images, urgent, source)
}
pub fn clear_soft_interrupts(&self) {
@@ -712,7 +715,7 @@ pub(super) async fn queue_soft_interrupt_for_session(
sessions: &super::SessionAgents,
) -> bool {
if let Some(queue) = queues.read().await.get(session_id).cloned() {
return enqueue_soft_interrupt(&queue, content, urgent, source);
return enqueue_soft_interrupt(&queue, content, Vec::new(), urgent, source);
}
let queue = {
@@ -727,7 +730,7 @@ pub(super) async fn queue_soft_interrupt_for_session(
if let Some(queue) = queue {
register_session_interrupt_queue(queues, session_id, queue.clone()).await;
enqueue_soft_interrupt(&queue, content, urgent, source)
enqueue_soft_interrupt(&queue, content, Vec::new(), urgent, source)
} else {
let session_exists = {
let guard = sessions.read().await;
@@ -742,6 +745,7 @@ pub(super) async fn queue_soft_interrupt_for_session(
session_id,
SoftInterruptMessage {
content,
images: Vec::new(),
urgent,
source,
},
@@ -6,6 +6,8 @@ use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PersistedSoftInterrupt {
content: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
images: Vec<(String, String)>,
urgent: bool,
source: PersistedSoftInterruptSource,
}
@@ -42,6 +44,7 @@ impl From<SoftInterruptMessage> for PersistedSoftInterrupt {
fn from(value: SoftInterruptMessage) -> Self {
Self {
content: value.content,
images: value.images,
urgent: value.urgent,
source: value.source.into(),
}
@@ -52,6 +55,7 @@ impl From<PersistedSoftInterrupt> for SoftInterruptMessage {
fn from(value: PersistedSoftInterrupt) -> Self {
Self {
content: value.content,
images: value.images,
urgent: value.urgent,
source: value.source.into(),
}
@@ -12,6 +12,7 @@ fn append_take_and_clear_round_trip() {
session_id,
SoftInterruptMessage {
content: "hello".to_string(),
images: vec![("image/png".to_string(), "ZmFrZQ==".to_string())],
urgent: true,
source: SoftInterruptSource::System,
},
@@ -21,6 +22,7 @@ fn append_take_and_clear_round_trip() {
session_id,
SoftInterruptMessage {
content: "world".to_string(),
images: Vec::new(),
urgent: false,
source: SoftInterruptSource::BackgroundTask,
},
@@ -30,6 +32,10 @@ fn append_take_and_clear_round_trip() {
let loaded = load(session_id).expect("load interrupts");
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].content, "hello");
assert_eq!(
loaded[0].images,
vec![("image/png".to_string(), "ZmFrZQ==".to_string())]
);
assert!(loaded[0].urgent);
assert_eq!(loaded[1].content, "world");
@@ -41,6 +47,7 @@ fn append_take_and_clear_round_trip() {
session_id,
SoftInterruptMessage {
content: "later".to_string(),
images: Vec::new(),
urgent: false,
source: SoftInterruptSource::User,
},
@@ -12,6 +12,38 @@ fn test_request_roundtrip() -> Result<()> {
Ok(())
}
#[test]
fn test_soft_interrupt_images_roundtrip_and_legacy_default() -> Result<()> {
let req = Request::SoftInterrupt {
id: 2,
content: "look at this".to_string(),
images: vec![("image/png".to_string(), "ZmFrZQ==".to_string())],
urgent: true,
};
let json = serde_json::to_string(&req)?;
let decoded = parse_request_json(&json)?;
let Request::SoftInterrupt {
content,
images,
urgent,
..
} = decoded
else {
return Err(anyhow!("wrong request type"));
};
assert_eq!(content, "look at this");
assert_eq!(images, vec![("image/png".to_string(), "ZmFrZQ==".to_string())]);
assert!(urgent);
let legacy = r#"{"type":"soft_interrupt","id":3,"content":"legacy","urgent":false}"#;
let decoded = parse_request_json(legacy)?;
let Request::SoftInterrupt { images, .. } = decoded else {
return Err(anyhow!("wrong legacy request type"));
};
assert!(images.is_empty());
Ok(())
}
#[test]
fn test_compacted_history_request_roundtrip() -> Result<()> {
let req = Request::GetCompactedHistory {
+2
View File
@@ -60,6 +60,8 @@ pub enum Request {
SoftInterrupt {
id: u64,
content: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
images: Vec<(String, String)>,
/// If true, can skip remaining tools at injection point C
#[serde(default)]
urgent: bool,
+2
View File
@@ -1448,6 +1448,8 @@ pub struct App {
active_experimental_feature_notice: Option<String>,
// Message to interleave during processing (set via Ctrl+Enter in queue mode)
interleave_message: Option<String>,
// Image attachments associated with the staged interleave message.
interleave_images: Vec<(String, String)>,
// Message sent as soft interrupt but not yet injected (shown in queue preview until injected)
pending_soft_interrupts: Vec<String>,
// Soft interrupts written to the socket but not yet acknowledged by the server.
+2
View File
@@ -522,6 +522,7 @@ pub(super) fn handle_transfer_command_local(app: &mut App) {
app.pending_transfer_request = true;
if app.is_processing {
app.interleave_message = Some(transfer_pause_message());
app.interleave_images.clear();
app.push_display_message(DisplayMessage::system(
"Queued /transfer. The current session will be asked to pause, then the compacted handoff will open in a new window."
.to_string(),
@@ -830,6 +831,7 @@ pub(super) fn handle_cancel_command(app: &mut App, trimmed: &str) -> bool {
if app.is_processing {
app.cancel_requested = true;
app.interleave_message = None;
app.interleave_images.clear();
app.pending_soft_interrupts.clear();
app.pending_soft_interrupt_requests.clear();
if app.cancel_overnight_for_interrupt() {
@@ -445,6 +445,7 @@ pub(super) fn interrupt_and_queue_synthetic_message(
) {
app.cancel_requested = true;
app.interleave_message = None;
app.interleave_images.clear();
app.pending_soft_interrupts.clear();
app.pending_soft_interrupt_requests.clear();
app.set_status_notice(status_notice);
@@ -153,6 +153,7 @@ impl App {
self.clear_streaming_render_state();
self.queued_messages.clear();
self.interleave_message = None;
self.interleave_images.clear();
self.pending_soft_interrupts.clear();
self.status = ProcessingStatus::Idle;
self.processing_started = None;
@@ -393,6 +394,7 @@ impl App {
self.clear_streaming_render_state();
self.queued_messages.clear();
self.interleave_message = None;
self.interleave_images.clear();
self.pending_soft_interrupts.clear();
self.status = ProcessingStatus::Idle;
self.processing_started = None;
@@ -810,6 +812,7 @@ impl App {
self.clear_streaming_render_state();
self.queued_messages.clear();
self.interleave_message = None;
self.interleave_images.clear();
self.pending_soft_interrupts.clear();
self.status = ProcessingStatus::Idle;
self.processing_started = None;
+1 -1
View File
@@ -173,7 +173,7 @@ impl App {
}
SendAction::Interleave => {
let prepared = input::take_prepared_input(self);
input::stage_local_interleave(self, prepared.expanded);
input::stage_local_interleave(self, prepared.expanded, prepared.images);
self.debug_trace
.record("message", format!("interleave:{}", msg));
format!("OK: interleave message '{}' (injecting now)", msg)
+13 -4
View File
@@ -1340,6 +1340,7 @@ pub(super) fn retrieve_pending_message_for_edit(app: &mut App) -> bool {
if let Some(msg) = app.interleave_message.take()
&& !msg.is_empty()
{
app.pending_images.append(&mut app.interleave_images);
parts.push(msg);
had_pending = true;
}
@@ -1742,7 +1743,7 @@ pub(super) fn handle_alternate_enter(app: &mut App) {
SendAction::Queue => queue_message(app),
SendAction::Interleave => {
let prepared = take_prepared_input(app);
stage_local_interleave(app, prepared.expanded);
stage_local_interleave(app, prepared.expanded, prepared.images);
}
}
}
@@ -2423,6 +2424,7 @@ pub(super) fn handle_global_control_shortcuts(
if app.is_processing {
app.cancel_requested = true;
app.interleave_message = None;
app.interleave_images.clear();
app.pending_soft_interrupts.clear();
app.pending_soft_interrupt_requests.clear();
if app.cancel_overnight_for_interrupt() {
@@ -2462,7 +2464,7 @@ pub(super) fn handle_enter(app: &mut App) -> bool {
SendAction::Queue => queue_message(app),
SendAction::Interleave => {
let prepared = take_prepared_input(app);
stage_local_interleave(app, prepared.expanded);
stage_local_interleave(app, prepared.expanded, prepared.images);
}
}
}
@@ -2552,6 +2554,7 @@ pub(super) fn handle_basic_key(app: &mut App, code: KeyCode) -> bool {
.any(|message| super::commands::is_poke_message(message));
app.cancel_requested = true;
app.interleave_message = None;
app.interleave_images.clear();
app.pending_soft_interrupts.clear();
app.pending_soft_interrupt_requests.clear();
let cancelled_overnight = app.cancel_overnight_for_interrupt();
@@ -2591,8 +2594,13 @@ pub(super) fn take_prepared_input(app: &mut App) -> PreparedInput {
}
}
pub(super) fn stage_local_interleave(app: &mut App, content: String) {
pub(super) fn stage_local_interleave(
app: &mut App,
content: String,
images: Vec<(String, String)>,
) {
app.interleave_message = Some(content);
app.interleave_images = images;
app.set_status_notice("⏭ Sending now (interleave)");
}
@@ -3050,9 +3058,10 @@ impl App {
pub(super) async fn send_interleave_now(
&mut self,
content: String,
images: Vec<(String, String)>,
remote: &mut crate::tui::backend::RemoteConnection,
) {
remote::send_interleave_now(self, content, remote).await;
remote::send_interleave_now(self, content, images, remote).await;
}
/// Retrieve all pending unsent messages into the input for editing.
+1
View File
@@ -514,6 +514,7 @@ pub(super) fn finish_turn(app: &mut App) {
app.stream_message_ended = false;
app.processing_started = None;
app.interleave_message = None;
app.interleave_images.clear();
app.pending_soft_interrupts.clear();
app.pending_soft_interrupt_requests.clear();
app.thought_line_inserted = false;
+17 -3
View File
@@ -1366,8 +1366,12 @@ pub(super) async fn process_remote_followups(app: &mut App, remote: &mut RemoteC
if let Some(interleave_msg) = app.interleave_message.take()
&& !interleave_msg.trim().is_empty()
{
let interleave_images = std::mem::take(&mut app.interleave_images);
let msg_clone = interleave_msg.clone();
match remote.soft_interrupt(interleave_msg, false).await {
match remote
.soft_interrupt(interleave_msg, interleave_images, false)
.await
{
Err(e) => {
app.push_display_message(DisplayMessage::error(format!(
"Failed to queue soft interrupt: {}",
@@ -1383,6 +1387,7 @@ pub(super) async fn process_remote_followups(app: &mut App, remote: &mut RemoteC
}
if let Some(interleave_msg) = app.interleave_message.take() {
let interleave_images = std::mem::take(&mut app.interleave_images);
if !interleave_msg.trim().is_empty() {
app.push_display_message(DisplayMessage {
role: "user".to_string(),
@@ -1392,8 +1397,17 @@ pub(super) async fn process_remote_followups(app: &mut App, remote: &mut RemoteC
title: None,
tool_data: None,
});
if let Err(e) =
begin_remote_send(app, remote, interleave_msg, vec![], false, None, false, 0).await
if let Err(e) = begin_remote_send(
app,
remote,
interleave_msg,
interleave_images,
false,
None,
false,
0,
)
.await
{
app.push_display_message(DisplayMessage::error(format!(
"Failed to send message: {}",
@@ -356,7 +356,7 @@ fn submit_transcript_input(app: &mut App) {
SendAction::Queue => queue_transcript_input(app),
SendAction::Interleave => {
let prepared = input::take_prepared_input(app);
input::stage_local_interleave(app, prepared.expanded);
input::stage_local_interleave(app, prepared.expanded, prepared.images);
}
}
}
@@ -403,7 +403,8 @@ async fn submit_remote_transcript_input(
SendAction::Queue => queue_transcript_input(app),
SendAction::Interleave => {
let prepared = input::take_prepared_input(app);
app.send_interleave_now(prepared.expanded, remote).await;
app.send_interleave_now(prepared.expanded, prepared.images, remote)
.await;
}
}
@@ -520,6 +521,11 @@ pub(in crate::tui::app) fn stage_turn_for_remote_tick_loop(app: &mut App, input:
if !app.is_remote {
return false;
}
if app.is_processing && !app.queue_mode {
let images = std::mem::take(&mut app.pending_images);
input::stage_local_interleave(app, input.to_string(), images);
return true;
}
app.queued_messages.push(input.to_string());
app.pending_images.clear();
true
@@ -10,13 +10,14 @@ pub(in crate::tui::app) fn handle_remote_char_input(app: &mut App, c: char) {
pub(in crate::tui::app) async fn send_interleave_now(
app: &mut App,
content: String,
images: Vec<(String, String)>,
remote: &mut RemoteConnection,
) {
if content.trim().is_empty() {
return;
}
let msg_clone = content.clone();
match remote.soft_interrupt(content, false).await {
match remote.soft_interrupt(content, images, false).await {
Err(e) => {
app.push_display_message(DisplayMessage::error(format!(
"Failed to send interleave: {}",
@@ -772,7 +773,8 @@ async fn handle_remote_key_internal(
app.queued_messages.push(prepared.expanded);
}
SendAction::Interleave => {
app.send_interleave_now(prepared.expanded, remote).await;
app.send_interleave_now(prepared.expanded, prepared.images, remote)
.await;
}
}
}
@@ -1876,7 +1878,10 @@ async fn handle_remote_key_internal(
if app.is_processing {
let pause_message = app_mod::commands::transfer_pause_message();
let pause_display = pause_message.clone();
match remote.soft_interrupt(pause_message, false).await {
match remote
.soft_interrupt(pause_message, Vec::new(), false)
.await
{
Ok(request_id) => {
app.track_pending_soft_interrupt(request_id, pause_display);
app.pending_transfer_request = true;
@@ -1973,7 +1978,10 @@ async fn handle_remote_key_internal(
};
if app.is_processing {
app.push_display_message(DisplayMessage::system(launch_notice(true)));
match remote.soft_interrupt(prompt.clone(), false).await {
match remote
.soft_interrupt(prompt.clone(), Vec::new(), false)
.await
{
Ok(request_id) => {
app.track_pending_soft_interrupt(request_id, prompt);
app.set_status_notice(format!("Interrupting for {}...", cmd_label));
@@ -2552,7 +2560,8 @@ async fn handle_remote_key_internal(
app.queued_messages.push(prepared.expanded);
}
SendAction::Interleave => {
app.send_interleave_now(prepared.expanded, remote).await;
app.send_interleave_now(prepared.expanded, prepared.images, remote)
.await;
}
}
}
@@ -1658,6 +1658,7 @@ pub(in crate::tui::app) fn handle_server_event(
if prev_session_id.is_some() {
app.queued_messages.clear();
app.interleave_message = None;
app.interleave_images.clear();
app.clear_pending_soft_interrupt_tracking();
}
app.remote_total_tokens = None;
@@ -1657,6 +1657,23 @@ fn test_send_action_modes() {
assert_eq!(app.send_action(false), SendAction::Submit);
}
#[test]
fn test_interleave_submission_preserves_pending_images() {
let mut app = create_test_app();
app.is_processing = true;
app.queue_mode = false;
app.input = "[image 1] describe this".to_string();
app.cursor_pos = app.input.len();
let images = vec![("image/png".to_string(), "ZmFrZQ==".to_string())];
app.pending_images = images.clone();
assert!(input::handle_enter(&mut app));
assert_eq!(app.interleave_message.as_deref(), Some("[image 1] describe this"));
assert_eq!(app.interleave_images, images);
assert!(app.pending_images.is_empty());
}
#[test]
fn test_send_action_submits_bang_commands_while_processing() {
let mut app = create_test_app();
@@ -28,6 +28,7 @@ impl App {
self.push_display_message(DisplayMessage::system(message).with_title(title));
}
self.interleave_message = None;
self.interleave_images.clear();
self.rate_limit_pending_message = restored.rate_limit_pending_message;
self.rate_limit_reset = restored.rate_limit_reset;
self.observe_page_markdown = restored.observe_page_markdown;
@@ -638,6 +639,7 @@ impl App {
experimental_feature_warnings_seen: HashSet::new(),
active_experimental_feature_notice: None,
interleave_message: None,
interleave_images: Vec::new(),
pending_soft_interrupts: Vec::new(),
pending_soft_interrupt_requests: Vec::new(),
autoreview_after_current_turn: false,
@@ -1069,6 +1071,7 @@ impl App {
experimental_feature_warnings_seen: HashSet::new(),
active_experimental_feature_notice: None,
interleave_message: None,
interleave_images: Vec::new(),
pending_soft_interrupts: Vec::new(),
pending_soft_interrupt_requests: Vec::new(),
autoreview_after_current_turn: false,
+13 -1
View File
@@ -136,6 +136,7 @@ impl App {
if self.cancel_requested {
self.cancel_requested = false;
self.interleave_message = None;
self.interleave_images.clear();
self.pending_soft_interrupts.clear();
self.pending_soft_interrupt_requests.clear();
self.clear_streaming_render_state();
@@ -302,6 +303,7 @@ impl App {
if self.cancel_requested {
self.cancel_requested = false;
self.interleave_message = None;
self.interleave_images.clear();
self.pending_soft_interrupts.clear();
self.pending_soft_interrupt_requests.clear();
// Save partial assistant response before clearing
@@ -370,6 +372,8 @@ impl App {
}
// Check for interleave request (Shift+Enter)
if let Some(interleave_msg) = self.interleave_message.take() {
let interleave_images =
std::mem::take(&mut self.interleave_images);
// Save partial assistant response if any
if !text_content.is_empty() || !tool_calls.is_empty() {
// Complete any pending tool
@@ -426,7 +430,14 @@ impl App {
}
}
// Add user's interleaved message
self.add_provider_message(Message::user(&interleave_msg));
if interleave_images.is_empty() {
self.add_provider_message(Message::user(&interleave_msg));
} else {
self.add_provider_message(Message::user_with_images(
&interleave_msg,
interleave_images,
));
}
self.push_display_message(DisplayMessage {
role: "user".to_string(),
content: interleave_msg,
@@ -1306,6 +1317,7 @@ impl App {
if self.cancel_requested {
self.cancel_requested = false;
self.interleave_message = None;
self.interleave_images.clear();
self.pending_soft_interrupts.clear();
self.pending_soft_interrupt_requests.clear();
// Partial text+tool_calls were already saved
+8 -1
View File
@@ -421,6 +421,7 @@ impl RemoteConnection {
id,
content,
urgent,
..
} => Some(format!(
"{} urgent={} content_bytes={} content_chars={}",
base("soft_interrupt", *id),
@@ -873,11 +874,17 @@ impl RemoteConnection {
/// Queue a soft interrupt message to be injected at the next safe point
/// This doesn't cancel anything - the message is naturally incorporated
pub async fn soft_interrupt(&mut self, content: String, urgent: bool) -> Result<u64> {
pub async fn soft_interrupt(
&mut self,
content: String,
images: Vec<(String, String)>,
urgent: bool,
) -> Result<u64> {
let id = self.next_request_id;
let request = Request::SoftInterrupt {
id,
content,
images,
urgent,
};
self.next_request_id += 1;