This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user