From 4e7158a5ce92bb29435d21579ec949114b1a77c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20M=C3=BCller?= Date: Thu, 1 Oct 2026 10:21:28 -0700 Subject: [PATCH] fix(acp): image prompt blocks reach the daemon session (#3248) --- crates/pa-cli/tests/acp_mode_e2e.rs | 54 +++++++++++++++++++ crates/pa-daemon/src/acp/daemon.rs | 11 +++- crates/pa-daemon/src/worker/input.rs | 5 +- .../src/worker/prompt_image_tests.rs | 5 ++ 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/crates/pa-cli/tests/acp_mode_e2e.rs b/crates/pa-cli/tests/acp_mode_e2e.rs index 0dba9a772..bb60b2c0f 100644 --- a/crates/pa-cli/tests/acp_mode_e2e.rs +++ b/crates/pa-cli/tests/acp_mode_e2e.rs @@ -893,6 +893,60 @@ fn acp_daemon_attached_prompt_after_cancel_runs() { ); } +#[test] +fn acp_daemon_attached_image_only_prompt_reaches_the_session() { + let (mut client, socket) = AcpChild::spawn_daemon_attached( + &["--mode", "acp", "--no-session"], + &json!({ "engine": "faux", "responses": ["SAW-IMAGE"] }), + ); + let init = client.request("initialize", &initialize_params()); + let _ = client.wait_response(init, TIMEOUT); + let new = client.request("session/new", &json!({ "mcpServers": [] })); + let (new_response, _) = client.wait_response(new, TIMEOUT); + let session_id = new_response["result"]["sessionId"] + .as_str() + .unwrap() + .to_string(); + // 1x1 PNG + let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + let prompt = client.request( + "session/prompt", + &json!({ + "sessionId": session_id, + "prompt": [ + { "type": "image", "data": png, "mimeType": "image/png" }, + ], + }), + ); + let (prompt_response, _) = client.wait_response(prompt, TIMEOUT); + assert_eq!( + prompt_response["result"]["stopReason"], "end_turn", + "an image-only prompt runs a turn: {prompt_response}" + ); + // The stored user row carries the image: an empty text block first, + // then the images (TS `_buildPromptContent`). + let list = daemon_request(&socket, "img-list", &json!({ "type": "list" })); + let active_session_id = &list["data"]["sessions"][0]["activeSessionId"]; + let messages = daemon_request( + &socket, + "img-msgs", + &json!({ "type": "get_messages", "activeSessionId": active_session_id }), + ); + let user = messages["data"]["messages"] + .as_array() + .unwrap() + .iter() + .find(|m| m["role"] == "user") + .unwrap(); + assert_eq!( + user["content"], + json!([ + { "type": "text", "text": "" }, + { "type": "image", "data": png, "mimeType": "image/png" }, + ]) + ); +} + /// Stop the sandboxed supervisor a test spawned (the shared-daemon /// product behavior leaves it running; a test owns its sandbox). fn shutdown_sandboxed_daemon(socket: &std::path::Path) { diff --git a/crates/pa-daemon/src/acp/daemon.rs b/crates/pa-daemon/src/acp/daemon.rs index 56de9fafd..17f37ed35 100644 --- a/crates/pa-daemon/src/acp/daemon.rs +++ b/crates/pa-daemon/src/acp/daemon.rs @@ -796,7 +796,16 @@ async fn handle_session_prompt( message: admitted.text, input: pa_types::daemon::PromptInput { content: None, - images: None, + // The image blocks ride the wire form `parse_prompt_images` + // reads (`{type, data, mimeType}`); TS forwards `images` + // only when the prompt carries any. + images: (!admitted.images.is_empty()).then(|| { + json!(admitted + .images + .into_iter() + .map(|image| json!({ "type": "image", "data": image.data, "mimeType": image.mime_type })) + .collect::>()) + }), // TS sends `followUp` + `queueIfBusy: true` on every ACP // prompt (acp-mode.ts): a prompt carrying a streaming // behavior is the worker's resume site for the post-abort diff --git a/crates/pa-daemon/src/worker/input.rs b/crates/pa-daemon/src/worker/input.rs index 6ab574780..c26c61c32 100644 --- a/crates/pa-daemon/src/worker/input.rs +++ b/crates/pa-daemon/src/worker/input.rs @@ -20,7 +20,9 @@ impl Worker { .get("message") .and_then(Value::as_str) .unwrap_or_default(); - if message.is_empty() { + // TS has no empty check; this port still rejects a prompt with neither text nor images. + let images = parse_prompt_images(payload); + if message.is_empty() && images.is_empty() { return response_failure(None, "prompt", "Prompt cannot be empty", None); } let streaming_behavior = payload.get("streamingBehavior").and_then(Value::as_str); @@ -42,7 +44,6 @@ impl Worker { ); } } - let images = parse_prompt_images(payload); // TS daemon prompts map `resumeIfIdle` to // `command.streamingBehavior !== undefined`: while the queued-input // suspension is set (post `abort`/manual `compact`), a plain prompt diff --git a/crates/pa-daemon/src/worker/prompt_image_tests.rs b/crates/pa-daemon/src/worker/prompt_image_tests.rs index 6f3ca8aeb..3fcebbaf9 100644 --- a/crates/pa-daemon/src/worker/prompt_image_tests.rs +++ b/crates/pa-daemon/src/worker/prompt_image_tests.rs @@ -86,4 +86,9 @@ async fn prompt_with_images_queues_the_images_with_the_message() { }], "the attachment rides the queue item" ); + // A prompt with neither text nor images stays rejected (this port's + // guard; the TS worker has no empty check). + let rejected = worker.dispatch("prompt", &json!({ "message": "" })).await; + assert!(!rejected.success); + assert_eq!(rejected.error.as_deref(), Some("Prompt cannot be empty")); } -- knotmirror 0.0.1