use supercode::tools::{
is_image_path, BashTool, EditFileTool, NetworkPolicy, ReadFileTool, Tool, ToolContext,
ViewImageTool, WebFetchTool, WebSearchTool, MULTIMODAL_IMAGE_MARKER,
};
fn ctx() -> (ToolContext, std::path::PathBuf) {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!(
"sc-p4c-tools-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::SeqCst)
));
std::fs::create_dir_all(&dir).unwrap();
(ToolContext::new(dir.clone()), dir)
}
fn tiny_png_bytes() -> Vec<u8> {
vec![
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8,
0xCF, 0xC0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xDD, 0x8D, 0xB0, 0x00, 0x00, 0x00,
0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
]
}
#[tokio::test]
async fn multimodal_off_by_default_reads_image_as_lossy_text() {
let (ctx, dir) = ctx();
let path = dir.join("photo.png");
std::fs::write(&path, tiny_png_bytes()).unwrap();
let out = ReadFileTool
.execute(serde_json::json!({"path": "photo.png"}), &ctx)
.await
.unwrap();
assert!(!out.starts_with(MULTIMODAL_IMAGE_MARKER));
}
#[tokio::test]
async fn multimodal_on_returns_image_marker_with_data_url() {
let (mut ctx, dir) = ctx();
ctx.multimodal_read = true;
let path = dir.join("photo.png");
std::fs::write(&path, tiny_png_bytes()).unwrap();
let out = ReadFileTool
.execute(serde_json::json!({"path": "photo.png"}), &ctx)
.await
.unwrap();
assert!(out.starts_with(MULTIMODAL_IMAGE_MARKER), "{out}");
let data_url = &out[MULTIMODAL_IMAGE_MARKER.len()..];
assert!(data_url.starts_with("data:image/png;base64,"), "{data_url}");
}
#[tokio::test]
async fn multimodal_on_non_image_file_is_unaffected() {
let (mut ctx, dir) = ctx();
ctx.multimodal_read = true;
std::fs::write(dir.join("notes.txt"), "plain text").unwrap();
let out = ReadFileTool
.execute(serde_json::json!({"path": "notes.txt"}), &ctx)
.await
.unwrap();
assert_eq!(out, "plain text");
}
#[test]
fn is_image_path_recognizes_the_documented_extensions() {
for ext in ["png", "jpg", "jpeg", "gif", "webp", "bmp", "PNG", "JPG"] {
assert!(
is_image_path(std::path::Path::new(&format!("f.{ext}"))),
"{ext} should be recognized"
);
}
assert!(!is_image_path(std::path::Path::new("f.txt")));
assert!(!is_image_path(std::path::Path::new("f.rs")));
}
#[tokio::test]
async fn read_before_edit_off_by_default_edit_succeeds_without_a_prior_read() {
let (ctx, dir) = ctx();
std::fs::write(dir.join("f.txt"), "hello world").unwrap();
let out = EditFileTool
.execute(
serde_json::json!({"path": "f.txt", "old_string": "hello", "new_string": "goodbye"}),
&ctx,
)
.await
.unwrap();
assert!(out.contains("Replaced"));
assert_eq!(
std::fs::read_to_string(dir.join("f.txt")).unwrap(),
"goodbye world"
);
}
#[tokio::test]
async fn read_before_edit_on_blocks_an_edit_with_no_prior_read() {
let (mut ctx, dir) = ctx();
ctx.require_read_before_edit = true;
std::fs::write(dir.join("f.txt"), "hello world").unwrap();
let err = EditFileTool
.execute(
serde_json::json!({"path": "f.txt", "old_string": "hello", "new_string": "goodbye"}),
&ctx,
)
.await
.unwrap_err();
assert!(err.to_string().contains("must be read"), "{err}");
assert_eq!(
std::fs::read_to_string(dir.join("f.txt")).unwrap(),
"hello world"
);
}
#[tokio::test]
async fn read_before_edit_on_allows_an_edit_after_reading_the_same_path() {
let (mut ctx, dir) = ctx();
ctx.require_read_before_edit = true;
std::fs::write(dir.join("f.txt"), "hello world").unwrap();
ReadFileTool
.execute(serde_json::json!({"path": "f.txt"}), &ctx)
.await
.unwrap();
let out = EditFileTool
.execute(
serde_json::json!({"path": "f.txt", "old_string": "hello", "new_string": "goodbye"}),
&ctx,
)
.await
.unwrap();
assert!(out.contains("Replaced"));
}
#[tokio::test]
async fn read_before_edit_on_a_different_path_read_does_not_satisfy_it() {
let (mut ctx, dir) = ctx();
ctx.require_read_before_edit = true;
std::fs::write(dir.join("a.txt"), "hello").unwrap();
std::fs::write(dir.join("b.txt"), "hello").unwrap();
ReadFileTool
.execute(serde_json::json!({"path": "a.txt"}), &ctx)
.await
.unwrap();
let err = EditFileTool
.execute(
serde_json::json!({"path": "b.txt", "old_string": "hello", "new_string": "bye"}),
&ctx,
)
.await
.unwrap_err();
assert!(err.to_string().contains("must be read"), "{err}");
}
fn sample_notebook() -> serde_json::Value {
serde_json::json!({
"cells": [
{"cell_type": "code", "metadata": {}, "source": ["print('one')"], "outputs": [], "execution_count": null},
{"cell_type": "code", "metadata": {}, "source": ["print('two')"], "outputs": [], "execution_count": null}
],
"metadata": {},
"nbformat": 4,
"nbformat_minor": 5
})
}
#[tokio::test]
async fn notebook_aware_off_by_default_cell_op_is_ignored_falls_through_to_string_replace() {
let (ctx, dir) = ctx();
let path = dir.join("nb.ipynb");
std::fs::write(&path, serde_json::to_string(&sample_notebook()).unwrap()).unwrap();
let err = EditFileTool
.execute(
serde_json::json!({"path": "nb.ipynb", "cell_index": 0, "cell_op": "delete"}),
&ctx,
)
.await
.unwrap_err();
assert!(err.to_string().contains("old_string"), "{err}");
}
#[tokio::test]
async fn notebook_aware_on_replaces_a_cells_source() {
let (mut ctx, dir) = ctx();
ctx.notebook_aware = true;
let path = dir.join("nb.ipynb");
std::fs::write(&path, serde_json::to_string(&sample_notebook()).unwrap()).unwrap();
let out = EditFileTool
.execute(
serde_json::json!({
"path": "nb.ipynb",
"cell_index": 0,
"cell_op": "replace",
"cell_source": "print('ONE-REPLACED')"
}),
&ctx,
)
.await
.unwrap();
assert!(out.contains("Replaced source of cell 0"), "{out}");
let doc: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
doc["cells"][0]["source"],
serde_json::json!(["print('ONE-REPLACED')"])
);
assert_eq!(
doc["cells"][1]["source"],
serde_json::json!(["print('two')"])
);
}
#[tokio::test]
async fn notebook_aware_on_inserts_a_cell() {
let (mut ctx, dir) = ctx();
ctx.notebook_aware = true;
let path = dir.join("nb.ipynb");
std::fs::write(&path, serde_json::to_string(&sample_notebook()).unwrap()).unwrap();
EditFileTool
.execute(
serde_json::json!({
"path": "nb.ipynb",
"cell_index": 1,
"cell_op": "insert",
"cell_source": "# a markdown cell",
"cell_type": "markdown"
}),
&ctx,
)
.await
.unwrap();
let doc: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(doc["cells"].as_array().unwrap().len(), 3);
assert_eq!(doc["cells"][1]["cell_type"], "markdown");
assert_eq!(
doc["cells"][2]["source"],
serde_json::json!(["print('two')"])
);
}
#[tokio::test]
async fn notebook_aware_on_deletes_a_cell() {
let (mut ctx, dir) = ctx();
ctx.notebook_aware = true;
let path = dir.join("nb.ipynb");
std::fs::write(&path, serde_json::to_string(&sample_notebook()).unwrap()).unwrap();
EditFileTool
.execute(
serde_json::json!({"path": "nb.ipynb", "cell_index": 0, "cell_op": "delete"}),
&ctx,
)
.await
.unwrap();
let doc: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(doc["cells"].as_array().unwrap().len(), 1);
assert_eq!(
doc["cells"][0]["source"],
serde_json::json!(["print('two')"])
);
}
#[tokio::test]
async fn notebook_aware_on_out_of_range_index_errors_without_writing() {
let (mut ctx, dir) = ctx();
ctx.notebook_aware = true;
let path = dir.join("nb.ipynb");
let original = serde_json::to_string(&sample_notebook()).unwrap();
std::fs::write(&path, &original).unwrap();
let err = EditFileTool
.execute(
serde_json::json!({"path": "nb.ipynb", "cell_index": 99, "cell_op": "delete"}),
&ctx,
)
.await
.unwrap_err();
assert!(err.to_string().contains("out of range"), "{err}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
}
#[tokio::test]
async fn shell_env_snapshot_off_by_default_bash_does_not_see_a_synthetic_var() {
let (ctx, _dir) = ctx();
assert!(ctx.shell_env.is_none());
let out = BashTool::default()
.execute(
serde_json::json!({"command": "echo \"[$SUPERCODE_P4C_TEST_VAR]\""}),
&ctx,
)
.await
.unwrap();
assert!(out.contains("[]"), "{out}");
}
#[tokio::test]
async fn shell_env_snapshot_on_bash_inherits_the_captured_environment() {
let (mut ctx, _dir) = ctx();
let mut env = std::collections::HashMap::new();
env.insert(
"SUPERCODE_P4C_TEST_VAR".to_string(),
"hello-from-snapshot".to_string(),
);
ctx.shell_env = Some(std::sync::Arc::new(env));
let out = BashTool::default()
.execute(
serde_json::json!({"command": "echo \"[$SUPERCODE_P4C_TEST_VAR]\""}),
&ctx,
)
.await
.unwrap();
assert!(out.contains("[hello-from-snapshot]"), "{out}");
}
#[tokio::test]
async fn view_image_reads_a_recognized_image_regardless_of_the_multimodal_flag() {
let (ctx, dir) = ctx();
assert!(
!ctx.multimodal_read,
"view_image must not need the read_file flag"
);
std::fs::write(dir.join("shot.png"), tiny_png_bytes()).unwrap();
let out = ViewImageTool
.execute(serde_json::json!({"path": "shot.png"}), &ctx)
.await
.unwrap();
assert!(out.starts_with(MULTIMODAL_IMAGE_MARKER), "{out}");
}
#[tokio::test]
async fn view_image_rejects_a_non_image_file() {
let (ctx, dir) = ctx();
std::fs::write(dir.join("notes.txt"), "hi").unwrap();
let err = ViewImageTool
.execute(serde_json::json!({"path": "notes.txt"}), &ctx)
.await
.unwrap_err();
assert!(
err.to_string().contains("not a recognized image file"),
"{err}"
);
}
#[test]
fn view_image_is_not_registered_by_the_default_builtins_registry() {
let registry = supercode::tools::ToolRegistry::with_builtins();
assert!(registry.get("view_image").is_none());
assert!(registry.get("web_fetch").is_none());
assert!(registry.get("web_search").is_none());
}
#[tokio::test]
async fn nested_instructions_off_by_default_no_injection() {
let (ctx, dir) = ctx();
let sub = dir.join("sub");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("CLAUDE.md"), "SUBDIR MARKER").unwrap();
std::fs::write(sub.join("f.txt"), "content").unwrap();
let out = ReadFileTool
.execute(serde_json::json!({"path": "sub/f.txt"}), &ctx)
.await
.unwrap();
assert_eq!(out, "content", "no injection when the knob is off");
}
#[tokio::test]
async fn nested_instructions_on_injects_once_on_first_touch() {
let (mut ctx, dir) = ctx();
ctx.nested_instructions = true;
let sub = dir.join("sub");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("CLAUDE.md"), "SUBDIR MARKER").unwrap();
std::fs::write(sub.join("f.txt"), "content").unwrap();
std::fs::write(sub.join("g.txt"), "more content").unwrap();
let out1 = ReadFileTool
.execute(serde_json::json!({"path": "sub/f.txt"}), &ctx)
.await
.unwrap();
assert!(out1.contains("SUBDIR MARKER"), "{out1}");
assert!(out1.contains("nested instructions from"), "{out1}");
let out2 = ReadFileTool
.execute(serde_json::json!({"path": "sub/g.txt"}), &ctx)
.await
.unwrap();
assert!(!out2.contains("SUBDIR MARKER"), "{out2}");
assert_eq!(out2, "more content");
}
#[tokio::test]
async fn nested_instructions_root_directory_itself_is_never_injected() {
let (mut ctx, dir) = ctx();
ctx.nested_instructions = true;
std::fs::write(dir.join("CLAUDE.md"), "ROOT MARKER").unwrap();
std::fs::write(dir.join("f.txt"), "content").unwrap();
let out = ReadFileTool
.execute(serde_json::json!({"path": "f.txt"}), &ctx)
.await
.unwrap();
assert_eq!(out, "content");
}
#[tokio::test]
async fn nested_instructions_on_edit_file_also_injects() {
let (mut ctx, dir) = ctx();
ctx.nested_instructions = true;
let sub = dir.join("sub");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("AGENTS.md"), "SUBDIR AGENTS MARKER").unwrap();
std::fs::write(sub.join("f.txt"), "hello world").unwrap();
let out = EditFileTool
.execute(
serde_json::json!({"path": "sub/f.txt", "old_string": "hello", "new_string": "bye"}),
&ctx,
)
.await
.unwrap();
assert!(out.contains("SUBDIR AGENTS MARKER"), "{out}");
}
#[test]
fn network_policy_default_none_permits_anything() {
let (ctx, _dir) = ctx();
assert!(ctx.network_policy.is_none());
assert!(ctx.check_network("https://example.com/path").is_ok());
}
#[test]
fn network_policy_disabled_permits_anything() {
let (mut ctx, _dir) = ctx();
ctx.network_policy = Some(NetworkPolicy {
enabled: false,
allow_domains: vec![],
deny_domains: vec!["evil.example".to_string()],
});
assert!(ctx.check_network("https://evil.example/x").is_ok());
}
#[test]
fn network_policy_enabled_denies_a_denylisted_host() {
let (mut ctx, _dir) = ctx();
ctx.network_policy = Some(NetworkPolicy {
enabled: true,
allow_domains: vec![],
deny_domains: vec!["evil.example".to_string()],
});
let err = ctx.check_network("https://evil.example/x").unwrap_err();
assert!(err.to_string().contains("denied"), "{err}");
assert!(ctx.check_network("https://ok.example/x").is_ok());
}
#[test]
fn network_policy_enabled_with_allowlist_denies_non_listed_hosts() {
let (mut ctx, _dir) = ctx();
ctx.network_policy = Some(NetworkPolicy {
enabled: true,
allow_domains: vec!["ok.example".to_string()],
deny_domains: vec![],
});
assert!(ctx.check_network("https://ok.example/x").is_ok());
let err = ctx.check_network("https://other.example/x").unwrap_err();
assert!(err.to_string().contains("allowlist"), "{err}");
}
#[test]
fn network_policy_deny_wins_over_allow() {
let (mut ctx, _dir) = ctx();
ctx.network_policy = Some(NetworkPolicy {
enabled: true,
allow_domains: vec!["evil.example".to_string()],
deny_domains: vec!["evil.example".to_string()],
});
assert!(ctx.check_network("https://evil.example/x").is_err());
}
#[tokio::test]
async fn web_fetch_respects_an_active_network_policy_before_ever_touching_the_network() {
let (mut ctx, _dir) = ctx();
ctx.network_policy = Some(NetworkPolicy {
enabled: true,
allow_domains: vec![],
deny_domains: vec!["blocked.example".to_string()],
});
let err = WebFetchTool
.execute(
serde_json::json!({"url": "https://blocked.example/page"}),
&ctx,
)
.await
.unwrap_err();
assert!(err.to_string().contains("denied"), "{err}");
}
#[tokio::test]
async fn web_fetch_rejects_a_non_http_scheme() {
let (ctx, _dir) = ctx();
let err = WebFetchTool
.execute(serde_json::json!({"url": "file:///etc/passwd"}), &ctx)
.await
.unwrap_err();
assert!(err.to_string().contains("http"), "{err}");
}
async fn spawn_raw_http_server(
response: String,
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let handle = tokio::spawn(async move {
if let Ok((mut sock, _)) = listener.accept().await {
let mut buf = [0u8; 2048];
let _ = sock.read(&mut buf).await;
let _ = sock.write_all(response.as_bytes()).await;
let _ = sock.flush().await;
}
});
(addr, handle)
}
async fn spawn_raw_http_server_with_connection_flag(
response: String,
) -> (
std::net::SocketAddr,
std::sync::Arc<std::sync::atomic::AtomicBool>,
tokio::task::JoinHandle<()>,
) {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let connected = Arc::new(AtomicBool::new(false));
let connected2 = connected.clone();
let handle = tokio::spawn(async move {
if let Ok((mut sock, _)) = listener.accept().await {
connected2.store(true, Ordering::SeqCst);
let mut buf = [0u8; 2048];
let _ = sock.read(&mut buf).await;
let _ = sock.write_all(response.as_bytes()).await;
let _ = sock.flush().await;
}
});
(addr, connected, handle)
}
fn http_200(body: &str) -> String {
format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
)
}
fn http_302(location: &str) -> String {
format!("HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
}
#[tokio::test]
async fn web_fetch_normal_non_redirect_request_still_works_under_an_active_network_policy() {
let (ctx, _dir) = ctx();
let (addr, server) = spawn_raw_http_server(http_200("hello from origin")).await;
let mut ctx = ctx;
ctx.network_policy = Some(NetworkPolicy {
enabled: true,
allow_domains: vec![],
deny_domains: vec!["definitely-not-this-host.invalid".to_string()],
});
let out = WebFetchTool
.execute(
serde_json::json!({"url": format!("http://localhost:{}/", addr.port())}),
&ctx,
)
.await
.unwrap();
assert!(out.contains("hello from origin"), "{out}");
server.await.unwrap();
}
#[tokio::test]
async fn web_fetch_blocks_a_redirect_to_a_denied_host_under_an_active_network_policy() {
let (ctx, _dir) = ctx();
let (target_addr, target_connected, target_server) =
spawn_raw_http_server_with_connection_flag(http_200("SECRET should never be seen")).await;
let (origin_addr, origin_server) = spawn_raw_http_server(http_302(&format!(
"http://127.0.0.1:{}/secret",
target_addr.port()
)))
.await;
let mut ctx = ctx;
ctx.network_policy = Some(NetworkPolicy {
enabled: true,
allow_domains: vec![],
deny_domains: vec!["127.0.0.1".to_string()],
});
let err = WebFetchTool
.execute(
serde_json::json!({"url": format!("http://localhost:{}/redirect", origin_addr.port())}),
&ctx,
)
.await
.unwrap_err();
assert!(
err.to_string().contains("denied"),
"expected a denied-host error, got: {err}"
);
origin_server.await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(
!target_connected.load(std::sync::atomic::Ordering::SeqCst),
"the denied redirect target must never have been connected to"
);
target_server.abort();
}
#[tokio::test]
async fn web_fetch_still_follows_a_redirect_to_an_allowed_host() {
let (ctx, _dir) = ctx();
let (target_addr, target_server) =
spawn_raw_http_server(http_200("hello from the allowed redirect target")).await;
let (origin_addr, origin_server) = spawn_raw_http_server(http_302(&format!(
"http://localhost:{}/landed",
target_addr.port()
)))
.await;
let mut ctx = ctx;
ctx.network_policy = Some(NetworkPolicy {
enabled: true,
allow_domains: vec![],
deny_domains: vec!["definitely-not-this-host.invalid".to_string()],
});
let out = WebFetchTool
.execute(
serde_json::json!({"url": format!("http://localhost:{}/redirect", origin_addr.port())}),
&ctx,
)
.await
.unwrap();
assert!(
out.contains("hello from the allowed redirect target"),
"{out}"
);
origin_server.await.unwrap();
target_server.await.unwrap();
}
#[tokio::test]
async fn web_search_without_a_configured_endpoint_errors_clearly() {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
{
let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::remove_var(supercode::tools::WEB_SEARCH_URL_ENV);
}
let (ctx, _dir) = ctx();
let err = WebSearchTool
.execute(serde_json::json!({"query": "rust async traits"}), &ctx)
.await
.unwrap_err();
assert!(
err.to_string()
.contains("requires a configured search endpoint"),
"{err}"
);
}
#[test]
fn tools_web_registered_via_module_activation_when_enabled() {
let hc = supercode::HarnessConfig::from_toml_str(
r#"
schema_version = 1
[core]
model = "m"
[core.tools]
enabled = ["read_file", "bash", "edit_file", "write_file"]
[capabilities.tools_web]
enabled = true
[experimental]
module_registry = true
"#,
)
.expect("parses");
let resolved = supercode::configfile::resolve_harness(
hc,
&supercode::configfile::ResolveOptions::default(),
)
.expect("resolves");
let registry = supercode::tools::ToolRegistry::from_config(&resolved.config);
assert!(registry.get("web_fetch").is_some());
assert!(registry.get("web_search").is_some());
}
mod agent_wiring {
use std::sync::atomic::{AtomicUsize, Ordering};
use async_trait::async_trait;
use supercode::{
Agent, ChatMessage, ChatRequest, Config, FunctionCall, Provider, Role, ToolCall, Usage,
};
struct ReadImageScript {
calls: AtomicUsize,
}
#[async_trait]
impl Provider for ReadImageScript {
async fn complete(
&self,
_req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
if n == 0 {
Ok((
ChatMessage {
role: Role::Assistant,
content: None,
content_parts: None,
tool_calls: Some(vec![ToolCall {
id: "call_1".into(),
kind: "function".into(),
function: FunctionCall {
name: "read_file".into(),
arguments: serde_json::json!({"path": "shot.png"}).to_string(),
},
}]),
tool_call_id: None,
name: None,
metadata: Default::default(),
},
Usage::default(),
))
} else {
Ok((ChatMessage::assistant("done"), Usage::default()))
}
}
}
#[tokio::test]
async fn read_file_multimodal_on_agent_history_carries_an_image_content_part() {
let dir = std::env::temp_dir().join(format!(
"sc-p4c-agent-wiring-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("shot.png"), super::tiny_png_bytes()).unwrap();
let config = Config::builder()
.cwd(dir)
.read_file_multimodal(true)
.build();
let mut agent = Agent::with_provider(
config,
Box::new(ReadImageScript {
calls: AtomicUsize::new(0),
}),
);
let answer = agent.send("look at shot.png").await.unwrap();
assert_eq!(answer, "done");
let tool_msg = agent
.history()
.iter()
.find(|m| m.tool_call_id.as_deref() == Some("call_1"))
.expect("tool result present");
let parts = tool_msg
.content_parts
.as_ref()
.expect("multimodal on: tool result must carry content_parts, not plain content");
assert!(
parts.iter().any(|p| p["type"] == "image_url"
&& p["image_url"]["url"]
.as_str()
.unwrap_or("")
.starts_with("data:image/png;base64,")),
"{parts:?}"
);
}
}