diff --git a/frontend/packages/inspector/src/App.tsx b/frontend/packages/inspector/src/App.tsx
index 2dfe24dc..3f2eb5ca 100644
--- a/frontend/packages/inspector/src/App.tsx
+++ b/frontend/packages/inspector/src/App.tsx
@@ -996,8 +996,25 @@ export default function App() {
switch (update.sessionUpdate) {
case "agent_message_chunk": {
- const content = update.content as { type?: string; text?: string } | undefined;
- if (content?.type === "text" && content.text) {
+ const content = update.content as { type?: string; text?: string; uri?: string; mimeType?: string } | undefined;
+ if (content?.type === "image" && content.uri) {
+ // Flush any accumulated text before the image
+ flushAssistant(time);
+ // Convert file:// URIs to the /v1/fs/file API endpoint so the
+ // browser can fetch the image from the Sandbox Agent server.
+ const filePath = content.uri.startsWith("file://")
+ ? content.uri.slice("file://".length)
+ : content.uri;
+ const displayUri = `/v1/fs/file?path=${encodeURIComponent(filePath)}`;
+ entries.push({
+ id: `image-${event.id}`,
+ eventId: event.id,
+ kind: "image",
+ time,
+ role: "assistant",
+ image: { uri: displayUri, mimeType: content.mimeType ?? "image/png" },
+ });
+ } else if (content?.type === "text" && content.text) {
if (!assistantAccumId) {
assistantAccumId = `assistant-${event.id}`;
assistantAccumText = "";
diff --git a/frontend/packages/inspector/src/components/chat/ChatMessages.tsx b/frontend/packages/inspector/src/components/chat/ChatMessages.tsx
index a96e7a57..6f1221eb 100644
--- a/frontend/packages/inspector/src/components/chat/ChatMessages.tsx
+++ b/frontend/packages/inspector/src/components/chat/ChatMessages.tsx
@@ -246,14 +246,20 @@ const ChatMessages = ({
return ;
}
- // Regular message
+ // Regular message or image
const entry = group.entries[0];
const messageClass = getMessageClass(entry);
return (
- {entry.text ? (
+ {entry.image ? (
+

+ ) : entry.text ? (
) : (
diff --git a/frontend/packages/inspector/src/components/chat/types.ts b/frontend/packages/inspector/src/components/chat/types.ts
index a748b024..9fc48170 100644
--- a/frontend/packages/inspector/src/components/chat/types.ts
+++ b/frontend/packages/inspector/src/components/chat/types.ts
@@ -1,7 +1,7 @@
export type TimelineEntry = {
id: string;
eventId?: string; // Links back to the original event for navigation
- kind: "message" | "tool" | "meta" | "reasoning";
+ kind: "message" | "tool" | "meta" | "reasoning" | "image";
time: string;
// For messages:
role?: "user" | "assistant";
@@ -15,4 +15,6 @@ export type TimelineEntry = {
reasoning?: { text: string; visibility?: string };
// For meta:
meta?: { title: string; detail?: string; severity?: "info" | "error" };
+ // For images:
+ image?: { uri: string; mimeType: string };
};
diff --git a/server/packages/opencode-adapter/src/lib.rs b/server/packages/opencode-adapter/src/lib.rs
index b4e04d8a..9fa149f9 100644
--- a/server/packages/opencode-adapter/src/lib.rs
+++ b/server/packages/opencode-adapter/src/lib.rs
@@ -3968,41 +3968,119 @@ async fn translate_session_update(
}
match kind {
- // ── Text / thought chunk ───────────────────────────────────────
+ // ── Text / thought / image chunk ─────────────────────────────
"agent_message_chunk" | "agent_thought_chunk" => {
- // ContentChunk.content is a ContentBlock; for text it has { type: "text", text: "…" }
- let chunk = update
- .pointer("/content/text")
+ // ContentChunk.content is a ContentBlock discriminated by `type`.
+ let content_type = update
+ .pointer("/content/type")
.and_then(Value::as_str)
- .unwrap_or("");
- if chunk.is_empty() {
- return;
- }
+ .unwrap_or("text");
+
+ match content_type {
+ // ── Image content block ──────────────────────────────────
+ "image" => {
+ // ACP ImageContent: { type: "image", uri: "file:///…", mimeType: "image/png" }
+ let uri = update
+ .pointer("/content/uri")
+ .and_then(Value::as_str)
+ .unwrap_or("");
+ if uri.is_empty() {
+ return;
+ }
+ let mime = update
+ .pointer("/content/mimeType")
+ .and_then(Value::as_str)
+ .unwrap_or("image/png");
- // Accumulate into a single part — reuse the same part ID so the
- // UI updates in-place instead of creating a new line per chunk.
- text_accum.push_str(chunk);
- let part_id = text_part_id.get_or_insert_with(|| {
- let id = format!("part_{message_id}_{part_counter}");
- *part_counter += 1;
- id
- });
- let part = json!({
- "id": *part_id,
- "sessionID": session_id,
- "messageID": message_id,
- "type": "text",
- "text": *text_accum,
- });
- state.emit_event(json!({
- "type":"message.part.updated",
- "properties":{
- "sessionID": session_id,
- "messageID": message_id,
- "part": part,
- "delta": chunk
+ // Derive a filesystem path from the URI for the filename field.
+ let path = uri.strip_prefix("file://").unwrap_or(uri);
+
+ // Finalize any accumulated text part before emitting the image.
+ if let Some(tid) = text_part_id.take() {
+ let part = json!({
+ "id": tid,
+ "sessionID": session_id,
+ "messageID": message_id,
+ "type": "text",
+ "text": *text_accum,
+ });
+ let env = json!({
+ "jsonrpc":"2.0",
+ "method":"_sandboxagent/opencode/message",
+ "params":{"message":{"info":{"id": message_id},"parts":[part]}}
+ });
+ if let Err(err) = state.persist_event(session_id, "agent", &env).await {
+ warn!(?err, "failed to persist ACP text part before image");
+ }
+ text_accum.clear();
+ }
+
+ let part_id = format!("part_{message_id}_{part_counter}");
+ *part_counter += 1;
+ let part = json!({
+ "id": part_id,
+ "sessionID": session_id,
+ "messageID": message_id,
+ "type": "file",
+ "mime": mime,
+ "filename": path,
+ "url": uri,
+ });
+ let env = json!({
+ "jsonrpc":"2.0",
+ "method":"_sandboxagent/opencode/message",
+ "params":{"message":{"info":{"id": message_id},"parts":[part.clone()]}}
+ });
+ if let Err(err) = state.persist_event(session_id, "agent", &env).await {
+ warn!(?err, "failed to persist ACP image part");
+ }
+ state.emit_event(json!({
+ "type":"message.part.updated",
+ "properties":{
+ "sessionID": session_id,
+ "messageID": message_id,
+ "part": part
+ }
+ }));
}
- }));
+
+ // ── Text content block (default) ────────────────────────
+ _ => {
+ // For text: { type: "text", text: "…" }
+ let chunk = update
+ .pointer("/content/text")
+ .and_then(Value::as_str)
+ .unwrap_or("");
+ if chunk.is_empty() {
+ return;
+ }
+
+ // Accumulate into a single part — reuse the same part ID so the
+ // UI updates in-place instead of creating a new line per chunk.
+ text_accum.push_str(chunk);
+ let part_id = text_part_id.get_or_insert_with(|| {
+ let id = format!("part_{message_id}_{part_counter}");
+ *part_counter += 1;
+ id
+ });
+ let part = json!({
+ "id": *part_id,
+ "sessionID": session_id,
+ "messageID": message_id,
+ "type": "text",
+ "text": *text_accum,
+ });
+ state.emit_event(json!({
+ "type":"message.part.updated",
+ "properties":{
+ "sessionID": session_id,
+ "messageID": message_id,
+ "part": part,
+ "delta": chunk
+ }
+ }));
+ }
+ }
}
// ── Tool call initiation ───────────────────────────────────────