diff --git a/bt-daemon/src/translate/opencode.rs b/bt-daemon/src/translate/opencode.rs index 96d7094..b4cb49f 100644 --- a/bt-daemon/src/translate/opencode.rs +++ b/bt-daemon/src/translate/opencode.rs @@ -1274,6 +1274,18 @@ impl OpenCodeTranslator { ..Default::default() })); } else { + // An idle session remains resumable, but its root is still the + // session-level summary users see between turns. Keep that summary + // current without dropping the state needed for the next turn. + ops.push(SpanOp::Merge(SpanRow { + span_id: s.root_span_id.clone(), + root_span_id: s.effective_root_span_id.clone(), + end_ms: Some(ts), + metadata: Some( + json!({"total_turns":s.turn_number,"total_tool_calls":s.tool_call_count}), + ), + ..Default::default() + })); self.sessions.insert(sid.into(), s); } ops diff --git a/bt-daemon/tests/opencode_translator.rs b/bt-daemon/tests/opencode_translator.rs index 21c4af8..6179ef6 100644 --- a/bt-daemon/tests/opencode_translator.rs +++ b/bt-daemon/tests/opencode_translator.rs @@ -120,6 +120,77 @@ fn opencode_builds_turn_llm_tool_and_closes_the_session() { .all(|r| r.end_ms.is_some())); } +#[test] +fn opencode_idle_updates_a_resumable_session_root() { + let registry = Registry::default_agents(); + let mut translator = registry.create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + let events = vec![ + event( + "session.created", + 1, + json!({"properties":{"info":{"id":"native"}}}), + ), + event( + "chat.message", + 2, + json!({"input":{"sessionID":"native"},"output":{"parts":[{"type":"text","text":"first"}]}}), + ), + event( + "message.part.updated", + 3, + json!({"properties":{"part":{"sessionID":"native","messageID":"one","type":"text","text":"one"}}}), + ), + event( + "message.updated", + 4, + json!({"properties":{"info":{"id":"one","sessionID":"native","role":"assistant","providerID":"openai","modelID":"gpt-5","time":{"created":2,"completed":4},"tokens":{}}}}), + ), + event( + "session.idle", + 5, + json!({"properties":{"sessionID":"native"}}), + ), + event( + "chat.message", + 10, + json!({"input":{"sessionID":"native"},"output":{"parts":[{"type":"text","text":"second"}]}}), + ), + event( + "message.part.updated", + 11, + json!({"properties":{"part":{"sessionID":"native","messageID":"two","type":"text","text":"two"}}}), + ), + event( + "message.updated", + 12, + json!({"properties":{"info":{"id":"two","sessionID":"native","role":"assistant","providerID":"openai","modelID":"gpt-5","time":{"created":10,"completed":12},"tokens":{}}}}), + ), + event( + "session.idle", + 13, + json!({"properties":{"sessionID":"native"}}), + ), + ]; + let mut ops = Vec::new(); + for event in events { + ops.extend(translator.handle(&event, &ctx).unwrap()); + } + let rows = reduce(ops); + let root = rows.values().find(|row| row.name == "OpenCode").unwrap(); + assert_eq!(root.metadata.as_ref().unwrap()["total_turns"], 2); + assert_eq!(root.end_ms, Some(13)); + assert_eq!( + rows.values() + .filter(|row| row.name.starts_with("Turn ")) + .count(), + 2 + ); +} + #[test] fn opencode_compaction_replaces_old_prefix_and_preserves_native_tail() { let registry = Registry::default_agents();