mirror of
https://github.com/zed-industries/zed.git
synced 2026-07-09 16:00:35 +00:00
acp: Support embedded resources in tool call content (#59821)
We weren't handling these really before, just rendering the URI. This should provide best-effort support for what we can render. <img width="800" height="359" alt="image" src="https://github.com/user-attachments/assets/51f101c7-c3be-4801-9000-62000a71faa6" /> (I will remove the extra labels that was just for testing) Release Notes: - acp: Support embedded resources in tool calls.
This commit is contained in:
parent
697cff57bc
commit
2df74932bc
6 changed files with 552 additions and 88 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -116,6 +116,7 @@ dependencies = [
|
|||
"language_model",
|
||||
"log",
|
||||
"markdown",
|
||||
"mime",
|
||||
"multi_buffer",
|
||||
"parking_lot",
|
||||
"portable-pty",
|
||||
|
|
|
|||
|
|
@ -392,6 +392,7 @@ mermaid_render = { path = "crates/mermaid_render" }
|
|||
svg_preview = { path = "crates/svg_preview" }
|
||||
media = { path = "crates/media" }
|
||||
menu = { path = "crates/menu" }
|
||||
mime = "0.3.17"
|
||||
migrator = { path = "crates/migrator" }
|
||||
mistral = { path = "crates/mistral" }
|
||||
multi_buffer = { path = "crates/multi_buffer" }
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ language.workspace = true
|
|||
language_model.workspace = true
|
||||
log.workspace = true
|
||||
markdown.workspace = true
|
||||
mime.workspace = true
|
||||
parking_lot = { workspace = true, optional = true }
|
||||
image.workspace = true
|
||||
portable-pty.workspace = true
|
||||
|
|
|
|||
|
|
@ -957,6 +957,10 @@ pub enum ContentBlock {
|
|||
Markdown {
|
||||
markdown: Entity<Markdown>,
|
||||
},
|
||||
EmbeddedResource {
|
||||
resource: acp::EmbeddedResource,
|
||||
markdown: Option<Entity<Markdown>>,
|
||||
},
|
||||
ResourceLink {
|
||||
resource_link: acp::ResourceLink,
|
||||
},
|
||||
|
|
@ -991,6 +995,26 @@ impl ContentBlock {
|
|||
this
|
||||
}
|
||||
|
||||
pub fn new_tool_call_content(
|
||||
block: acp::ContentBlock,
|
||||
language_registry: &Arc<LanguageRegistry>,
|
||||
path_style: PathStyle,
|
||||
cx: &mut App,
|
||||
) -> Self {
|
||||
match block {
|
||||
acp::ContentBlock::Resource(resource) => {
|
||||
if let Some((image, dimensions)) = Self::decode_embedded_resource_image(&resource) {
|
||||
Self::Image { image, dimensions }
|
||||
} else {
|
||||
let markdown =
|
||||
Self::embedded_resource_markdown(&resource, language_registry, cx);
|
||||
Self::EmbeddedResource { resource, markdown }
|
||||
}
|
||||
}
|
||||
block => Self::new(block, language_registry, path_style, cx),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn append(
|
||||
&mut self,
|
||||
block: acp::ContentBlock,
|
||||
|
|
@ -1026,6 +1050,13 @@ impl ContentBlock {
|
|||
let combined = format!("{}\n{}", existing_content, new_content);
|
||||
*self = Self::create_markdown_block(combined, language_registry, cx);
|
||||
}
|
||||
(ContentBlock::EmbeddedResource { resource, .. }, _) => {
|
||||
let existing_content =
|
||||
Self::embedded_resource_string_contents(resource, path_style);
|
||||
let new_content = Self::block_string_contents(&block, path_style);
|
||||
let combined = format!("{}\n{}", existing_content, new_content);
|
||||
*self = Self::create_markdown_block(combined, language_registry, cx);
|
||||
}
|
||||
(ContentBlock::Image { .. }, _) => {
|
||||
let new_content = Self::block_string_contents(&block, path_style);
|
||||
let combined = format!("`Image`\n{}", new_content);
|
||||
|
|
@ -1063,13 +1094,30 @@ impl ContentBlock {
|
|||
|
||||
fn decode_image(
|
||||
image_content: &acp::ImageContent,
|
||||
) -> Option<(Arc<gpui::Image>, Option<gpui::Size<u32>>)> {
|
||||
Self::decode_image_data(&image_content.data, &image_content.mime_type)
|
||||
}
|
||||
|
||||
fn decode_embedded_resource_image(
|
||||
resource: &acp::EmbeddedResource,
|
||||
) -> Option<(Arc<gpui::Image>, Option<gpui::Size<u32>>)> {
|
||||
let acp::EmbeddedResourceResource::BlobResourceContents(blob) = &resource.resource else {
|
||||
return None;
|
||||
};
|
||||
let mime_type = blob.mime_type.as_deref()?;
|
||||
Self::decode_image_data(&blob.blob, mime_type)
|
||||
}
|
||||
|
||||
fn decode_image_data(
|
||||
data: &str,
|
||||
mime_type: &str,
|
||||
) -> Option<(Arc<gpui::Image>, Option<gpui::Size<u32>>)> {
|
||||
use base64::Engine as _;
|
||||
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(image_content.data.as_bytes())
|
||||
.decode(data.as_bytes())
|
||||
.ok()?;
|
||||
let format = gpui::ImageFormat::from_mime_type(&image_content.mime_type)?;
|
||||
let format = gpui::ImageFormat::from_mime_type(mime_type)?;
|
||||
let dimensions = Self::image_dimensions(&bytes, format);
|
||||
Some((Arc::new(gpui::Image::from_bytes(format, bytes)), dimensions))
|
||||
}
|
||||
|
|
@ -1099,19 +1147,141 @@ impl ContentBlock {
|
|||
cx: &mut App,
|
||||
) -> ContentBlock {
|
||||
ContentBlock::Markdown {
|
||||
markdown: cx.new(|cx| {
|
||||
Markdown::new_with_options(
|
||||
content.into(),
|
||||
Some(language_registry.clone()),
|
||||
None,
|
||||
MarkdownOptions {
|
||||
render_mermaid_diagrams: true,
|
||||
render_metadata_blocks: true,
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
markdown: Self::create_markdown(content, language_registry, cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_markdown(
|
||||
content: String,
|
||||
language_registry: &Arc<LanguageRegistry>,
|
||||
cx: &mut App,
|
||||
) -> Entity<Markdown> {
|
||||
cx.new(|cx| {
|
||||
Markdown::new_with_options(
|
||||
content.into(),
|
||||
Some(language_registry.clone()),
|
||||
None,
|
||||
MarkdownOptions {
|
||||
render_mermaid_diagrams: true,
|
||||
render_metadata_blocks: true,
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn embedded_resource_markdown(
|
||||
resource: &acp::EmbeddedResource,
|
||||
language_registry: &Arc<LanguageRegistry>,
|
||||
cx: &mut App,
|
||||
) -> Option<Entity<Markdown>> {
|
||||
match &resource.resource {
|
||||
acp::EmbeddedResourceResource::TextResourceContents(text) => Some(
|
||||
Self::create_markdown(Self::text_resource_markdown(text), language_registry, cx),
|
||||
),
|
||||
acp::EmbeddedResourceResource::BlobResourceContents(_) => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn text_resource_markdown(resource: &acp::TextResourceContents) -> String {
|
||||
match text_resource_render_mode(resource.mime_type.as_deref()) {
|
||||
TextResourceRenderMode::Markdown => resource.text.clone(),
|
||||
TextResourceRenderMode::CodeBlock(language) => {
|
||||
Self::fenced_code_block(&resource.text, language)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text_content<'a>(&'a self, cx: &'a App) -> Option<&'a str> {
|
||||
match self {
|
||||
ContentBlock::Markdown { markdown } => Some(markdown.read(cx).source()),
|
||||
ContentBlock::EmbeddedResource { resource, .. } => match &resource.resource {
|
||||
acp::EmbeddedResourceResource::TextResourceContents(text) => Some(&text.text),
|
||||
acp::EmbeddedResourceResource::BlobResourceContents(_) => None,
|
||||
_ => None,
|
||||
},
|
||||
ContentBlock::Empty
|
||||
| ContentBlock::ResourceLink { .. }
|
||||
| ContentBlock::Image { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn fenced_code_block(text: &str, language: Option<&str>) -> String {
|
||||
let fence_len = text
|
||||
.as_bytes()
|
||||
.chunk_by(|left, right| left == right)
|
||||
.filter(|chunk| chunk.first() == Some(&b'`'))
|
||||
.map(|chunk| chunk.len() + 1)
|
||||
.max()
|
||||
.unwrap_or(3)
|
||||
.max(3);
|
||||
let fence = "`".repeat(fence_len);
|
||||
|
||||
let mut markdown = String::new();
|
||||
markdown.push_str(&fence);
|
||||
if let Some(language) = language {
|
||||
markdown.push_str(language);
|
||||
}
|
||||
markdown.push('\n');
|
||||
markdown.push_str(text);
|
||||
if !text.ends_with('\n') {
|
||||
markdown.push('\n');
|
||||
}
|
||||
markdown.push_str(&fence);
|
||||
markdown
|
||||
}
|
||||
|
||||
fn embedded_resource_string_contents(
|
||||
resource: &acp::EmbeddedResource,
|
||||
path_style: PathStyle,
|
||||
) -> String {
|
||||
match &resource.resource {
|
||||
acp::EmbeddedResourceResource::TextResourceContents(text) => {
|
||||
Self::resource_link_md(&text.uri, path_style)
|
||||
}
|
||||
acp::EmbeddedResourceResource::BlobResourceContents(blob) => {
|
||||
Self::resource_link_md(&blob.uri, path_style)
|
||||
}
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn embedded_resource_text(resource: &acp::EmbeddedResource) -> &str {
|
||||
match &resource.resource {
|
||||
acp::EmbeddedResourceResource::TextResourceContents(text) => &text.text,
|
||||
acp::EmbeddedResourceResource::BlobResourceContents(blob) => &blob.uri,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn embedded_resource_label(resource: &acp::EmbeddedResource) -> &str {
|
||||
match &resource.resource {
|
||||
acp::EmbeddedResourceResource::TextResourceContents(text) => &text.uri,
|
||||
acp::EmbeddedResourceResource::BlobResourceContents(blob) => &blob.uri,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn embedded_resource(&self) -> Option<(&acp::EmbeddedResource, Option<&Entity<Markdown>>)> {
|
||||
match self {
|
||||
ContentBlock::EmbeddedResource { resource, markdown } => {
|
||||
Some((resource, markdown.as_ref()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn visible_content(&self, cx: &App) -> bool {
|
||||
match self {
|
||||
ContentBlock::Empty => false,
|
||||
ContentBlock::Markdown { markdown } => !markdown.read(cx).source().trim().is_empty(),
|
||||
ContentBlock::EmbeddedResource { resource, markdown } => match markdown {
|
||||
Some(markdown) => !markdown.read(cx).source().trim().is_empty(),
|
||||
None => !Self::embedded_resource_text(resource).trim().is_empty(),
|
||||
},
|
||||
ContentBlock::ResourceLink { .. } | ContentBlock::Image { .. } => true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1150,6 +1320,13 @@ impl ContentBlock {
|
|||
match self {
|
||||
ContentBlock::Empty => "",
|
||||
ContentBlock::Markdown { markdown } => markdown.read(cx).source(),
|
||||
ContentBlock::EmbeddedResource { resource, markdown } => {
|
||||
if let Some(markdown) = markdown {
|
||||
markdown.read(cx).source()
|
||||
} else {
|
||||
Self::embedded_resource_label(resource)
|
||||
}
|
||||
}
|
||||
ContentBlock::ResourceLink { resource_link } => &resource_link.uri,
|
||||
ContentBlock::Image { .. } => "`Image`",
|
||||
}
|
||||
|
|
@ -1159,6 +1336,7 @@ impl ContentBlock {
|
|||
match self {
|
||||
ContentBlock::Empty => None,
|
||||
ContentBlock::Markdown { markdown } => Some(markdown),
|
||||
ContentBlock::EmbeddedResource { markdown, .. } => markdown.as_ref(),
|
||||
ContentBlock::ResourceLink { .. } => None,
|
||||
ContentBlock::Image { .. } => None,
|
||||
}
|
||||
|
|
@ -1179,6 +1357,61 @@ impl ContentBlock {
|
|||
}
|
||||
}
|
||||
|
||||
enum TextResourceRenderMode {
|
||||
Markdown,
|
||||
CodeBlock(Option<&'static str>),
|
||||
}
|
||||
|
||||
fn text_resource_render_mode(mime_type: Option<&str>) -> TextResourceRenderMode {
|
||||
let Some(mime_type) = mime_type else {
|
||||
return TextResourceRenderMode::CodeBlock(None);
|
||||
};
|
||||
let Ok(mime) = mime_type.parse::<mime::Mime>() else {
|
||||
return TextResourceRenderMode::CodeBlock(None);
|
||||
};
|
||||
|
||||
let type_ = mime.type_().as_str();
|
||||
let subtype = mime.subtype().as_str();
|
||||
let suffix = mime.suffix().map(|suffix| suffix.as_str());
|
||||
|
||||
if matches!(
|
||||
(type_, subtype),
|
||||
("text", "markdown") | ("text", "x-markdown")
|
||||
) {
|
||||
return TextResourceRenderMode::Markdown;
|
||||
}
|
||||
|
||||
let language = match (type_, subtype, suffix) {
|
||||
(_, "json", _) | (_, _, Some("json")) => Some("json"),
|
||||
(_, "xml", _) | (_, _, Some("xml")) => Some("xml"),
|
||||
("text", "html", _) => Some("html"),
|
||||
("text", "css", _) => Some("css"),
|
||||
("text", "csv", _) => Some("csv"),
|
||||
("text", "tab-separated-values", _) => Some("tsv"),
|
||||
("text", "javascript", _) | ("application", "javascript", _) => Some("javascript"),
|
||||
("application", "x-javascript", _) => Some("javascript"),
|
||||
("text", "typescript", _) | ("application", "typescript", _) => Some("typescript"),
|
||||
("text", "x-shellscript", _) | ("application", "x-shellscript", _) => Some("sh"),
|
||||
("application", "x-sh", _) => Some("sh"),
|
||||
("text", "x-python", _) => Some("python"),
|
||||
("text", "x-rust", _) => Some("rust"),
|
||||
("text", "x-go", _) => Some("go"),
|
||||
("text", "x-ruby", _) => Some("ruby"),
|
||||
("text", "x-c", _) => Some("c"),
|
||||
// `mime` parses `text/x-c++` as subtype `x-c+` with an empty suffix.
|
||||
("text", "x-c+", Some("")) => Some("cpp"),
|
||||
("text", "plain", _) => None,
|
||||
("text", _, _) => None,
|
||||
("application", "graphql", _) => Some("graphql"),
|
||||
("application", "toml", _) => Some("toml"),
|
||||
("application", "yaml", _) | ("application", "x-yaml", _) => Some("yaml"),
|
||||
(_, _, Some("yaml" | "yml")) => Some("yaml"),
|
||||
_ => return TextResourceRenderMode::CodeBlock(None),
|
||||
};
|
||||
|
||||
TextResourceRenderMode::CodeBlock(language)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ToolCallContent {
|
||||
ContentBlock(ContentBlock),
|
||||
|
|
@ -1195,14 +1428,14 @@ impl ToolCallContent {
|
|||
cx: &mut App,
|
||||
) -> Result<Option<Self>> {
|
||||
match content {
|
||||
acp::ToolCallContent::Content(acp::Content { content, .. }) => {
|
||||
Ok(Some(Self::ContentBlock(ContentBlock::new(
|
||||
acp::ToolCallContent::Content(acp::Content { content, .. }) => Ok(Some(
|
||||
Self::ContentBlock(ContentBlock::new_tool_call_content(
|
||||
content,
|
||||
&language_registry,
|
||||
path_style,
|
||||
cx,
|
||||
))))
|
||||
}
|
||||
)),
|
||||
)),
|
||||
acp::ToolCallContent::Diff(diff) => Ok(Some(Self::Diff(cx.new(|cx| {
|
||||
Diff::finalized(
|
||||
diff.path.to_string_lossy().into_owned(),
|
||||
|
|
@ -3858,6 +4091,206 @@ mod tests {
|
|||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_resource_markdown_uses_mime_type_for_code_blocks() {
|
||||
let shell = acp::TextResourceContents::new("echo 'hello from exec test'", "tool://preview")
|
||||
.mime_type("text/x-shellscript".to_string());
|
||||
assert_eq!(
|
||||
ContentBlock::text_resource_markdown(&shell),
|
||||
"```sh\necho 'hello from exec test'\n```"
|
||||
);
|
||||
|
||||
let markdown = acp::TextResourceContents::new("**approval** requested", "tool://preview")
|
||||
.mime_type("text/markdown".to_string());
|
||||
assert_eq!(
|
||||
ContentBlock::text_resource_markdown(&markdown),
|
||||
"**approval** requested"
|
||||
);
|
||||
|
||||
let plain = acp::TextResourceContents::new("plain preview", "tool://preview")
|
||||
.mime_type("text/plain".to_string());
|
||||
assert_eq!(
|
||||
ContentBlock::text_resource_markdown(&plain),
|
||||
"```\nplain preview\n```"
|
||||
);
|
||||
|
||||
let cpp = acp::TextResourceContents::new("int main() {}", "tool://preview")
|
||||
.mime_type("text/x-c++; charset=utf-8".to_string());
|
||||
assert_eq!(
|
||||
ContentBlock::text_resource_markdown(&cpp),
|
||||
"```cpp\nint main() {}\n```"
|
||||
);
|
||||
|
||||
let untyped = acp::TextResourceContents::new("# plain preview", "tool://preview");
|
||||
assert_eq!(
|
||||
ContentBlock::text_resource_markdown(&untyped),
|
||||
"```\n# plain preview\n```"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_tool_call_content_preserves_embedded_text_resource(
|
||||
cx: &mut gpui::TestAppContext,
|
||||
) {
|
||||
init_test(cx);
|
||||
|
||||
cx.update(|cx| {
|
||||
let language_registry =
|
||||
Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
|
||||
let content = acp::ContentBlock::Resource(acp::EmbeddedResource::new(
|
||||
acp::EmbeddedResourceResource::TextResourceContents(
|
||||
acp::TextResourceContents::new("echo 'hello from exec test'", "tool://preview")
|
||||
.mime_type("text/x-shellscript".to_string()),
|
||||
),
|
||||
));
|
||||
|
||||
let block = ContentBlock::new_tool_call_content(
|
||||
content,
|
||||
&language_registry,
|
||||
PathStyle::local(),
|
||||
cx,
|
||||
);
|
||||
|
||||
let ContentBlock::EmbeddedResource { resource, markdown } = &block else {
|
||||
panic!("expected embedded resource block, got {block:?}");
|
||||
};
|
||||
match &resource.resource {
|
||||
acp::EmbeddedResourceResource::TextResourceContents(text) => {
|
||||
assert_eq!(text.text, "echo 'hello from exec test'");
|
||||
assert_eq!(text.uri, "tool://preview");
|
||||
assert_eq!(text.mime_type.as_deref(), Some("text/x-shellscript"));
|
||||
}
|
||||
other => panic!("expected text resource contents, got {other:?}"),
|
||||
}
|
||||
|
||||
let markdown = markdown
|
||||
.as_ref()
|
||||
.expect("text resources should have renderable markdown")
|
||||
.read(cx)
|
||||
.source()
|
||||
.to_string();
|
||||
assert_eq!(markdown, "```sh\necho 'hello from exec test'\n```");
|
||||
assert_eq!(
|
||||
block.to_markdown(cx),
|
||||
"```sh\necho 'hello from exec test'\n```"
|
||||
);
|
||||
assert_eq!(block.text_content(cx), Some("echo 'hello from exec test'"));
|
||||
|
||||
let untyped = ContentBlock::new_tool_call_content(
|
||||
acp::ContentBlock::Resource(acp::EmbeddedResource::new(
|
||||
acp::EmbeddedResourceResource::TextResourceContents(
|
||||
acp::TextResourceContents::new("# plain preview", "tool://preview"),
|
||||
),
|
||||
)),
|
||||
&language_registry,
|
||||
PathStyle::local(),
|
||||
cx,
|
||||
);
|
||||
assert_eq!(untyped.to_markdown(cx), "```\n# plain preview\n```");
|
||||
assert_eq!(untyped.text_content(cx), Some("# plain preview"));
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_tool_call_content_renders_embedded_image_blob_resource(
|
||||
cx: &mut gpui::TestAppContext,
|
||||
) {
|
||||
init_test(cx);
|
||||
|
||||
cx.update(|cx| {
|
||||
let language_registry =
|
||||
Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
|
||||
let image_blob = acp::ContentBlock::Resource(acp::EmbeddedResource::new(
|
||||
acp::EmbeddedResourceResource::BlobResourceContents(
|
||||
acp::BlobResourceContents::new(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
||||
"tool://preview.png",
|
||||
)
|
||||
.mime_type("image/png".to_string()),
|
||||
),
|
||||
));
|
||||
|
||||
let block = ContentBlock::new_tool_call_content(
|
||||
image_blob,
|
||||
&language_registry,
|
||||
PathStyle::local(),
|
||||
cx,
|
||||
);
|
||||
|
||||
let ContentBlock::Image { image, dimensions } = &block else {
|
||||
panic!("expected image block, got {block:?}");
|
||||
};
|
||||
assert_eq!(image.format(), gpui::ImageFormat::Png);
|
||||
assert_eq!(
|
||||
dimensions.as_ref().map(|size| (size.width, size.height)),
|
||||
Some((1, 1))
|
||||
);
|
||||
assert_eq!(block.to_markdown(cx), "`Image`");
|
||||
assert_eq!(block.text_content(cx), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_tool_call_content_falls_back_for_non_image_blob_resource(
|
||||
cx: &mut gpui::TestAppContext,
|
||||
) {
|
||||
init_test(cx);
|
||||
|
||||
cx.update(|cx| {
|
||||
let language_registry =
|
||||
Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
|
||||
let archive_blob = acp::ContentBlock::Resource(acp::EmbeddedResource::new(
|
||||
acp::EmbeddedResourceResource::BlobResourceContents(
|
||||
acp::BlobResourceContents::new("not an image", "tool://archive.bin")
|
||||
.mime_type("application/octet-stream".to_string()),
|
||||
),
|
||||
));
|
||||
|
||||
let block = ContentBlock::new_tool_call_content(
|
||||
archive_blob,
|
||||
&language_registry,
|
||||
PathStyle::local(),
|
||||
cx,
|
||||
);
|
||||
|
||||
let ContentBlock::EmbeddedResource { resource, markdown } = &block else {
|
||||
panic!("expected embedded resource block, got {block:?}");
|
||||
};
|
||||
assert!(markdown.is_none());
|
||||
match &resource.resource {
|
||||
acp::EmbeddedResourceResource::BlobResourceContents(blob) => {
|
||||
assert_eq!(blob.uri, "tool://archive.bin");
|
||||
assert_eq!(blob.mime_type.as_deref(), Some("application/octet-stream"));
|
||||
}
|
||||
other => panic!("expected blob resource contents, got {other:?}"),
|
||||
}
|
||||
assert_eq!(block.to_markdown(cx), "tool://archive.bin");
|
||||
assert_eq!(block.text_content(cx), None);
|
||||
|
||||
let invalid_image_blob = acp::ContentBlock::Resource(acp::EmbeddedResource::new(
|
||||
acp::EmbeddedResourceResource::BlobResourceContents(
|
||||
acp::BlobResourceContents::new("not-base64", "tool://preview.png")
|
||||
.mime_type("image/png".to_string()),
|
||||
),
|
||||
));
|
||||
let invalid = ContentBlock::new_tool_call_content(
|
||||
invalid_image_blob,
|
||||
&language_registry,
|
||||
PathStyle::local(),
|
||||
cx,
|
||||
);
|
||||
let ContentBlock::EmbeddedResource { resource, markdown } = &invalid else {
|
||||
panic!("expected embedded resource block, got {invalid:?}");
|
||||
};
|
||||
assert!(markdown.is_none());
|
||||
assert_eq!(
|
||||
ContentBlock::embedded_resource_label(resource),
|
||||
"tool://preview.png"
|
||||
);
|
||||
assert_eq!(invalid.to_markdown(cx), "tool://preview.png");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_authorization_details_deserialize_legacy_network_bool() {
|
||||
// Older builds persisted `network: bool`; the `alias` on
|
||||
|
|
@ -6100,6 +6533,9 @@ mod tests {
|
|||
ContentBlock::ResourceLink { .. } => {
|
||||
panic!("Expected markdown content, got resource link")
|
||||
}
|
||||
ContentBlock::EmbeddedResource { .. } => {
|
||||
panic!("Expected markdown content, got embedded resource")
|
||||
}
|
||||
ContentBlock::Image { .. } => {
|
||||
panic!("Expected markdown content, got image")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -920,8 +920,13 @@ fn collect_markdowns(
|
|||
ToolCallContent::ContentBlock(ContentBlock::Markdown { markdown }) => {
|
||||
Some(markdown.clone())
|
||||
}
|
||||
ToolCallContent::ContentBlock(ContentBlock::EmbeddedResource {
|
||||
markdown: Some(markdown),
|
||||
..
|
||||
}) => Some(markdown.clone()),
|
||||
ToolCallContent::ContentBlock(
|
||||
ContentBlock::Empty
|
||||
| ContentBlock::EmbeddedResource { markdown: None, .. }
|
||||
| ContentBlock::ResourceLink { .. }
|
||||
| ContentBlock::Image { .. },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use agent_client_protocol::schema::v1 as acp;
|
|||
use std::cell::RefCell;
|
||||
|
||||
use acp_thread::{
|
||||
ContentBlock, PlanEntry, SandboxAuthorizationDetails, SandboxFallbackAuthorizationDetails,
|
||||
PlanEntry, SandboxAuthorizationDetails, SandboxFallbackAuthorizationDetails,
|
||||
SandboxNotAppliedReason,
|
||||
};
|
||||
use agent::{
|
||||
|
|
@ -6179,15 +6179,7 @@ impl ThreadView {
|
|||
if matches!(tool_call.status, ToolCallStatus::Canceled) {
|
||||
let has_visible_content =
|
||||
tool_call.content.iter().any(|content| match content {
|
||||
ToolCallContent::ContentBlock(block) => match block {
|
||||
ContentBlock::Empty => false,
|
||||
ContentBlock::Markdown { markdown } => {
|
||||
!markdown.read(cx).source().trim().is_empty()
|
||||
}
|
||||
ContentBlock::ResourceLink { .. } | ContentBlock::Image { .. } => {
|
||||
true
|
||||
}
|
||||
},
|
||||
ToolCallContent::ContentBlock(block) => block.visible_content(cx),
|
||||
ToolCallContent::Diff(_) | ToolCallContent::Terminal(_) => true,
|
||||
});
|
||||
if !has_visible_content {
|
||||
|
|
@ -9527,7 +9519,18 @@ impl ThreadView {
|
|||
) -> AnyElement {
|
||||
match content {
|
||||
ToolCallContent::ContentBlock(content) => {
|
||||
if let Some(resource_link) = content.resource_link() {
|
||||
if let Some((resource, markdown)) = content.embedded_resource() {
|
||||
self.render_embedded_resource_output(
|
||||
resource,
|
||||
markdown.cloned(),
|
||||
entry_ix,
|
||||
context_ix,
|
||||
tool_call,
|
||||
card_layout,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
} else if let Some(resource_link) = content.resource_link() {
|
||||
self.render_resource_link(resource_link, cx)
|
||||
} else if let Some(markdown) = content.markdown() {
|
||||
self.render_markdown_output(
|
||||
|
|
@ -9539,16 +9542,9 @@ impl ThreadView {
|
|||
window,
|
||||
cx,
|
||||
)
|
||||
} else if let Some((image, dimensions)) = content.image() {
|
||||
} else if let Some((image, _)) = content.image() {
|
||||
let location = tool_call.locations.first().cloned();
|
||||
self.render_image_output(
|
||||
entry_ix,
|
||||
image.clone(),
|
||||
dimensions,
|
||||
location,
|
||||
card_layout,
|
||||
cx,
|
||||
)
|
||||
self.render_image_output(entry_ix, image.clone(), location, card_layout, cx)
|
||||
} else {
|
||||
Empty.into_any_element()
|
||||
}
|
||||
|
|
@ -9569,6 +9565,60 @@ impl ThreadView {
|
|||
}
|
||||
}
|
||||
|
||||
fn render_embedded_resource_output(
|
||||
&self,
|
||||
resource: &acp::EmbeddedResource,
|
||||
markdown: Option<Entity<Markdown>>,
|
||||
entry_ix: usize,
|
||||
context_ix: usize,
|
||||
tool_call: &ToolCall,
|
||||
card_layout: bool,
|
||||
window: &Window,
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
if let Some(markdown) = markdown {
|
||||
return self.render_markdown_output(
|
||||
markdown,
|
||||
entry_ix,
|
||||
context_ix,
|
||||
tool_call,
|
||||
card_layout,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
let uri = match &resource.resource {
|
||||
acp::EmbeddedResourceResource::BlobResourceContents(blob) => blob.uri.as_str(),
|
||||
acp::EmbeddedResourceResource::TextResourceContents(text) => text.uri.as_str(),
|
||||
_ => "",
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.map(|this| {
|
||||
if card_layout {
|
||||
this.p_2().when(context_ix > 0, |this| {
|
||||
this.border_t_1()
|
||||
.border_color(self.tool_card_border_color(cx))
|
||||
})
|
||||
} else {
|
||||
this.ml(rems(0.4))
|
||||
.px_3p5()
|
||||
.border_l_1()
|
||||
.border_color(self.tool_card_border_color(cx))
|
||||
}
|
||||
})
|
||||
.when(!uri.is_empty(), |this| {
|
||||
this.child(
|
||||
Label::new(uri.to_string())
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_resource_link(
|
||||
&self,
|
||||
resource_link: &acp::ResourceLink,
|
||||
|
|
@ -9762,28 +9812,10 @@ impl ThreadView {
|
|||
&self,
|
||||
entry_ix: usize,
|
||||
image: Arc<gpui::Image>,
|
||||
dimensions: Option<gpui::Size<u32>>,
|
||||
location: Option<acp::ToolCallLocation>,
|
||||
card_layout: bool,
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
let format_name = match image.format() {
|
||||
gpui::ImageFormat::Png => "PNG",
|
||||
gpui::ImageFormat::Jpeg => "JPEG",
|
||||
gpui::ImageFormat::Webp => "WebP",
|
||||
gpui::ImageFormat::Gif => "GIF",
|
||||
gpui::ImageFormat::Svg => "SVG",
|
||||
gpui::ImageFormat::Bmp => "BMP",
|
||||
gpui::ImageFormat::Tiff => "TIFF",
|
||||
gpui::ImageFormat::Ico => "ICO",
|
||||
gpui::ImageFormat::Pnm => "PNM",
|
||||
};
|
||||
let dimensions_label = if let Some(size) = dimensions {
|
||||
format!("{}×{} {}", size.width, size.height, format_name)
|
||||
} else {
|
||||
format_name.into()
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.map(|this| {
|
||||
|
|
@ -9796,27 +9828,17 @@ impl ThreadView {
|
|||
.border_color(self.tool_card_border_color(cx))
|
||||
}
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.child(
|
||||
Label::new(dimensions_label)
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted)
|
||||
.buffer_font(cx),
|
||||
)
|
||||
.when_some(location, |this, _loc| {
|
||||
this.child(
|
||||
Button::new(("go-to-file", entry_ix), "Go to File")
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.open_tool_call_location(entry_ix, 0, window, cx);
|
||||
})),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when_some(location, |this, _loc| {
|
||||
this.child(
|
||||
h_flex().w_full().justify_end().child(
|
||||
Button::new(("go-to-file", entry_ix), "Go to File")
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.open_tool_call_location(entry_ix, 0, window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
img(image)
|
||||
.max_w_96()
|
||||
|
|
@ -9906,8 +9928,8 @@ impl ThreadView {
|
|||
|
||||
let is_cancelled = matches!(tool_call.status, ToolCallStatus::Canceled)
|
||||
|| tool_call.content.iter().any(|c| match c {
|
||||
ToolCallContent::ContentBlock(ContentBlock::Markdown { markdown }) => {
|
||||
markdown.read(cx).source() == "User canceled"
|
||||
ToolCallContent::ContentBlock(block) => {
|
||||
block.text_content(cx) == Some("User canceled")
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
|
|
@ -10284,14 +10306,12 @@ impl ThreadView {
|
|||
if matches!(status, ToolCallStatus::Failed) {
|
||||
tool_call.content.iter().find_map(|content| {
|
||||
if let ToolCallContent::ContentBlock(block) = content {
|
||||
if let acp_thread::ContentBlock::Markdown { markdown } = block {
|
||||
let source = markdown.read(cx).source().to_string();
|
||||
if !source.is_empty() {
|
||||
if source == "User canceled" {
|
||||
return None;
|
||||
} else {
|
||||
return Some(SharedString::from(source));
|
||||
}
|
||||
if let Some(source) = block.text_content(cx).filter(|source| !source.is_empty())
|
||||
{
|
||||
if source == "User canceled" {
|
||||
return None;
|
||||
} else {
|
||||
return Some(SharedString::from(source));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue