diff --git a/bt-daemon/src/translate/opencode.rs b/bt-daemon/src/translate/opencode.rs index 87159df0..d4be4955 100644 --- a/bt-daemon/src/translate/opencode.rs +++ b/bt-daemon/src/translate/opencode.rs @@ -2,7 +2,7 @@ //! this module owns span construction, correlation, and recovery. use super::git::GitMetadataCache; -use super::recent::RecentSet; +use super::recent::{RecentMap, RecentSet}; use super::tool::{error_text, with_tool_approval, ToolApproval}; use super::{ local_username, AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory, @@ -264,9 +264,19 @@ struct ToolPartState { #[serde(default)] output: Option, #[serde(default)] + attachments: Vec, + #[serde(default)] error: Option, } +#[derive(Deserialize)] +struct ToolAttachment { + #[serde(default)] + mime: Option, + #[serde(default)] + url: Option, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct MessageInfo { @@ -351,6 +361,7 @@ struct NativeSession { tool_names: HashMap, tool_args: HashMap, tool_outputs: HashMap, + closed_tool_spans: RecentMap, tool_errors: HashMap, tool_message_ids: HashMap, denied_tools: HashSet, @@ -361,6 +372,12 @@ struct NativeSession { history_tool_results: HashMap>, } +struct ClosedToolSpan { + span_id: String, + root_span_id: String, + parent_span_id: String, +} + impl NativeSession { /// The user turn that owns work in this session right now. fn turn_owner(&self) -> Option { @@ -784,6 +801,7 @@ impl OpenCodeTranslator { let Some(state) = self.sessions.get_mut(&part.session_id) else { return Vec::new(); }; + let mut ops = Vec::new(); let message_id = part.message_id.as_deref().unwrap_or(""); match part.kind.as_deref() { Some("text") if part.text.is_some() => { @@ -843,8 +861,25 @@ impl OpenCodeTranslator { .and_then(|state| state.status.as_deref()) { Some("completed") => { - if let Some(v) = tool_state.and_then(|state| state.output) { - state.tool_outputs.insert(call_id.into(), v.clone()); + if let Some(v) = tool_state.map(|state| { + tool_result_content( + state.output.unwrap_or(Value::Null), + state.attachments, + ) + }) { + if let Some(closed) = state.closed_tool_spans.get(call_id) { + if contains_image_attachment(&v) { + ops.push(SpanOp::Merge(SpanRow { + span_id: closed.span_id.clone(), + root_span_id: closed.root_span_id.clone(), + parent_span_ids: vec![closed.parent_span_id.clone()], + output: Some(v.clone()), + ..Default::default() + })); + } + } else { + state.tool_outputs.insert(call_id.into(), v.clone()); + } state .history_tool_results .entry(message_id.into()) @@ -872,7 +907,7 @@ impl OpenCodeTranslator { } _ => {} } - Vec::new() + ops } fn message_updated(&mut self, info: MessageInfo, ts_ms: i64) -> Vec { @@ -1080,8 +1115,19 @@ impl OpenCodeTranslator { } let had_start = s.tool_starts.contains_key(&call); let parent_span_id = s.tool_parent_span_ids.remove(&call).unwrap_or(turn); + let span_id = ids::span_id(&self.daemon_session_id, &format!("tool:{sid}:{call}")); + if had_start { + s.closed_tool_spans.insert( + call.clone(), + ClosedToolSpan { + span_id: span_id.clone(), + root_span_id: s.effective_root_span_id.clone(), + parent_span_id: parent_span_id.clone(), + }, + ); + } let row = SpanRow { - span_id: ids::span_id(&self.daemon_session_id, &format!("tool:{sid}:{call}")), + span_id, root_span_id: s.effective_root_span_id.clone(), parent_span_ids: vec![parent_span_id], turn_span_id: (!had_start).then(|| s.turn_owner()).flatten(), @@ -1322,6 +1368,43 @@ impl OpenCodeTranslator { } } +fn tool_result_content(output: Value, attachments: Vec) -> Value { + let images = attachments + .into_iter() + .filter(|attachment| { + attachment + .mime + .as_deref() + .is_some_and(|mime| mime.starts_with("image/")) + }) + .filter_map(|attachment| attachment.url) + .map(|url| json!({"type":"image_url","image_url":{"url":url}})) + .collect::>(); + if images.is_empty() { + return output; + } + let mut content = Vec::with_capacity(images.len() + 1); + match output { + Value::String(text) if !text.is_empty() => content.push(json!({"type":"text","text":text})), + Value::Array(parts) => content.extend(parts), + Value::Null => {} + other => content.push(json!({"type":"text","text":other})), + } + content.extend(images); + Value::Array(content) +} + +fn contains_image_attachment(value: &Value) -> bool { + match value { + Value::Array(values) => values.iter().any(contains_image_attachment), + Value::Object(fields) => { + fields.get("type").and_then(Value::as_str) == Some("image_url") + || fields.values().any(contains_image_attachment) + } + _ => false, + } +} + fn native_session_id(v: &Value) -> Option { v.pointer("/input/sessionID") .or_else(|| v.get("sessionID")) diff --git a/bt-daemon/tests/opencode_translator.rs b/bt-daemon/tests/opencode_translator.rs index ed11dead..255399d1 100644 --- a/bt-daemon/tests/opencode_translator.rs +++ b/bt-daemon/tests/opencode_translator.rs @@ -133,6 +133,68 @@ fn opencode_builds_turn_llm_tool_and_closes_the_session() { .all(|r| r.end_ms.is_some())); } +#[test] +fn opencode_merges_late_tool_result_attachments_into_closed_tool_span() { + let mut translator = Registry::default_agents().create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + let events = [ + event( + "session.created", + 1, + json!({"properties":{"info":{"id":"native"}}}), + ), + event( + "chat.message", + 2, + json!({"input":{"sessionID":"native"},"output":{"parts":[]}}), + ), + event( + "tool.execute.before", + 3, + json!({"input":{"sessionID":"native","callID":"call-1","tool":"read"},"output":{"args":{"path":"image.png"}}}), + ), + event( + "tool.execute.after", + 4, + json!({"input":{"sessionID":"native","callID":"call-1","tool":"read"},"result":{"output":"image contents"}}), + ), + event( + "message.part.updated", + 5, + json!({"properties":{"part":{"sessionID":"native","messageID":"assistant-1","type":"tool","callID":"call-1","tool":"read","state":{"status":"completed","input":{"path":"image.png"},"output":"image contents","attachments":[{"mime":"image/png","url":"file:///tmp/image.png"}]}}}}), + ), + ]; + + let ops = events + .iter() + .flat_map(|event| translator.handle(event, &ctx).unwrap()) + .collect::>(); + let tool_id = ops + .iter() + .find_map(|op| match op { + SpanOp::Merge(row) if row.span_type == SpanType::Tool => Some(row.span_id.clone()), + _ => None, + }) + .unwrap(); + let late_attachment_merge = ops.iter().find(|op| { + matches!(op, SpanOp::Merge(row) if row.span_id == tool_id && row.output.as_ref().is_some_and(|output| output.to_string().contains("image_url"))) + }); + assert!(late_attachment_merge.is_some()); + + let rows = reduce(ops); + assert_eq!( + rows[&tool_id].output, + Some(json!([ + {"type":"text","text":"image contents"}, + {"type":"image_url","image_url":{"url":"file:///tmp/image.png"}} + ])) + ); + assert_eq!(rows[&tool_id].end_ms, Some(4)); +} + #[test] fn opencode_idle_updates_a_resumable_session_root() { let registry = Registry::default_agents();