diff --git a/CHANGELOG.md b/CHANGELOG.md index 976b4b6..81d8f47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Fixed + +- Images read by an agent (Read tool) are shown right away; with Claude Code they were dropped. + ## [v202611-00030] – 2026-11-15 ### Added diff --git a/server/src/claude_code.rs b/server/src/claude_code.rs index d69cdb9..9672f32 100644 --- a/server/src/claude_code.rs +++ b/server/src/claude_code.rs @@ -11,7 +11,7 @@ use crate::config::ModelConfig; use crate::permission::{Decision, Evaluator, ToolInfo, bash_prefix, is_dangerous, split_command}; use crate::provider::{ChatRequest, Provider, StreamEvent}; use crate::store::UsageRecord; -use crate::types::{Event, Message, Part, Role, Session, Usage, new_id, now_ms}; +use crate::types::{Event, ImageData, Message, Part, Role, Session, Usage, new_id, now_ms}; use anyhow::{Result, anyhow, bail}; use async_trait::async_trait; use serde_json::{Value, json}; @@ -286,6 +286,20 @@ fn result_text(content: &Value) -> String { } } +/// Images inside a tool result (the CLI's Read of a picture): base64 blocks. +fn result_images(content: &Value) -> Vec { + content + .as_array() + .into_iter() + .flatten() + .filter(|b| b["type"] == "image" && b["source"]["type"] == "base64") + .filter_map(|b| { + let data = b["source"]["data"].as_str().filter(|d| !d.is_empty())?; + Some(ImageData { media_type: b["source"]["media_type"].as_str().unwrap_or("image/png").to_string(), data: data.to_string(), blob: None, size: None }) + }) + .collect() +} + /// Images of the prompt as files the CLI can read (it has no inline image input in this mode). fn save_images(cwd: &str, parts: &[Part]) -> Vec { use base64::Engine; @@ -458,7 +472,7 @@ pub async fn run(app: Arc, session_id: &str, cancel: CancellationToken) -> let id = b["tool_use_id"].as_str().unwrap_or("").to_string(); let is_error = b["is_error"].as_bool().unwrap_or(false); let meta = if is_error { None } else { diffs.remove(&id).map(|d| json!({"diff": d})) }; - parts.push(Part::ToolResult { name: calls.get(&id).cloned().unwrap_or_default(), call_id: id, content: result_text(&b["content"]), images: vec![], is_error, meta }); + parts.push(Part::ToolResult { name: calls.get(&id).cloned().unwrap_or_default(), call_id: id, content: result_text(&b["content"]), images: result_images(&b["content"]), is_error, meta }); } if !parts.is_empty() { let m = Message::new(Role::User, parts); @@ -852,6 +866,15 @@ pub async fn update() -> Result { #[cfg(test)] mod tests { + #[test] + fn tool_result_images_are_kept() { + let content = json!([{"type": "text", "text": "ok"}, {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "QUJD"}}]); + let imgs = result_images(&content); + assert_eq!(imgs.len(), 1); + assert_eq!((imgs[0].media_type.as_str(), imgs[0].data.as_str()), ("image/jpeg", "QUJD")); + assert!(result_images(&json!("plain text")).is_empty()); + } + use super::*; #[test] diff --git a/web/src/components/ToolCard.tsx b/web/src/components/ToolCard.tsx index c5baedf..535b9b0 100644 --- a/web/src/components/ToolCard.tsx +++ b/web/src/components/ToolCard.tsx @@ -121,7 +121,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0; const openByDefault = (status === "err" && !!result) || - (!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image" || call.name === "generate_3d")); + (!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || (result.images?.length ?? 0) > 0 || call.name === "generate_image" || call.name === "generate_3d")); const open = toggled ?? openByDefault; return ( diff --git a/web/src/whatsnew.ts b/web/src/whatsnew.ts index 63a6110..88804f5 100644 --- a/web/src/whatsnew.ts +++ b/web/src/whatsnew.ts @@ -3,6 +3,10 @@ * under "unreleased"; scripts/release.sh renames the key to the release version. */ export const WHATS_NEW: Record = { + unreleased: { + en: ["Images an agent reads are now shown in the chat (also with Claude Code)"], + de: ["Bilder, die ein Agent liest, werden jetzt im Chat angezeigt (auch mit Claude Code)"], + }, "202611-00030": { en: ["/clear: the model forgets the conversation so far, the chat stays visible"], de: ["/clear: das Modell vergisst das bisherige Gespräch, der Chat bleibt sichtbar"],