Skip to content

Commit e570752

Browse files
authored
feat(api): forward pasted images through the claude CLI transport (#159)
Image blocks from the latest user message ride the stream-json stdin envelope in the same Anthropic wire shape the direct API path uses, placed before the text block. Image-only prompts omit the empty text block. Earlier turns' images are not re-sent: a resumed session already has them and a flattened restart lost the non-text content. Capability image_input flips to true for the CLI transport.
1 parent f8e1851 commit e570752

1 file changed

Lines changed: 125 additions & 10 deletions

File tree

src-rust/crates/api/src/providers/claude_cli.rs

Lines changed: 125 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99
//
1010
// Delegation model: the CLI runs its own agent loop (tools, permissions,
1111
// context) in the current working directory. This provider forwards the
12-
// user's prompt, streams Claude's text back, and renders the CLI's tool
13-
// activity as one-line notices. It never emits `ToolUse` blocks, so the
12+
// user's prompt (with any pasted images as standard Anthropic image blocks),
13+
// streams Claude's text back, and renders the CLI's tool activity as
14+
// one-line notices. It never emits `ToolUse` blocks, so the
1415
// query loop treats every turn as self-contained (`end_turn`).
1516
//
1617
// Session continuity: the CLI's `session_id` (from the `init` envelope) is
@@ -105,6 +106,27 @@ fn last_user_text(request: &ProviderRequest) -> String {
105106
.unwrap_or_default()
106107
}
107108

109+
/// Image blocks from the latest user message, forwarded to the CLI alongside
110+
/// the text prompt. Images from earlier turns are not re-sent: on a resumed
111+
/// session the CLI already has them, and on a flattened restart they are gone
112+
/// with the rest of the non-text content.
113+
fn last_user_images(request: &ProviderRequest) -> Vec<ContentBlock> {
114+
request
115+
.messages
116+
.iter()
117+
.rev()
118+
.find(|message| matches!(message.role, Role::User))
119+
.map(|message| match &message.content {
120+
MessageContent::Blocks(blocks) => blocks
121+
.iter()
122+
.filter(|block| matches!(block, ContentBlock::Image { .. }))
123+
.cloned()
124+
.collect(),
125+
MessageContent::Text(_) => Vec::new(),
126+
})
127+
.unwrap_or_default()
128+
}
129+
108130
/// Flatten the whole transcript into one prompt for sessions that cannot be
109131
/// resumed (the CLI has no matching session to `--resume`).
110132
fn flattened_transcript(request: &ProviderRequest) -> String {
@@ -167,13 +189,22 @@ fn build_args(model: &str, resume: Option<&str>) -> Vec<String> {
167189
args
168190
}
169191

170-
/// The single stream-json stdin line carrying the user prompt.
171-
fn stdin_line(prompt: &str) -> String {
192+
/// The single stream-json stdin line carrying the user prompt. Image blocks
193+
/// ride along in the same Anthropic wire shape the API takes, placed before
194+
/// the text to match the direct `anthropic.rs` path.
195+
fn stdin_line(prompt: &str, images: &[ContentBlock]) -> String {
196+
let mut content: Vec<Value> = images
197+
.iter()
198+
.filter_map(|block| serde_json::to_value(block).ok())
199+
.collect();
200+
if !prompt.is_empty() || content.is_empty() {
201+
content.push(json!({"type": "text", "text": prompt}));
202+
}
172203
let envelope = json!({
173204
"type": "user",
174205
"message": {
175206
"role": "user",
176-
"content": [{"type": "text", "text": prompt}],
207+
"content": content,
177208
},
178209
});
179210
format!("{}\n", envelope)
@@ -481,6 +512,7 @@ impl ClaudeCliProvider {
481512
&self,
482513
model: &str,
483514
prompt: &str,
515+
images: &[ContentBlock],
484516
resume: Option<&str>,
485517
) -> Result<SpawnOutcome, ProviderError> {
486518
let mut command = Command::new(self.binary_or_default());
@@ -498,7 +530,7 @@ impl ClaudeCliProvider {
498530
status: None,
499531
body: None,
500532
})?;
501-
let payload = stdin_line(prompt);
533+
let payload = stdin_line(prompt, images);
502534
if let Err(e) = stdin.write_all(payload.as_bytes()).await {
503535
warn!(error = %e, "failed writing prompt to claude CLI stdin");
504536
}
@@ -565,9 +597,15 @@ impl ClaudeCliProvider {
565597
None
566598
};
567599

600+
let images = last_user_images(request);
568601
if let Some(session_id) = resume_session {
569602
match self
570-
.spawn_turn(&request.model, &last_user_text(request), Some(&session_id))
603+
.spawn_turn(
604+
&request.model,
605+
&last_user_text(request),
606+
&images,
607+
Some(&session_id),
608+
)
571609
.await?
572610
{
573611
SpawnOutcome::Turn(turn) => return Ok(*turn),
@@ -584,7 +622,10 @@ impl ClaudeCliProvider {
584622
} else {
585623
last_user_text(request)
586624
};
587-
match self.spawn_turn(&request.model, &prompt, None).await? {
625+
match self
626+
.spawn_turn(&request.model, &prompt, &images, None)
627+
.await?
628+
{
588629
SpawnOutcome::Turn(turn) => Ok(*turn),
589630
SpawnOutcome::ExitedEarly { stderr } => {
590631
let detail = collect_stderr(&stderr).await;
@@ -762,7 +803,9 @@ impl LlmProvider for ClaudeCliProvider {
762803
// The CLI runs its own tools; Coven Code's tool loop stays out.
763804
tool_calling: false,
764805
thinking: false,
765-
image_input: false,
806+
// Pasted images are forwarded on stdin as standard Anthropic
807+
// image blocks; the CLI accepts them like the API does.
808+
image_input: true,
766809
pdf_input: false,
767810
audio_input: false,
768811
video_input: false,
@@ -820,13 +863,85 @@ mod tests {
820863

821864
#[test]
822865
fn stdin_line_is_a_stream_json_user_envelope() {
823-
let line = stdin_line("hello");
866+
let line = stdin_line("hello", &[]);
824867
let parsed: Value = serde_json::from_str(line.trim()).expect("valid JSON");
825868
assert_eq!(parsed["type"], "user");
826869
assert_eq!(parsed["message"]["content"][0]["text"], "hello");
827870
assert!(line.ends_with('\n'));
828871
}
829872

873+
fn png_block(data: &str) -> ContentBlock {
874+
ContentBlock::Image {
875+
source: claurst_core::types::ImageSource {
876+
source_type: "base64".to_string(),
877+
media_type: Some("image/png".to_string()),
878+
data: Some(data.to_string()),
879+
url: None,
880+
},
881+
}
882+
}
883+
884+
#[test]
885+
fn stdin_line_places_image_blocks_before_the_text() {
886+
let line = stdin_line("what is this?", &[png_block("aGk=")]);
887+
let parsed: Value = serde_json::from_str(line.trim()).expect("valid JSON");
888+
let content = parsed["message"]["content"]
889+
.as_array()
890+
.expect("content array");
891+
assert_eq!(content.len(), 2);
892+
assert_eq!(content[0]["type"], "image");
893+
assert_eq!(content[0]["source"]["type"], "base64");
894+
assert_eq!(content[0]["source"]["media_type"], "image/png");
895+
assert_eq!(content[0]["source"]["data"], "aGk=");
896+
assert_eq!(content[1]["type"], "text");
897+
assert_eq!(content[1]["text"], "what is this?");
898+
}
899+
900+
#[test]
901+
fn stdin_line_omits_the_text_block_for_image_only_prompts() {
902+
let line = stdin_line("", &[png_block("aGk=")]);
903+
let parsed: Value = serde_json::from_str(line.trim()).expect("valid JSON");
904+
let content = parsed["message"]["content"]
905+
.as_array()
906+
.expect("content array");
907+
assert_eq!(content.len(), 1);
908+
assert_eq!(content[0]["type"], "image");
909+
}
910+
911+
#[test]
912+
fn last_user_images_reads_only_the_latest_user_message() {
913+
let request = request_with(vec![
914+
Message::user_blocks(vec![
915+
png_block("b2xk"),
916+
ContentBlock::Text {
917+
text: "earlier".to_string(),
918+
},
919+
]),
920+
Message {
921+
role: Role::Assistant,
922+
content: MessageContent::Text("reply".to_string()),
923+
uuid: None,
924+
cost: None,
925+
snapshot_patch: None,
926+
},
927+
Message::user_blocks(vec![
928+
png_block("bmV3"),
929+
ContentBlock::Text {
930+
text: "latest".to_string(),
931+
},
932+
]),
933+
]);
934+
let images = last_user_images(&request);
935+
assert_eq!(images.len(), 1);
936+
assert!(matches!(
937+
&images[0],
938+
ContentBlock::Image { source } if source.data.as_deref() == Some("bmV3")
939+
));
940+
941+
let text_only = request_with(vec![Message::user("no images")]);
942+
assert!(last_user_images(&text_only).is_empty());
943+
}
944+
830945
#[test]
831946
fn last_user_text_reads_text_and_blocks() {
832947
let request = request_with(vec![

0 commit comments

Comments
 (0)