Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 88 additions & 5 deletions bt-daemon/src/translate/opencode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -264,9 +264,19 @@ struct ToolPartState {
#[serde(default)]
output: Option<Value>,
#[serde(default)]
attachments: Vec<ToolAttachment>,
#[serde(default)]
error: Option<Value>,
}

#[derive(Deserialize)]
struct ToolAttachment {
#[serde(default)]
mime: Option<String>,
#[serde(default)]
url: Option<String>,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct MessageInfo {
Expand Down Expand Up @@ -351,6 +361,7 @@ struct NativeSession {
tool_names: HashMap<String, String>,
tool_args: HashMap<String, Value>,
tool_outputs: HashMap<String, Value>,
closed_tool_spans: RecentMap<String, ClosedToolSpan>,
tool_errors: HashMap<String, String>,
tool_message_ids: HashMap<String, String>,
denied_tools: HashSet<String>,
Expand All @@ -361,6 +372,12 @@ struct NativeSession {
history_tool_results: HashMap<String, HashMap<String, Value>>,
}

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<String> {
Expand Down Expand Up @@ -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() => {
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -872,7 +907,7 @@ impl OpenCodeTranslator {
}
_ => {}
}
Vec::new()
ops
}

fn message_updated(&mut self, info: MessageInfo, ts_ms: i64) -> Vec<SpanOp> {
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -1322,6 +1368,43 @@ impl OpenCodeTranslator {
}
}

fn tool_result_content(output: Value, attachments: Vec<ToolAttachment>) -> 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::<Vec<_>>();
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<String> {
v.pointer("/input/sessionID")
.or_else(|| v.get("sessionID"))
Expand Down
62 changes: 62 additions & 0 deletions bt-daemon/tests/opencode_translator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>();
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();
Expand Down
Loading