diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2123d71a..f0119776 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,6 +155,36 @@ jobs: node -e 'import(process.argv[1]).then((m) => { if (typeof m.default !== "function") process.exit(1) })' \ "$PWD/install-test/node_modules/@braintrust/trace-opencode/dist/index.mjs" + opencode-v2-minimum: + name: OpenCode 2 minimum compatibility + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: ./.github/actions/setup-node-pnpm + with: + cache-dependency-path: src/plugins/opencode/content/pnpm-lock.yaml + - uses: ./.github/actions/setup-rust + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: bt-daemon -> target + save-if: ${{ github.event_name != 'pull_request' }} + - name: Install OpenCode 2.0 and packed plugin + run: | + set -euo pipefail + make build-opencode + mkdir -p "$RUNNER_TEMP/opencode-v2-package" + (cd dist/opencode && pnpm pack --pack-destination "$RUNNER_TEMP/opencode-v2-package" >/dev/null) + tarball="$(find "$RUNNER_TEMP/opencode-v2-package" -name 'braintrust-trace-opencode-*.tgz' -print -quit)" + npm install --prefix "$RUNNER_TEMP/opencode-v2-min" --no-audit --no-fund @opencode/cli@2.0.0 "$tarball" + - name: Trace OpenCode 2.0 sessions + env: + OPENCODE_V2_BIN: ${{ runner.temp }}/opencode-v2-min/node_modules/.bin/opencode + OPENCODE_V2_PLUGIN_DIR: ${{ runner.temp }}/opencode-v2-min/node_modules/@braintrust/trace-opencode + BT_AGENT_INFERENCE_MODE: mock + BT_AGENT_INGEST_MODE: mock + run: cargo test --manifest-path bt-daemon/Cargo.toml --all-features --locked --test agent_integration opencode_v2_ -- --ignored --nocapture --test-threads=1 + resolve-pi-compatibility: name: Resolve Pi compatibility window runs-on: ubuntu-24.04 @@ -227,6 +257,7 @@ jobs: codex: ${{ steps.versions.outputs.codex }} claude: ${{ steps.versions.outputs.claude }} opencode: ${{ steps.versions.outputs.opencode }} + opencode_v2: ${{ steps.versions.outputs.opencode_v2 }} pi: ${{ steps.versions.outputs.pi }} steps: - id: versions @@ -236,6 +267,7 @@ jobs: echo "codex=$(npm view @openai/codex version)" >> "$GITHUB_OUTPUT" echo "claude=$(npm view @anthropic-ai/claude-code version)" >> "$GITHUB_OUTPUT" echo "opencode=$(npm view opencode-ai version)" >> "$GITHUB_OUTPUT" + echo "opencode_v2=$(npm view @opencode/cli version)" >> "$GITHUB_OUTPUT" echo "pi=$(npm view @earendil-works/pi-coding-agent version)" >> "$GITHUB_OUTPUT" daemon: @@ -319,6 +351,9 @@ jobs: fi sleep 10 done + - name: Install OpenCode 2 + shell: bash + run: npm install --prefix "${{ runner.temp }}/opencode-v2" --no-audit --no-fund "@opencode/cli@${{ needs.resolve-coding-agent-versions.outputs.opencode_v2 }}" - name: Set up Node and pnpm uses: ./.github/actions/setup-node-pnpm with: @@ -339,6 +374,7 @@ jobs: "$tarball" @opencode-ai/plugin@latest @opencode-ai/sdk@latest echo "OPENCODE_PLUGIN=$install_dir/node_modules/@braintrust/trace-opencode/dist/index.mjs" \ >> "$GITHUB_ENV" + echo "OPENCODE_V2_PLUGIN_DIR=$install_dir/node_modules/@braintrust/trace-opencode" >> "$GITHUB_ENV" - name: Build and install Pi extension for integration harness shell: bash run: | @@ -376,6 +412,7 @@ jobs: CODEX_BIN: ${{ runner.temp }}/coding-agents/node_modules/.bin/codex${{ matrix.agent_suffix }} CLAUDE_BIN: ${{ runner.temp }}/coding-agents/node_modules/.bin/claude${{ matrix.agent_suffix }} OPENCODE_BIN: ${{ runner.temp }}/coding-agents/node_modules/.bin/opencode${{ matrix.agent_suffix }} + OPENCODE_V2_BIN: ${{ runner.temp }}/opencode-v2/node_modules/.bin/opencode${{ matrix.agent_suffix }} PI_BIN: ${{ runner.temp }}/coding-agents/node_modules/.bin/pi${{ matrix.agent_suffix }} PI_EXTENSION_PATH: ${{ runner.temp }}/pi-extension/node_modules/@braintrust/pi-extension/dist/index.mjs run: cargo test --manifest-path bt-daemon/Cargo.toml --all-features --locked --test agent_integration -- --ignored --nocapture --test-threads=1 @@ -426,6 +463,7 @@ jobs: - plugins - js-packages - opencode-compatibility + - opencode-v2-minimum - resolve-pi-compatibility - pi-compatibility - resolve-coding-agent-versions diff --git a/bt-daemon/src/client.rs b/bt-daemon/src/client.rs index e9aa8a36..1e73e439 100644 --- a/bt-daemon/src/client.rs +++ b/bt-daemon/src/client.rs @@ -395,26 +395,30 @@ pub(crate) async fn flush_managed_run_in( timeout_ms: u64, data_dir: &std::path::Path, ) -> anyhow::Result { - let stream = match connect(socket).await { - Ok(stream) => stream, - Err(_) => { - let accepted_sessions = journal::read_managed_run_keys(data_dir, managed_run_id) - .await - .len() as u64; - return Ok(wire::FlushResult { - flushed: true, - pending: 0, - accepted_sessions, - }); - } - }; - let mut conn = Conn::new(stream); - conn.initialize("managed-run-flush").await?; - let params = ManagedRunFlushParams { - managed_run_id: managed_run_id.to_string(), - timeout_ms, - }; - conn.call(method::MANAGED_RUN_FLUSH, params).await + tokio::time::timeout(Duration::from_millis(timeout_ms), async { + let stream = match connect(socket).await { + Ok(stream) => stream, + Err(_) => { + let accepted_sessions = journal::read_managed_run_keys(data_dir, managed_run_id) + .await + .len() as u64; + return Ok(wire::FlushResult { + flushed: true, + pending: 0, + accepted_sessions, + }); + } + }; + let mut conn = Conn::new(stream); + conn.initialize("managed-run-flush").await?; + let params = ManagedRunFlushParams { + managed_run_id: managed_run_id.to_string(), + timeout_ms, + }; + conn.call(method::MANAGED_RUN_FLUSH, params).await + }) + .await + .map_err(|_| anyhow::anyhow!("managed run flush timed out after {timeout_ms}ms"))? } /// Query daemon status. `Ok(None)` means no daemon is running. diff --git a/bt-daemon/src/hook.rs b/bt-daemon/src/hook.rs index 9f5feb35..333eed31 100644 --- a/bt-daemon/src/hook.rs +++ b/bt-daemon/src/hook.rs @@ -78,7 +78,13 @@ pub(crate) fn should_flush_ingress_event(env: &wire::Envelope) -> bool { }, "opencode" => matches!( env.event.as_str(), - "session.idle" | "session.deleted" | "session.error" | "server.instance.disposed" + "session.idle" + | "session.deleted" + | "session.error" + | "server.instance.disposed" + | "session.execution.succeeded" + | "session.execution.failed" + | "session.execution.interrupted" ), _ => false, } diff --git a/bt-daemon/src/managed_run/mod.rs b/bt-daemon/src/managed_run/mod.rs index 66d87487..99e3cf2c 100644 --- a/bt-daemon/src/managed_run/mod.rs +++ b/bt-daemon/src/managed_run/mod.rs @@ -147,8 +147,12 @@ async fn run_traced_inner( .as_ref() .map(|runtime| runtime.socket.clone()) .unwrap_or_else(|| paths::socket_path(None)); - let mut injection = - agent.inject_with_context(&hook_command, &managed_run_id, &route, &socket)?; + let mut injection = if args.source == RunSource::OpenCode { + let v2 = opencode::is_v2(&executable).await?; + opencode::inject_for_version(v2, &args.agent_args)? + } else { + agent.inject_with_context(&hook_command, &managed_run_id, &route, &socket)? + }; if args.source == RunSource::Codex { let executable_for_trust = std::env::var_os("CODEX_BIN").unwrap_or_else(|| OsString::from("codex")); diff --git a/bt-daemon/src/managed_run/opencode.rs b/bt-daemon/src/managed_run/opencode.rs index 9e658672..b302efa3 100644 --- a/bt-daemon/src/managed_run/opencode.rs +++ b/bt-daemon/src/managed_run/opencode.rs @@ -3,8 +3,54 @@ use super::{Injection, ManagedRun}; use crate::agents::OpenCode; use crate::args::RunHookCommand; +use std::ffi::OsString; -fn opencode_managed_config(existing: Option<&str>) -> anyhow::Result { +pub(super) async fn is_v2(executable: &std::ffi::OsStr) -> anyhow::Result { + let mut command: tokio::process::Command = + crate::subprocess::background_command(executable).into(); + command.arg("--version").kill_on_drop(true); + let output = tokio::time::timeout(std::time::Duration::from_secs(15), command.output()) + .await + .map_err(|_| anyhow::anyhow!("timed out detecting OpenCode version"))? + .map_err(|error| anyhow::anyhow!("failed to detect OpenCode version: {error}"))?; + if !output.status.success() { + anyhow::bail!( + "failed to detect OpenCode version: --version exited with {}", + output.status + ); + } + let output = String::from_utf8_lossy(&output.stdout); + let version = output + .split_whitespace() + .last() + .ok_or_else(|| anyhow::anyhow!("OpenCode returned an empty version"))?; + Ok(version.trim_start_matches('v').starts_with("2.")) +} + +pub(super) fn inject_for_version(v2: bool, args: &[OsString]) -> anyhow::Result { + if v2 + && args.iter().any(|arg| { + arg == "--server" + || arg.to_string_lossy().starts_with("--server=") + || arg == "--service" + }) + { + anyhow::bail!("OpenCode 2 managed tracing requires a private server; remove --server"); + } + let config = + opencode_managed_config(std::env::var("OPENCODE_CONFIG_CONTENT").ok().as_deref(), v2)?; + Ok(Injection { + env: vec![("OPENCODE_CONFIG_CONTENT", config)], + args: if v2 { + vec!["--standalone".into()] + } else { + Vec::new() + }, + ..Injection::default() + }) +} + +fn opencode_managed_config(existing: Option<&str>, v2: bool) -> anyhow::Result { let mut config = match existing { Some(raw) => serde_json::from_str::(raw) .map_err(|error| anyhow::anyhow!("invalid OPENCODE_CONFIG_CONTENT: {error}"))?, @@ -18,8 +64,14 @@ fn opencode_managed_config(existing: Option<&str>) -> anyhow::Result { .or_insert_with(|| serde_json::Value::Array(Vec::new())) .as_array_mut() .ok_or_else(|| anyhow::anyhow!("OPENCODE_CONFIG_CONTENT.plugin must be an array"))?; - let plugin = crate::env::var("BRAINTRUST_TRACE_OPENCODE_PLUGIN_SPEC") - .unwrap_or_else(|| "@braintrust/trace-opencode/tracing".to_string()); + let plugin = crate::env::var("BRAINTRUST_TRACE_OPENCODE_PLUGIN_SPEC").unwrap_or_else(|| { + if v2 { + "@braintrust/trace-opencode" + } else { + "@braintrust/trace-opencode/tracing" + } + .to_string() + }); if !plugins.iter().any(|value| value.as_str() == Some(&plugin)) { plugins.push(serde_json::Value::String(plugin)); } @@ -31,17 +83,33 @@ impl ManagedRun for OpenCode { ("OPENCODE_BIN", "opencode") } + fn invocation_args(&self, injection: Vec, mut args: Vec) -> Vec { + if injection.first().is_some_and(|arg| arg == "--standalone") + && args.first().is_some_and(|arg| arg == "serve") + { + // `serve` already starts its own server and does not accept --standalone. + return args; + } + if injection.first().is_some_and(|arg| arg == "--standalone") + && args + .first() + .is_some_and(|arg| arg == "run" || arg == "mini") + { + let command = args.remove(0); + return std::iter::once(command) + .chain(injection) + .chain(args) + .collect(); + } + injection.into_iter().chain(args).collect() + } + fn inject( &self, _hook_command: &RunHookCommand, _managed_run_id: &str, ) -> anyhow::Result { - let config = - opencode_managed_config(std::env::var("OPENCODE_CONFIG_CONTENT").ok().as_deref())?; - Ok(Injection { - env: vec![("OPENCODE_CONFIG_CONTENT", config)], - ..Injection::default() - }) + inject_for_version(false, &[]) } } @@ -52,7 +120,8 @@ mod tests { #[test] fn opencode_managed_run_preserves_inline_config_and_adds_plugin() { let config = - opencode_managed_config(Some(r#"{"model":"test/model","plugin":["other"]}"#)).unwrap(); + opencode_managed_config(Some(r#"{"model":"test/model","plugin":["other"]}"#), false) + .unwrap(); let config: serde_json::Value = serde_json::from_str(&config).unwrap(); assert_eq!(config["model"], "test/model"); assert_eq!( @@ -71,4 +140,52 @@ mod tests { .args .is_empty()); } + + #[test] + fn opencode_v2_managed_run_uses_server_package() { + let config = + opencode_managed_config(Some(r#"{"model":"test/model","plugin":["other"]}"#), true) + .unwrap(); + let config: serde_json::Value = serde_json::from_str(&config).unwrap(); + assert_eq!(config["model"], "test/model"); + assert_eq!( + config["plugin"], + serde_json::json!(["other", "@braintrust/trace-opencode"]) + ); + } + + #[test] + fn opencode_v2_rejects_external_server_arguments() { + assert!(inject_for_version(true, &["--server=http://localhost".into()]).is_err()); + assert!(inject_for_version(false, &["--server=http://localhost".into()]).is_ok()); + } + + #[cfg(unix)] + #[tokio::test] + async fn version_detection_executes_the_selected_binary() { + use std::os::unix::fs::PermissionsExt; + let temp = tempfile::tempdir().unwrap(); + let executable = temp.path().join("opencode"); + std::fs::write(&executable, "#!/bin/sh\nprintf '2.2.5\\n'\n").unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(is_v2(executable.as_os_str()).await.unwrap()); + std::fs::write(&executable, "#!/bin/sh\nprintf '1.18.0\\n'\n").unwrap(); + assert!(!is_v2(executable.as_os_str()).await.unwrap()); + } + + #[test] + fn opencode_v2_private_flag_follows_subcommand() { + let private = vec![OsString::from("--standalone")]; + assert_eq!( + OpenCode.invocation_args(private.clone(), vec!["run".into(), "hello".into()]), + vec!["run", "--standalone", "hello"] + ); + assert_eq!( + OpenCode.invocation_args( + private, + vec!["serve".into(), "--port".into(), "1234".into()] + ), + vec!["serve", "--port", "1234"] + ); + } } diff --git a/bt-daemon/src/server/rpc.rs b/bt-daemon/src/server/rpc.rs index f8e8c938..9eeeda50 100644 --- a/bt-daemon/src/server/rpc.rs +++ b/bt-daemon/src/server/rpc.rs @@ -53,6 +53,7 @@ pub(super) async fn serve_connection( continue; } let mut shutdown_after_response = false; + let mut stop_after_response = false; let response = match Message::from_line(&line) { Ok(Message::Request(req)) => { let request_id = req.id.clone(); @@ -63,6 +64,14 @@ pub(super) async fn serve_connection( "request received" ); let response = handle_request(&daemon, req, &mut client).await; + stop_after_response = method == method::EVENT_LOG + && (response.error.is_some() + || response + .result + .as_ref() + .and_then(|v| v.get("accepted")) + .and_then(Value::as_bool) + == Some(false)); shutdown_after_response = method == method::DAEMON_SHUTDOWN && response.error.is_none() && response @@ -139,6 +148,9 @@ pub(super) async fn serve_connection( daemon.trigger_shutdown(); } write_result?; + if stop_after_response { + break; + } } } Ok(()) @@ -211,6 +223,7 @@ pub(super) async fn handle_request( daemon_version: daemon.version.clone(), capabilities: Capabilities { sources: daemon.translators.sources(), + ordered_event_pipeline: true, }, }; Response::serialized(id, result) diff --git a/bt-daemon/src/translate/mod.rs b/bt-daemon/src/translate/mod.rs index 40780482..c48f7528 100644 --- a/bt-daemon/src/translate/mod.rs +++ b/bt-daemon/src/translate/mod.rs @@ -20,6 +20,7 @@ mod git; mod grok; mod muse; mod opencode; +mod opencode_v2; mod pi; pub(crate) use claude::successful_subagent_handback_message; pub(crate) use pi::request_config as pi_request_config; diff --git a/bt-daemon/src/translate/opencode.rs b/bt-daemon/src/translate/opencode.rs index c487b0cf..de3028af 100644 --- a/bt-daemon/src/translate/opencode.rs +++ b/bt-daemon/src/translate/opencode.rs @@ -17,16 +17,72 @@ use std::sync::Arc; impl TranslatorFactory for crate::agents::OpenCode { fn create(&self, session_id: &str) -> Box { - Box::new(OpenCodeTranslator { - daemon_session_id: session_id.to_string(), - sessions: HashMap::new(), - permission_requests: HashMap::new(), - git: GitMetadataCache::shared(), - last_ts_ms: 0, + Box::new(OpenCodeVersionedTranslator { + v1: new_translator(session_id), + v2: super::opencode_v2::OpenCodeV2Translator::new(session_id), + version: None, }) } } +pub(super) fn new_translator(session_id: &str) -> Box { + Box::new(OpenCodeTranslator { + daemon_session_id: session_id.to_string(), + sessions: HashMap::new(), + permission_requests: HashMap::new(), + git: GitMetadataCache::shared(), + last_ts_ms: 0, + }) +} + +struct OpenCodeVersionedTranslator { + v1: Box, + v2: super::opencode_v2::OpenCodeV2Translator, + version: Option, +} + +impl AgentTranslator for OpenCodeVersionedTranslator { + fn handle(&mut self, event: &Envelope, ctx: &SessionCtx) -> anyhow::Result> { + if event.event == "session_start" { + return Ok(Vec::new()); + } + let v2 = *self + .version + .get_or_insert_with(|| match event.source_version.as_deref() { + Some(version) => version.trim_start_matches('v').split('.').next() == Some("2"), + None => { + event.payload.get("type").and_then(Value::as_str) == Some(event.event.as_str()) + && event + .payload + .pointer("/data/sessionID") + .and_then(Value::as_str) + .is_some() + } + }); + if v2 { + self.v2.handle(event, ctx) + } else { + self.v1.handle(event, ctx) + } + } + + fn checkpoint(&mut self, ctx: &SessionCtx) -> anyhow::Result> { + if self.version == Some(true) { + self.v2.checkpoint(ctx) + } else { + self.v1.checkpoint(ctx) + } + } + + fn finalize(&mut self, ctx: &SessionCtx) -> anyhow::Result> { + if self.version == Some(true) { + self.v2.finalize(ctx) + } else { + self.v1.finalize(ctx) + } + } +} + // Partial DTOs describe only the native fields this reducer interprets. Serde // deliberately accepts future fields, while tool inputs, outputs, and message // content remain raw JSON where Braintrust preserves the native representation. @@ -297,6 +353,8 @@ struct MessageInfo { #[serde(default)] summary: bool, #[serde(default)] + compaction: bool, + #[serde(default)] error: Option, } @@ -355,6 +413,7 @@ struct NativeSession { current_input: Option, current_output: Option, system_prompt: Option, + model_input: Option>, output_parts: HashMap, reasoning_parts: HashMap, tool_calls: HashMap>, @@ -519,6 +578,18 @@ impl AgentTranslator for OpenCodeTranslator { "session.created" => self.session_created(event, ctx), "chat.message" => self.chat_message(event, ctx), "experimental.chat.system.transform" => self.system_prompt(event), + "model.context" => { + if let Some(sid) = native_session_id(&event.payload) { + if let Some(state) = self.sessions.get_mut(&sid) { + state.model_input = event + .payload + .get("messages") + .and_then(Value::as_array) + .cloned(); + } + } + Vec::new() + } "message.part.updated" => properties::(&event.payload, "part") .map(|part| self.part_updated(part, event.ts_ms)) .unwrap_or_default(), @@ -702,10 +773,13 @@ impl OpenCodeTranslator { state.tool_message_ids.clear(); state.completed_messages.clear(); state.turn_number += 1; - let turn_id = ids::span_id( - &self.daemon_session_id, - &format!("turn:{sid}:{}", state.turn_number), - ); + let turn_key = event + .payload + .pointer("/input/turnID") + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or_else(|| state.turn_number.to_string()); + let turn_id = ids::span_id(&self.daemon_session_id, &format!("turn:{sid}:{turn_key}")); let output = event.payload.get("output").unwrap_or(&event.payload); let input = output .get("parts") @@ -719,6 +793,24 @@ impl OpenCodeTranslator { .join("\n") }) .unwrap_or_default(); + let images = output + .get("parts") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|part| part.get("type").and_then(Value::as_str) == Some("image_url")) + .cloned() + .collect::>(); + let input_value = if images.is_empty() { + Value::String(input.clone()) + } else { + let mut content = Vec::new(); + if !input.is_empty() { + content.push(json!({"type":"text","text":input})); + } + content.extend(images); + Value::Array(content) + }; let message_id = output .get("message") .and_then(|message| message.get("id")) @@ -749,8 +841,8 @@ impl OpenCodeTranslator { ); state.history.upsert( &message_id, - (!input.is_empty()) - .then(|| json!({"role":"user","content":input})) + (!input.is_empty() || input_value.is_array()) + .then(|| json!({"role":"user","content":input_value})) .into_iter() .collect(), ); @@ -771,7 +863,7 @@ impl OpenCodeTranslator { span_type: SpanType::Task, is_turn: true, start_ms: Some(event.ts_ms), - input: (!input.is_empty()).then_some(Value::String(input)), + input: (!input.is_empty() || input_value.is_array()).then_some(input_value), metadata: Some( json!({"turn_number":state.turn_number,"model":model,"loaded_skill_names":skills}), ), @@ -966,6 +1058,7 @@ impl OpenCodeTranslator { })); } } + let captured_input = state.model_input.take(); let mut input = Vec::new(); if let Some(system) = &state.system_prompt { input.push(json!({"role":"system","content":system})) @@ -984,8 +1077,13 @@ impl OpenCodeTranslator { input.extend(state.history.active()); state.history.upsert(mid, history_values); } - let Some(turn) = turn else { - return Vec::new(); + if let Some(captured) = captured_input { + input = captured; + } + let parent = match &turn { + Some(turn) => turn.clone(), + None if info.summary || info.compaction => state.root_span_id.clone(), + None => return Vec::new(), }; let provider = info.provider_id.as_deref().unwrap_or("unknown"); let model = info.model_id.as_deref().unwrap_or("unknown"); @@ -997,13 +1095,8 @@ impl OpenCodeTranslator { vec![SpanOp::Insert(SpanRow { span_id: ids::span_id(&self.daemon_session_id, &format!("llm:{sid}:{mid}")), root_span_id: state.effective_root_span_id.clone(), - turn_span_id: Some( - state - .owner_turn_span_id - .clone() - .unwrap_or_else(|| turn.clone()), - ), - parent_span_ids: vec![turn], + turn_span_id: state.owner_turn_span_id.clone().or(turn), + parent_span_ids: vec![parent], name: format!("{provider}/{model}"), span_type: SpanType::Llm, start_ms: info @@ -1023,6 +1116,7 @@ impl OpenCodeTranslator { json!({"prompt_tokens":prompt,"completion_tokens":completion,"tokens":prompt+completion+reasoning,"prompt_cached_tokens":cache_read,"prompt_cache_creation_tokens":cache_write,"reasoning_tokens":reasoning}), ), error: info.error.as_ref().map(|error| format_error(Some(error))), + tags: (info.summary || info.compaction).then(|| vec!["compaction".into()]), ..Default::default() })] } diff --git a/bt-daemon/src/translate/opencode_v2.rs b/bt-daemon/src/translate/opencode_v2.rs new file mode 100644 index 00000000..9226d6b1 --- /dev/null +++ b/bt-daemon/src/translate/opencode_v2.rs @@ -0,0 +1,1109 @@ +//! OpenCode 2 native event translation. +mod event; +mod span; + +use self::event::*; +use self::span::*; +use super::{ + git::GitMetadataCache, local_username, recent::RecentSet, tool::ToolApproval, AgentTranslator, + SessionCtx, SpanOp, SpanRow, SpanType, +}; +use crate::{ids, wire::Envelope}; +use serde::Serialize; +use serde_json::Value; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + sync::Arc, +}; + +// SpanRow exposes JSON at the sink boundary; all data before this conversion +// uses the concrete types in `span` (apart from native, open-ended values). +fn span_value(value: impl Serialize) -> Value { + match serde_json::to_value(value) { + Ok(value) => value, + Err(error) => { + tracing::warn!(%error, "could not serialize OpenCode span data"); + Value::Null + } + } +} + +#[derive(Default)] +struct SessionState { + root: String, + trace_root: String, + parents: Vec, + owner_turn: Option, + turn: Option, + turn_number: u32, + turn_skills: Vec, + tool_count: u32, + idle: bool, + output: Option, + history: Vec, + native_window: Option, + calls: HashMap>, + results: HashMap>, + tools: HashMap<(String, String), ToolState>, + denied_tools: HashSet<(String, String)>, + completed_messages: RecentSet, +} +struct ToolState { + name: String, + skill_name: Option, + message_id: String, + span_id: String, + parent: String, + denied: bool, +} +struct Step { + started: i64, + model: Option, + context: Option, +} +struct Permission { + session_id: String, + call_id: Option, + message_id: Option, + action: String, +} +struct HistoryEntry { + id: String, + messages: Vec, +} +struct NativeWindow { + model: Option, + messages: Vec, + history_boundary: usize, +} +impl NativeWindow { + fn matches_model(&self, model: Option<&Model>) -> bool { + matches!( + (self.model.as_ref(), model), + (Some(a), Some(b)) if a.id.is_some() && a.provider_id.is_some() + && a.id == b.id && a.provider_id == b.provider_id + ) + } +} +struct Compaction { + started: i64, + input_id: Option, +} +struct CompactionResult<'a> { + session_id: &'a str, + id: &'a str, + text: String, + model: Option, + tokens: Option, + started: i64, + ended: i64, + error: Option, + keep_id: Option, + input_id: Option, + native_messages: Option, +} + +pub(super) struct OpenCodeV2Translator { + daemon_session_id: String, + sessions: HashMap, + pending_inputs: HashMap<(String, String), Prompt>, + tool_names: HashMap<(String, String, String), String>, + steps: HashMap<(String, String), Step>, + pending_context: HashMap, + text_parts: HashMap<(String, String, bool), BTreeMap>, + permissions: HashMap, + compactions: HashMap, + seen: RecentSet, + last_ts_ms: i64, + git: Arc, +} +impl OpenCodeV2Translator { + pub(super) fn new(session_id: &str) -> Self { + Self { + daemon_session_id: session_id.into(), + sessions: HashMap::new(), + pending_inputs: HashMap::new(), + tool_names: HashMap::new(), + steps: HashMap::new(), + pending_context: HashMap::new(), + text_parts: HashMap::new(), + permissions: HashMap::new(), + compactions: HashMap::new(), + seen: RecentSet::default(), + last_ts_ms: 0, + git: GitMetadataCache::shared(), + } + } + fn open( + &mut self, + sid: &str, + parent_id: Option<&str>, + title: Option<&str>, + ts: i64, + ) -> Vec { + if self.sessions.contains_key(sid) { + return vec![]; + } + let root = ids::span_id(&self.daemon_session_id, &format!("session:{sid}")); + let parent = parent_id.and_then(|p| self.sessions.get(p)); + let trace_root = parent + .map(|p| p.trace_root.clone()) + .unwrap_or_else(|| root.clone()); + let parents = parent + .and_then(|p| p.turn.clone()) + .into_iter() + .collect::>(); + let owner = parent.and_then(|p| p.owner_turn.clone().or_else(|| p.turn.clone())); + let name = if parent_id.is_some() { + let title = title.unwrap_or("Subagent"); + if let Some((description, agent)) = title.split_once(" (@") { + format!( + "{}: {description}", + agent.strip_suffix(" subagent)").unwrap_or(agent) + ) + } else { + title.to_owned() + } + } else { + "OpenCode".into() + }; + let metadata = RootMetadata { + session_id: sid, + source: "opencode", + defer_root: true, + username: local_username(), + parent_session_id: parent_id, + is_subagent: parent_id.map(|_| true), + }; + self.sessions.insert( + sid.into(), + SessionState { + root: root.clone(), + trace_root: trace_root.clone(), + parents: parents.clone(), + owner_turn: owner.clone(), + ..Default::default() + }, + ); + vec![SpanOp::Insert(SpanRow { + span_id: root, + root_span_id: trace_root, + parent_span_ids: parents, + turn_span_id: owner, + name, + span_type: SpanType::Task, + start_ms: Some(ts), + metadata: Some(span_value(metadata)), + ..Default::default() + })] + } + fn turn(&mut self, sid: &str, id: &str, prompt: Prompt, ts: i64) -> Vec { + let mut ops = self.open(sid, None, None, ts); + let Some(s) = self.sessions.get_mut(sid) else { + return ops; + }; + if s.idle { + s.idle = false; + ops.push(SpanOp::Merge(SpanRow { + span_id: s.root.clone(), + root_span_id: s.trace_root.clone(), + parent_span_ids: s.parents.clone(), + ..Default::default() + })); + } + if let Some(turn) = s.turn.take() { + ops.push(SpanOp::Merge(SpanRow { + span_id: turn, + root_span_id: s.trace_root.clone(), + parent_span_ids: vec![s.root.clone()], + end_ms: Some(ts), + output: s.output.take().map(Value::String), + ..Default::default() + })); + } + s.turn_number += 1; + let turn = ids::span_id(&self.daemon_session_id, &format!("turn:{sid}:{id}")); + let mut content = vec![ContentPart::text(prompt.text.clone())]; + for file in prompt.files { + if file.mime.starts_with("image/") { + content.push(ContentPart::image(format!( + "data:{};base64,{}", + file.mime, file.data + ))); + } + } + let input = if content.len() == 1 { + Content::Text(prompt.text.clone()) + } else { + Content::Parts(content) + }; + let has_input = !prompt.text.is_empty() || matches!(input, Content::Parts(_)); + s.history.push(HistoryEntry { + id: id.into(), + messages: has_input + .then(|| Message::new("user", input.clone())) + .into_iter() + .collect(), + }); + let mut skills = prompt + .text + .split_whitespace() + .filter_map(|s| { + s.strip_prefix("/skills:") + .or_else(|| s.strip_prefix("/skills")) + }) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect::>(); + for skill in prompt.skills { + if !skills.contains(&skill.name) { + skills.push(skill.name); + } + } + s.turn = Some(turn.clone()); + s.turn_skills = skills.clone(); + ops.push(SpanOp::Insert(SpanRow { + span_id: turn.clone(), + root_span_id: s.trace_root.clone(), + parent_span_ids: vec![s.root.clone()], + turn_span_id: Some(s.owner_turn.clone().unwrap_or(turn)), + name: format!("Turn {}", s.turn_number), + span_type: SpanType::Task, + is_turn: true, + start_ms: Some(ts), + input: has_input.then(|| span_value(input)), + metadata: Some(span_value(TurnMetadata { + turn_number: s.turn_number, + model: None, + loaded_skill_names: skills, + })), + ..Default::default() + })); + ops + } + fn finish( + &mut self, + sid: &str, + ts: i64, + permanent: bool, + error: Option, + ) -> Vec { + let Some(mut s) = self.sessions.remove(sid) else { + return vec![]; + }; + let mut ops = Vec::new(); + for (_, tool) in s.tools.drain() { + if !tool.denied { + ops.push(SpanOp::Merge(SpanRow { + span_id: tool.span_id, + root_span_id: s.trace_root.clone(), + parent_span_ids: vec![tool.parent], + end_ms: Some(ts), + error: Some("Interrupted before tool completion".into()), + ..Default::default() + })); + } + } + if let Some(turn) = s.turn.take() { + ops.push(SpanOp::Merge(SpanRow { + span_id: turn, + root_span_id: s.trace_root.clone(), + parent_span_ids: vec![s.root.clone()], + end_ms: Some(ts), + output: s.output.take().map(Value::String), + error: error.clone(), + ..Default::default() + })); + } + ops.push(SpanOp::Merge(SpanRow { + span_id: s.root.clone(), + root_span_id: s.trace_root.clone(), + parent_span_ids: s.parents.clone(), + end_ms: Some(ts), + metadata: Some(span_value(RootTotals { + total_turns: s.turn_number, + total_tool_calls: s.tool_count, + })), + error, + ..Default::default() + })); + if !permanent { + s.idle = true; + self.sessions.insert(sid.into(), s); + } + ops + } +} +impl AgentTranslator for OpenCodeV2Translator { + fn handle(&mut self, event: &Envelope, _ctx: &SessionCtx) -> anyhow::Result> { + self.last_ts_ms = self.last_ts_ms.max(event.ts_ms); + let mut payload = event.payload.clone(); + let Some(object) = payload.as_object_mut() else { + return Ok(vec![]); + }; + object.insert("type".into(), event.event.clone().into()); + let Ok(native) = serde_json::from_value::(payload) else { + return Ok(vec![]); + }; + if let Some(id) = &native.id { + if !self.seen.insert(id.clone()) { + return Ok(vec![]); + } + } + let directory = native + .location + .as_ref() + .and_then(|location| location.directory.as_deref()); + let ts = event.ts_ms; + let mut ops = match native.event { + NativeEvent::Created(data) => self.open( + &data.session_id, + data.parent_id.as_deref(), + data.title.as_deref(), + ts, + ), + NativeEvent::Enqueued(data) => { + if let InboxItem::User { payload } = data.item { + self.pending_inputs + .insert((data.session_id, data.inbox_id), payload); + } + vec![] + } + NativeEvent::Delivered(data) => { + match self + .pending_inputs + .remove(&(data.session_id.clone(), data.inbox_id.clone())) + { + Some(prompt) => self.turn(&data.session_id, &data.inbox_id, prompt, ts), + None => vec![], + } + } + NativeEvent::Cancelled(data) => { + self.pending_inputs + .remove(&(data.session_id, data.inbox_id)); + vec![] + } + NativeEvent::Context(data) => { + self.pending_context.insert(data.session_id.clone(), data); + vec![] + } + NativeEvent::StepStarted(data) => { + let context = self.pending_context.remove(&data.session_id); + let turn_update = self.sessions.get(&data.session_id).and_then(|s| { + let turn = s.turn.as_ref()?; + let model = data.model.as_ref()?.id.as_ref()?; + Some(SpanOp::Merge(SpanRow { + span_id: turn.clone(), + root_span_id: s.trace_root.clone(), + parent_span_ids: vec![s.root.clone()], + metadata: Some(span_value(TurnMetadata { + turn_number: s.turn_number, + model: Some(model.clone()), + loaded_skill_names: s.turn_skills.clone(), + })), + ..Default::default() + })) + }); + self.steps.insert( + (data.session_id, data.message_id), + Step { + started: data.started.unwrap_or(ts), + model: data.model, + context, + }, + ); + turn_update.into_iter().collect() + } + NativeEvent::ToolInputStarted(data) => { + self.tool_names.insert( + ( + data.session_id, + data.message_id.unwrap_or_default(), + data.id, + ), + data.name, + ); + vec![] + } + NativeEvent::ToolCalled(data) => { + let name = self + .tool_names + .get(&( + data.session_id.clone(), + data.message_id.clone(), + data.id.clone(), + )) + .or_else(|| { + self.tool_names.get(&( + data.session_id.clone(), + String::new(), + data.id.clone(), + )) + }) + .cloned() + .unwrap_or_else(|| "tool".into()); + let Some(s) = self.sessions.get_mut(&data.session_id) else { + return Ok(vec![]); + }; + let Some(parent) = s.turn.clone() else { + return Ok(vec![]); + }; + let tool_key = (data.message_id.clone(), data.id.clone()); + if s.tools.contains_key(&tool_key) { + return Ok(vec![]); + } + if s.denied_tools.contains(&tool_key) { + return Ok(vec![]); + } + let span_id = ids::span_id( + &self.daemon_session_id, + &format!("tool:{}:{}:{}", data.session_id, data.message_id, data.id), + ); + let span_name = if name == "skill" { + data.input + .get("name") + .and_then(Value::as_str) + .map(|skill| format!("skill: {skill}")) + .unwrap_or_else(|| name.clone()) + } else { + name.clone() + }; + let skill_name = (name == "skill") + .then(|| { + data.input + .get("name") + .and_then(Value::as_str) + .map(str::to_owned) + }) + .flatten(); + s.calls + .entry(data.message_id.clone()) + .or_default() + .push(ToolCall::new(data.id.clone(), name.clone(), &data.input)); + s.tools.insert( + tool_key, + ToolState { + name: name.clone(), + skill_name, + message_id: data.message_id, + span_id: span_id.clone(), + parent: parent.clone(), + denied: false, + }, + ); + vec![SpanOp::Insert(SpanRow { + span_id, + root_span_id: s.trace_root.clone(), + parent_span_ids: vec![parent], + turn_span_id: s.owner_turn.clone().or_else(|| s.turn.clone()), + name: span_name, + span_type: SpanType::Tool, + start_ms: Some(ts), + input: Some(data.input), + metadata: Some(span_value(ToolMetadata::new(&name, &data.id))), + ..Default::default() + })] + } + NativeEvent::ToolSuccess(data) => { + let output = normalize_tool_content(data.content.unwrap_or_default()); + self.finish_tool( + &data.session_id, + &data.message_id, + &data.id, + Some(output), + None, + ts, + ) + } + NativeEvent::ToolFailed(data) => { + let error = data.error.message; + self.finish_tool( + &data.session_id, + &data.message_id, + &data.id, + None, + Some(error), + ts, + ) + } + NativeEvent::PermissionAsked(data) => { + self.permissions.insert( + data.id, + Permission { + session_id: data.session_id, + call_id: data.source.as_ref().map(|source| source.id.clone()), + message_id: data.source.and_then(|source| source.message_id), + action: data.action, + }, + ); + vec![] + } + NativeEvent::PermissionReplied(data) => { + let Some(p) = self.permissions.remove(&data.request_id) else { + return Ok(vec![]); + }; + if p.session_id != data.session_id { + return Ok(vec![]); + } + if !matches!(data.reply.as_str(), "reject" | "denied" | "deny") { + return Ok(vec![]); + } + let Some(call) = p.call_id else { + return Ok(vec![]); + }; + let Some(s) = self.sessions.get_mut(&p.session_id) else { + return Ok(vec![]); + }; + let message_id = p + .message_id + .or_else(|| { + let mut matches = self + .tool_names + .keys() + .filter(|(sid, _, id)| sid == &p.session_id && id == &call) + .map(|(_, message_id, _)| message_id.clone()); + let first = matches.next()?; + matches.next().is_none().then_some(first) + }) + .or_else(|| { + let mut matches = s + .tools + .keys() + .filter(|(_, id)| id == &call) + .map(|(message_id, _)| message_id.clone()); + let first = matches.next()?; + matches.next().is_none().then_some(first) + }); + let Some(message_id) = message_id else { + return Ok(vec![]); + }; + let key = (message_id.clone(), call.clone()); + let tool_name = self + .tool_names + .get(&(p.session_id.clone(), message_id.clone(), call.clone())) + .cloned() + .unwrap_or_else(|| p.action.clone()); + let existing = s.tools.get_mut(&key); + let (span_id, parent, had_start) = if let Some(tool) = existing { + tool.denied = true; + (tool.span_id.clone(), tool.parent.clone(), true) + } else { + let Some(parent) = s.turn.clone() else { + return Ok(vec![]); + }; + ( + ids::span_id( + &self.daemon_session_id, + &format!("tool:{}:{message_id}:{call}", p.session_id), + ), + parent, + false, + ) + }; + s.denied_tools.insert(key); + let mut metadata = ToolMetadata::new(&tool_name, &call); + metadata.tool_approval = Some(ToolApproval::Denied); + metadata.permission_id = Some(&data.request_id); + metadata.permission_type = Some(&p.action); + let row = SpanRow { + span_id, + root_span_id: s.trace_root.clone(), + parent_span_ids: vec![parent], + turn_span_id: (!had_start) + .then(|| s.owner_turn.clone().or_else(|| s.turn.clone())) + .flatten(), + name: if had_start { + String::new() + } else { + tool_name.clone() + }, + span_type: SpanType::Tool, + start_ms: (!had_start).then_some(ts), + end_ms: Some(ts), + metadata: Some(span_value(metadata)), + ..Default::default() + }; + vec![if had_start { + SpanOp::Merge(row) + } else { + SpanOp::Insert(row) + }] + } + NativeEvent::TextEnded(data) | NativeEvent::ReasoningEnded(data) => { + let reasoning = event.event == "session.reasoning.ended"; + self.text_parts + .entry((data.session_id, data.message_id, reasoning)) + .or_default() + .insert(data.ordinal.unwrap_or(0), data.text); + vec![] + } + NativeEvent::StepEnded(data) | NativeEvent::StepFailed(data) => { + self.finish_step(data, ts) + } + NativeEvent::CompactionStarted(data) => { + self.compactions.insert( + data.session_id, + Compaction { + started: ts, + input_id: data.input_id, + }, + ); + vec![] + } + NativeEvent::CompactionEnded(data) => { + let start = self + .compactions + .remove(&data.session_id) + .unwrap_or(Compaction { + started: ts, + input_id: None, + }); + self.compact(CompactionResult { + session_id: &data.session_id, + id: native.id.as_deref().unwrap_or("compaction-summary"), + text: data.text, + model: data.model, + tokens: data.tokens, + started: start.started, + ended: ts, + error: None, + keep_id: native.metadata.and_then(|m| m.keep_id), + input_id: start.input_id, + native_messages: data + .native + .and_then(|n| n.messages) + .or_else(|| data.provider_context.and_then(|n| n.messages)), + }) + } + NativeEvent::CompactionFailed(data) => { + let start = self + .compactions + .remove(&data.session_id) + .unwrap_or(Compaction { + started: ts, + input_id: None, + }); + self.compact(CompactionResult { + session_id: &data.session_id, + id: native.id.as_deref().unwrap_or("compaction-failed"), + text: String::new(), + model: None, + tokens: data.tokens, + started: start.started, + ended: ts, + error: Some(data.error.message), + keep_id: None, + input_id: start.input_id, + native_messages: None, + }) + } + NativeEvent::Succeeded(data) => self.finish(&data.session_id, ts, false, None), + NativeEvent::Failed(data) => { + self.finish(&data.session_id, ts, false, Some(data.error.message)) + } + NativeEvent::Interrupted(data) => self.finish( + &data.session_id, + ts, + false, + Some(format!( + "OpenCode session interrupted ({})", + data.reason.as_deref().unwrap_or("unknown") + )), + ), + NativeEvent::Deleted(data) => self.finish(&data.session_id, ts, true, None), + NativeEvent::Other => vec![], + }; + self.git.enrich_rows(directory, &mut ops); + Ok(ops) + } + fn finalize(&mut self, _ctx: &SessionCtx) -> anyhow::Result> { + let ids: Vec<_> = self.sessions.keys().cloned().collect(); + Ok(ids + .iter() + .flat_map(|id| self.finish(id, self.last_ts_ms, true, None)) + .collect()) + } +} +impl OpenCodeV2Translator { + fn finish_tool( + &mut self, + sid: &str, + message_id: &str, + call: &str, + output: Option>, + error: Option, + ts: i64, + ) -> Vec { + self.tool_names + .remove(&(sid.into(), message_id.into(), call.into())); + self.tool_names + .remove(&(sid.into(), String::new(), call.into())); + let key = (message_id.to_owned(), call.to_owned()); + let Some(s) = self.sessions.get_mut(sid) else { + return vec![]; + }; + if s.denied_tools.remove(&key) { + s.tools.remove(&key); + return vec![]; + } + if !s + .tools + .get(&key) + .is_some_and(|tool| tool.message_id == message_id) + { + return vec![]; + } + let Some(tool) = s.tools.remove(&key) else { + return vec![]; + }; + if tool.denied { + return vec![]; + } + s.tool_count += 1; + let result = if let Some(error) = &error { + Content::Text(error.clone()) + } else { + Content::Parts(output.clone().unwrap_or_default()) + }; + s.results + .entry(tool.message_id) + .or_default() + .push(Message::tool(call.into(), result)); + let mut metadata = ToolMetadata::new(&tool.name, call); + metadata.tool_approval = Some(ToolApproval::Approved); + if tool.name == "skill" { + metadata.tool_kind = Some("skill"); + metadata.skill_name = Some(tool.skill_name.as_deref()); + } + vec![SpanOp::Merge(SpanRow { + span_id: tool.span_id, + root_span_id: s.trace_root.clone(), + parent_span_ids: vec![tool.parent], + end_ms: Some(ts), + output: Some(span_value(output)), + error, + metadata: Some(span_value(metadata)), + ..Default::default() + })] + } + fn finish_step(&mut self, data: StepEnded, ts: i64) -> Vec { + let key = (data.session_id.clone(), data.message_id.clone()); + let step = self.steps.remove(&key).unwrap_or(Step { + started: ts, + model: None, + context: None, + }); + let text = self + .text_parts + .remove(&(data.session_id.clone(), data.message_id.clone(), false)) + .map(|parts| parts.into_values().collect::()) + .unwrap_or_default(); + let reasoning = self + .text_parts + .remove(&(data.session_id.clone(), data.message_id.clone(), true)) + .map(|parts| parts.into_values().collect::()); + let Some(s) = self.sessions.get_mut(&data.session_id) else { + return vec![]; + }; + if !s.completed_messages.insert(data.message_id.clone()) { + return vec![]; + } + let Some(parent) = s.turn.clone() else { + return vec![]; + }; + let mut assistant = Message::new("assistant", Content::Text(text.clone())); + let calls = s.calls.remove(&data.message_id).unwrap_or_default(); + if !calls.is_empty() { + assistant.tool_calls = calls.clone(); + } + if let Some(reasoning) = reasoning { + assistant.reasoning = Some(vec![Reasoning { + id: "reasoning", + content: reasoning, + }]); + } + let input = step.context.map(normalize_context).unwrap_or_else(|| { + let window = s + .native_window + .as_ref() + .filter(|window| window.matches_model(step.model.as_ref())); + let (mut input, start) = window.map_or_else( + || (Vec::new(), 0), + |window| (window.messages.clone(), window.history_boundary), + ); + input.extend( + s.history[start..] + .iter() + .flat_map(|entry| entry.messages.iter().cloned()), + ); + input + }); + if !text.is_empty() { + s.output = Some(text.clone()); + } + let mut messages = Vec::new(); + if !text.is_empty() || !calls.is_empty() { + messages.push(assistant.clone()); + } + messages.extend(s.results.remove(&data.message_id).unwrap_or_default()); + s.history.push(HistoryEntry { + id: data.message_id.clone(), + messages, + }); + let metrics = Metrics::from_tokens(data.tokens.unwrap_or_default()); + let provider = step + .model + .as_ref() + .and_then(|m| m.provider_id.as_deref()) + .unwrap_or("unknown"); + let model = step + .model + .as_ref() + .and_then(|m| m.id.as_deref()) + .unwrap_or("unknown"); + let metadata = LlmMetadata { + provider, + model, + message_id: &data.message_id, + variant: step.model.as_ref().and_then(|m| m.variant.as_deref()), + }; + vec![SpanOp::Insert(SpanRow { + span_id: ids::span_id( + &self.daemon_session_id, + &format!("llm:{}:{}", data.session_id, data.message_id), + ), + root_span_id: s.trace_root.clone(), + parent_span_ids: vec![parent.clone()], + turn_span_id: Some(s.owner_turn.clone().unwrap_or(parent)), + name: format!("{provider}/{model}"), + span_type: SpanType::Llm, + start_ms: Some(step.started), + end_ms: Some(ts), + input: Some(span_value(input)), + output: Some(span_value([assistant])), + metadata: Some(span_value(metadata)), + metrics: Some(span_value(metrics)), + error: data.error.map(|e| e.message), + ..Default::default() + })] + } + fn compact(&mut self, result: CompactionResult<'_>) -> Vec { + let CompactionResult { + session_id: sid, + id, + text, + model, + tokens, + started, + ended: ts, + error, + keep_id, + input_id, + native_messages, + } = result; + let Some(s) = self.sessions.get_mut(sid) else { + return vec![]; + }; + let parent = s.turn.clone().unwrap_or_else(|| s.root.clone()); + if let Some(input_id) = input_id { + if let Some(window) = &mut s.native_window { + let removed = s.history[..window.history_boundary] + .iter() + .filter(|entry| entry.id == input_id) + .count(); + window.history_boundary -= removed; + } + s.history.retain(|entry| entry.id != input_id); + } + let tail = keep_id + .as_deref() + .and_then(|id| s.history.iter().position(|entry| entry.id == id)) + .unwrap_or(s.history.len()); + let window = s.native_window.as_ref().filter(|window| { + window.history_boundary <= tail && window.matches_model(model.as_ref()) + }); + let (mut input, start) = window.map_or_else( + || (Vec::new(), 0), + |window| (window.messages.clone(), window.history_boundary), + ); + input.extend( + s.history[start..tail] + .iter() + .flat_map(|entry| entry.messages.iter().cloned()), + ); + let output = match native_messages { + Some(NativeMessages::Messages(messages)) => { + let messages = normalize_context(Context { + session_id: sid.to_owned(), + system: Vec::new(), + messages: messages + .into_iter() + .map(|message| ContextMessage { + role: message.role, + content: message.content, + }) + .collect(), + }); + if error.is_none() { + s.native_window = Some(NativeWindow { + model: model.clone(), + messages: messages.clone(), + history_boundary: s.history.len(), + }); + } + span_value(messages) + } + Some(NativeMessages::Opaque(messages)) => { + if error.is_none() { + s.native_window = None; + } + messages + } + None => { + if error.is_none() { + let kept = s.history.drain(tail..).collect::>(); + s.history.clear(); + s.history.push(HistoryEntry { + id: id.into(), + messages: vec![Message::summary(text.clone())], + }); + s.history.extend(kept); + s.native_window = None; + } + span_value([Message::new("assistant", Content::Text(text))]) + } + }; + let variant = model.as_ref().and_then(|m| m.variant.as_deref()); + let provider = model + .as_ref() + .and_then(|m| m.provider_id.as_deref()) + .unwrap_or("unknown"); + let model = model + .as_ref() + .and_then(|m| m.id.as_deref()) + .unwrap_or("unknown"); + let metrics = Metrics::from_tokens(tokens.unwrap_or_default()); + vec![SpanOp::Insert(SpanRow { + span_id: ids::span_id(&self.daemon_session_id, &format!("llm:{sid}:{id}")), + root_span_id: s.trace_root.clone(), + parent_span_ids: vec![parent], + turn_span_id: s.owner_turn.clone().or_else(|| s.turn.clone()), + name: format!("{provider}/{model}"), + span_type: SpanType::Llm, + start_ms: Some(started), + end_ms: Some(ts), + input: Some(span_value(input)), + output: Some(output), + metadata: Some(span_value(LlmMetadata { + provider, + model, + message_id: id, + variant, + })), + metrics: Some(span_value(metrics)), + error, + tags: Some(vec!["compaction".into()]), + ..Default::default() + })] + } +} +fn normalize_context(context: Context) -> Vec { + let mut messages = Vec::new(); + let system = context.system.join("\n\n"); + if !system.is_empty() { + messages.push(Message::new("system", Content::Text(system))); + } + for message in context.messages { + match message.content { + MessageContent::Text(text) => { + messages.push(Message::new(message.role, Content::Text(text))) + } + MessageContent::Other(content) => { + messages.push(Message::new(message.role, Content::Dynamic(content))) + } + MessageContent::Parts(parts) => { + let mut content = Vec::new(); + let mut calls = Vec::new(); + let mut single_text = None; + for part in parts { + match part { + ContextPart::Known(KnownContextPart::Text { text }) => { + single_text = Some(text.clone()); + content.push(ContentPart::text(text)); + } + ContextPart::Known(KnownContextPart::Media { + media_type, + data, + media, + extra, + }) => { + let source = media.as_ref().and_then(|media| media.source.as_ref()); + let mime = source + .and_then(|source| source.media_type.as_deref()) + .or(media_type.as_deref()); + let bytes = source + .and_then(|source| source.data.as_deref()) + .or(data.as_deref()); + if let Some((mime, bytes)) = mime + .zip(bytes) + .filter(|(mime, _)| mime.starts_with("image/")) + { + content.push(ContentPart::image(format!( + "data:{mime};base64,{bytes}" + ))); + } else { + content.push(ContentPart::Native(KnownContextPart::Media { + media_type, + data, + media, + extra, + })); + } + } + ContextPart::Known(KnownContextPart::ToolCall { id, name, input }) => { + calls.push(ToolCall::new(id, name, &input)) + } + ContextPart::Known(KnownContextPart::ToolResult { id, result }) => { + let value = match serde_json::from_value::>( + result.value.clone(), + ) { + Ok(parts) => Content::Parts(normalize_tool_content(parts)), + Err(_) => Content::Dynamic(result.value), + }; + messages.push(Message::tool(id, value)); + } + ContextPart::Other(value) => content.push(ContentPart::Dynamic(value)), + } + } + if message.role == "tool" && content.is_empty() && calls.is_empty() { + continue; + } + let value = if content.is_empty() { + Content::Text(String::new()) + } else if content.len() == 1 && single_text.is_some() { + Content::Text(single_text.unwrap_or_default()) + } else { + Content::Parts(content) + }; + let mut normalized = Message::new(message.role, value); + normalized.tool_calls = calls; + messages.push(normalized); + } + } + } + messages +} + +fn normalize_tool_content(parts: Vec) -> Vec { + parts + .into_iter() + .map(|part| match part { + ToolContent::Known(KnownToolContent::File { mime, uri, .. }) + if mime.starts_with("image/") => + { + ContentPart::image(uri) + } + other => ContentPart::Tool(other), + }) + .collect() +} diff --git a/bt-daemon/src/translate/opencode_v2/event.rs b/bt-daemon/src/translate/opencode_v2/event.rs new file mode 100644 index 00000000..a5a40ba7 --- /dev/null +++ b/bt-daemon/src/translate/opencode_v2/event.rs @@ -0,0 +1,395 @@ +//! Partial OpenCode 2 wire types. Unknown fields are accepted; arbitrary tool +//! inputs, outputs and provider-specific content remain JSON values. +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Deserialize)] +pub(super) struct NativeEnvelope { + pub id: Option, + pub location: Option, + pub metadata: Option, + #[serde(flatten)] + pub event: NativeEvent, +} +#[derive(Deserialize)] +pub(super) struct Location { + pub directory: Option, +} +#[derive(Deserialize)] +pub(super) struct EventMetadata { + #[serde(rename = "keepID")] + pub keep_id: Option, +} + +#[derive(Deserialize)] +#[serde(tag = "type", content = "data")] +pub(super) enum NativeEvent { + #[serde(rename = "session.created")] + Created(Created), + #[serde(rename = "session.inbox.enqueued")] + Enqueued(Enqueued), + #[serde(rename = "session.inbox.delivered")] + Delivered(Inbox), + #[serde(rename = "session.inbox.cancelled")] + Cancelled(Inbox), + #[serde(rename = "model.context")] + Context(Context), + #[serde(rename = "session.step.started")] + StepStarted(StepStarted), + #[serde(rename = "session.step.ended")] + StepEnded(StepEnded), + #[serde(rename = "session.step.failed")] + StepFailed(StepEnded), + #[serde(rename = "session.text.ended")] + TextEnded(TextEnded), + #[serde(rename = "session.reasoning.ended")] + ReasoningEnded(TextEnded), + #[serde(rename = "session.tool.input.started")] + ToolInputStarted(ToolInputStarted), + #[serde(rename = "session.tool.called")] + ToolCalled(ToolCalled), + #[serde(rename = "session.tool.success")] + ToolSuccess(ToolSuccess), + #[serde(rename = "session.tool.failed")] + ToolFailed(ToolFailed), + #[serde(rename = "permission.asked")] + PermissionAsked(PermissionAsked), + #[serde(rename = "permission.replied")] + PermissionReplied(PermissionReplied), + #[serde(rename = "session.compaction.started")] + CompactionStarted(CompactionStarted), + #[serde(rename = "session.compaction.ended")] + CompactionEnded(CompactionEnded), + #[serde(rename = "session.compaction.failed")] + CompactionFailed(CompactionFailed), + #[serde(rename = "session.execution.succeeded")] + Succeeded(Session), + #[serde(rename = "session.execution.failed")] + Failed(ExecutionFailed), + #[serde(rename = "session.execution.interrupted")] + Interrupted(Interrupted), + #[serde(rename = "session.deleted")] + Deleted(Session), + #[serde(other)] + Other, +} + +#[derive(Deserialize)] +pub(super) struct Session { + #[serde(rename = "sessionID")] + pub session_id: String, +} +#[derive(Deserialize)] +pub(super) struct Created { + #[serde(rename = "sessionID")] + pub session_id: String, + #[serde(rename = "parentID")] + pub parent_id: Option, + pub title: Option, +} +#[derive(Deserialize)] +pub(super) struct Inbox { + #[serde(rename = "sessionID")] + pub session_id: String, + #[serde(rename = "inboxID")] + pub inbox_id: String, +} +#[derive(Deserialize)] +pub(super) struct Enqueued { + #[serde(rename = "sessionID")] + pub session_id: String, + #[serde(rename = "inboxID")] + pub inbox_id: String, + pub item: InboxItem, +} +#[derive(Deserialize)] +#[serde(tag = "type")] +pub(super) enum InboxItem { + #[serde(rename = "user")] + User { payload: Prompt }, + #[serde(other)] + Other, +} +#[derive(Deserialize)] +pub(super) struct Prompt { + #[serde(default)] + pub text: String, + #[serde(default)] + pub files: Vec, + #[serde(default)] + pub skills: Vec, +} +#[derive(Deserialize)] +pub(super) struct PromptSkill { + pub name: String, +} +#[derive(Deserialize)] +pub(super) struct PromptFile { + pub mime: String, + pub data: String, +} +#[derive(Clone, Default, Deserialize, Serialize)] +pub(super) struct Model { + pub id: Option, + #[serde(rename = "providerID")] + pub provider_id: Option, + pub variant: Option, +} +#[derive(Deserialize)] +pub(super) struct StepStarted { + #[serde(rename = "sessionID")] + pub session_id: String, + #[serde(rename = "assistantMessageID")] + pub message_id: String, + pub started: Option, + pub model: Option, +} +#[derive(Deserialize)] +pub(super) struct StepEnded { + #[serde(rename = "sessionID")] + pub session_id: String, + #[serde(rename = "assistantMessageID")] + pub message_id: String, + pub tokens: Option, + pub error: Option, +} +#[derive(Default, Deserialize, Serialize)] +pub(super) struct Tokens { + #[serde(default)] + pub input: i64, + #[serde(default)] + pub output: i64, + #[serde(default)] + pub reasoning: i64, + #[serde(default)] + pub cache: CacheTokens, +} +#[derive(Default, Deserialize, Serialize)] +pub(super) struct CacheTokens { + #[serde(default)] + pub read: i64, + #[serde(default)] + pub write: i64, +} +#[derive(Deserialize)] +pub(super) struct TextEnded { + #[serde(rename = "sessionID")] + pub session_id: String, + #[serde(rename = "assistantMessageID")] + pub message_id: String, + pub ordinal: Option, + pub text: String, +} +#[derive(Deserialize)] +pub(super) struct ToolInputStarted { + #[serde(rename = "sessionID")] + pub session_id: String, + #[serde(rename = "assistantMessageID")] + pub message_id: Option, + pub id: String, + pub name: String, +} +#[derive(Deserialize)] +pub(super) struct ToolCalled { + #[serde(rename = "sessionID")] + pub session_id: String, + #[serde(rename = "assistantMessageID")] + pub message_id: String, + pub id: String, + pub input: Value, +} +#[derive(Deserialize)] +pub(super) struct ToolSuccess { + #[serde(rename = "sessionID")] + pub session_id: String, + #[serde(rename = "assistantMessageID")] + pub message_id: String, + pub id: String, + pub content: Option>, +} +#[derive(Deserialize)] +pub(super) struct ToolFailed { + #[serde(rename = "sessionID")] + pub session_id: String, + #[serde(rename = "assistantMessageID")] + pub message_id: String, + pub id: String, + pub error: NativeError, +} +#[derive(Deserialize, Serialize)] +pub(super) struct NativeError { + #[serde(rename = "type")] + pub kind: String, + pub message: String, + pub status: Option, + pub response: Option, + #[serde(rename = "retryAfterMs")] + pub retry_after_ms: Option, +} +#[derive(Deserialize, Serialize)] +pub(super) struct ErrorResponse { + pub body: String, +} +#[derive(Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub(super) enum ToolContent { + Known(KnownToolContent), + Other(Value), +} +#[derive(Clone, Deserialize, Serialize)] +#[serde(tag = "type")] +pub(super) enum KnownToolContent { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "file")] + File { + mime: String, + uri: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + name: Option, + }, +} +#[derive(Deserialize)] +pub(super) struct PermissionAsked { + #[serde(rename = "sessionID")] + pub session_id: String, + pub id: String, + pub action: String, + pub source: Option, +} +#[derive(Deserialize)] +pub(super) struct PermissionSource { + pub id: String, + #[serde(rename = "messageID")] + pub message_id: Option, +} +#[derive(Deserialize)] +pub(super) struct PermissionReplied { + #[serde(rename = "sessionID")] + pub session_id: String, + #[serde(rename = "requestID")] + pub request_id: String, + pub reply: String, +} +#[derive(Deserialize)] +pub(super) struct CompactionStarted { + #[serde(rename = "sessionID")] + pub session_id: String, + #[serde(rename = "inputID")] + pub input_id: Option, +} +#[derive(Deserialize)] +pub(super) struct CompactionEnded { + #[serde(rename = "sessionID")] + pub session_id: String, + pub text: String, + pub model: Option, + pub tokens: Option, + pub native: Option, + #[serde(rename = "providerContext")] + pub provider_context: Option, +} +#[derive(Deserialize)] +pub(super) struct NativeCompaction { + pub messages: Option, +} +#[derive(Deserialize)] +#[serde(untagged)] +pub(super) enum NativeMessages { + Messages(Vec), + Opaque(Value), +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct NativeContextMessage { + pub role: String, + pub content: MessageContent, +} +#[derive(Deserialize)] +pub(super) struct CompactionFailed { + #[serde(rename = "sessionID")] + pub session_id: String, + pub error: NativeError, + pub tokens: Option, +} +#[derive(Deserialize)] +pub(super) struct ExecutionFailed { + #[serde(rename = "sessionID")] + pub session_id: String, + pub error: NativeError, +} +#[derive(Deserialize)] +pub(super) struct Interrupted { + #[serde(rename = "sessionID")] + pub session_id: String, + pub reason: Option, +} + +#[derive(Deserialize)] +pub(super) struct Context { + #[serde(rename = "sessionID")] + pub session_id: String, + #[serde(default)] + pub system: Vec, + #[serde(default)] + pub messages: Vec, +} +#[derive(Deserialize)] +pub(super) struct ContextMessage { + pub role: String, + pub content: MessageContent, +} +#[derive(Deserialize)] +#[serde(untagged)] +pub(super) enum MessageContent { + Text(String), + Parts(Vec), + Other(Value), +} +#[derive(Deserialize)] +#[serde(untagged)] +pub(super) enum ContextPart { + Known(KnownContextPart), + Other(Value), +} +#[derive(Clone, Deserialize, Serialize)] +#[serde(tag = "type")] +pub(super) enum KnownContextPart { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "media")] + Media { + #[serde(rename = "mediaType")] + media_type: Option, + data: Option, + media: Option, + #[serde(flatten)] + extra: serde_json::Map, + }, + #[serde(rename = "tool-call")] + ToolCall { + id: String, + name: String, + input: Value, + }, + #[serde(rename = "tool-result")] + ToolResult { id: String, result: ToolResult }, +} +#[derive(Clone, Deserialize, Serialize)] +pub(super) struct Media { + pub source: Option, + #[serde(flatten)] + pub extra: serde_json::Map, +} +#[derive(Clone, Deserialize, Serialize)] +pub(super) struct MediaSource { + #[serde(rename = "mediaType")] + pub media_type: Option, + pub data: Option, + #[serde(flatten)] + pub extra: serde_json::Map, +} +#[derive(Clone, Deserialize, Serialize)] +pub(super) struct ToolResult { + pub value: Value, +} diff --git a/bt-daemon/src/translate/opencode_v2/span.rs b/bt-daemon/src/translate/opencode_v2/span.rs new file mode 100644 index 00000000..3341b005 --- /dev/null +++ b/bt-daemon/src/translate/opencode_v2/span.rs @@ -0,0 +1,192 @@ +//! Concrete span data produced from OpenCode 2 events. +use serde::Serialize; +use serde_json::Value; + +#[derive(Clone, Serialize)] +#[serde(untagged)] +pub(super) enum Content { + Text(String), + Parts(Vec), + Dynamic(Value), +} +#[derive(Clone, Serialize)] +#[serde(untagged)] +pub(super) enum ContentPart { + Text(TextPart), + Image(ImagePart), + Native(super::event::KnownContextPart), + Tool(super::event::ToolContent), + Dynamic(Value), +} +#[derive(Clone, Serialize)] +pub(super) struct TextPart { + #[serde(rename = "type")] + pub kind: &'static str, + pub text: String, +} +#[derive(Clone, Serialize)] +pub(super) struct ImagePart { + #[serde(rename = "type")] + pub kind: &'static str, + pub image_url: ImageUrl, +} +#[derive(Clone, Serialize)] +pub(super) struct ImageUrl { + pub url: String, +} +impl ContentPart { + pub fn text(text: String) -> Self { + Self::Text(TextPart { kind: "text", text }) + } + pub fn image(url: String) -> Self { + Self::Image(ImagePart { + kind: "image_url", + image_url: ImageUrl { url }, + }) + } +} +#[derive(Clone, Serialize)] +pub(super) struct ToolCall { + pub id: String, + #[serde(rename = "type")] + pub kind: &'static str, + pub function: FunctionCall, +} +#[derive(Clone, Serialize)] +pub(super) struct FunctionCall { + pub name: String, + pub arguments: String, +} +impl ToolCall { + pub fn new(id: String, name: String, input: &Value) -> Self { + Self { + id, + kind: "function", + function: FunctionCall { + name, + arguments: input.to_string(), + }, + } + } +} +#[derive(Clone, Serialize)] +pub(super) struct Reasoning { + pub id: &'static str, + pub content: String, +} +#[derive(Clone, Serialize)] +pub(super) struct Message { + pub role: String, + pub content: Content, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tool_calls: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message_type: Option<&'static str>, +} +impl Message { + pub fn new(role: impl Into, content: Content) -> Self { + Self { + role: role.into(), + content, + tool_calls: vec![], + reasoning: None, + tool_call_id: None, + message_type: None, + } + } + pub fn tool(id: String, content: Content) -> Self { + let mut message = Self::new("tool", content); + message.tool_call_id = Some(id); + message + } + pub fn summary(text: String) -> Self { + let mut message = Self::new("assistant", Content::Text(text)); + message.message_type = Some("compaction_summary"); + message + } +} +#[derive(Serialize)] +pub(super) struct RootMetadata<'a> { + pub session_id: &'a str, + pub source: &'static str, + #[serde(rename = "_bt_defer_root")] + pub defer_root: bool, + pub username: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_session_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_subagent: Option, +} +#[derive(Serialize)] +pub(super) struct TurnMetadata { + pub turn_number: u32, + pub model: Option, + pub loaded_skill_names: Vec, +} +#[derive(Serialize)] +pub(super) struct RootTotals { + pub total_turns: u32, + pub total_tool_calls: u32, +} +#[derive(Serialize)] +pub(super) struct ToolMetadata<'a> { + pub tool_name: &'a str, + pub call_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_approval: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_type: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_kind: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub skill_name: Option>, +} +impl<'a> ToolMetadata<'a> { + pub fn new(tool_name: &'a str, call_id: &'a str) -> Self { + Self { + tool_name, + call_id, + tool_approval: None, + permission_id: None, + permission_type: None, + tool_kind: None, + skill_name: None, + } + } +} +#[derive(Serialize)] +pub(super) struct LlmMetadata<'a> { + pub provider: &'a str, + pub model: &'a str, + pub message_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + pub variant: Option<&'a str>, +} +#[derive(Serialize)] +pub(super) struct Metrics { + pub prompt_tokens: i64, + pub completion_tokens: i64, + pub tokens: i64, + pub prompt_cached_tokens: i64, + pub prompt_cache_creation_tokens: i64, + pub reasoning_tokens: i64, +} +impl Metrics { + pub fn from_tokens(t: super::event::Tokens) -> Self { + let prompt = t.input + t.cache.read + t.cache.write; + Self { + prompt_tokens: prompt, + completion_tokens: t.output, + tokens: prompt + t.output + t.reasoning, + prompt_cached_tokens: t.cache.read, + prompt_cache_creation_tokens: t.cache.write, + reasoning_tokens: t.reasoning, + } + } +} diff --git a/bt-daemon/src/translate/tool.rs b/bt-daemon/src/translate/tool.rs index aabe0ae6..02e53e66 100644 --- a/bt-daemon/src/translate/tool.rs +++ b/bt-daemon/src/translate/tool.rs @@ -4,9 +4,11 @@ //! This module only encodes the common Braintrust representation once that //! source-specific interpretation has been made. +use serde::Serialize; use serde_json::{json, Map, Value}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] pub enum ToolApproval { Approved, Denied, diff --git a/bt-daemon/src/wire/methods.rs b/bt-daemon/src/wire/methods.rs index 1d4146ec..df9947b6 100644 --- a/bt-daemon/src/wire/methods.rs +++ b/bt-daemon/src/wire/methods.rs @@ -44,6 +44,9 @@ pub struct InitializeResult { pub struct Capabilities { #[serde(default)] pub sources: Vec, + /// A failed event closes the connection before any later event is accepted. + #[serde(default)] + pub ordered_event_pipeline: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/bt-daemon/tests/agent_integration.rs b/bt-daemon/tests/agent_integration.rs index fec5de37..c2807683 100644 --- a/bt-daemon/tests/agent_integration.rs +++ b/bt-daemon/tests/agent_integration.rs @@ -53,6 +53,36 @@ fn row_contains(row: &Value, fragments: &[&str]) -> bool { .all(|fragment| serialized.contains(fragment)) } +fn opencode_test_env( + workspace: &std::path::Path, + home: &std::path::Path, +) -> [(std::ffi::OsString, std::ffi::OsString); 6] { + [ + ("PWD".into(), workspace.into()), + ("HOME".into(), home.into()), + ("USERPROFILE".into(), home.into()), + ("XDG_CONFIG_HOME".into(), home.join("config").into()), + ("XDG_DATA_HOME".into(), home.join("data").into()), + ("XDG_CACHE_HOME".into(), home.join("cache").into()), + ] +} + +#[cfg(unix)] +#[tokio::test] +async fn agent_output_does_not_wait_for_inherited_descendant_handles() { + let world = AgentTestWorld::start().await; + let mut command = support::agent_process::command("sh"); + command.args(["-c", "sleep 5 & printf captured"]); + let output = tokio::time::timeout( + std::time::Duration::from_secs(2), + world.output(&mut command), + ) + .await + .expect("foreground command should not wait for its background child"); + assert!(output.status.success()); + assert_eq!(output.stdout, b"captured"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[ignore = "requires the Codex CLI installed on PATH"] async fn codex_session_emits_traces() { @@ -519,3 +549,849 @@ fn request_helpers_recognize_tool_results_and_advertised_tools() { }; assert!(anthropic.has_tool_result("toolu-1")); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "requires OpenCode 2 and a built OpenCode package"] +async fn opencode_v2_session_emits_traces() { + use base64::Engine; + let inference = OpenAiMock::new(|_, request| { + if request.body["messages"] + .as_array() + .and_then(|messages| { + messages + .iter() + .rev() + .find(|message| message["role"] == "user") + }) + .is_some_and(|message| message.to_string().contains("model failure marker")) + { + return MockReply::http_error( + axum::http::StatusCode::BAD_REQUEST, + json!({"error":{"message":"MODEL_FAILURE_FIXTURE","type":"invalid_request_error"}}), + ); + } + let tools = request.body["tools"].as_array(); + let has_tool_result = request.body["messages"] + .as_array() + .is_some_and(|messages| messages.iter().any(|message| message["role"] == "tool")); + if tools.is_some() && !has_tool_result { + if request.contains_text("image tool marker") { + return MockReply::response(OpenAiTurn::tool_call( + "call_image", + "fixture_image", + json!({}), + )); + } + let command = if request.contains_text("interrupt marker") { + if cfg!(windows) { + "Start-Sleep -Seconds 30".to_string() + } else { + "sleep 30".to_string() + } + } else { + tool_command("OPENCODE_V2_TOOL_OK") + }; + MockReply::response(OpenAiTurn::tool_call( + "call_v2", + "shell", + json!({"command":command,"description":"Run a test command"}), + )) + } else { + MockReply::response(OpenAiTurn::text("OPENCODE_V2_OK")) + } + }); + let inference_server = TestServer::start(inference.router()).await; + let world = AgentTestWorld::start().await; + let workspace = world.workspace(); + let image = base64::engine::general_purpose::STANDARD.decode("iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR4nGP4z8AARAwQCgAf7gP9i18U1AAAAABJRU5ErkJggg==").unwrap(); + std::fs::write(workspace.join("image.png"), image).unwrap(); + let home = world.temp_path("opencode-v2-home"); + std::fs::create_dir_all(&home).unwrap(); + let config = json!({ + "model": "mock/mock-model", + "providers": {"mock": {"package":"@opencode/ai/providers/openai-compatible", "settings":{"baseURL":format!("{}/v1", inference_server.uri()),"apiKey":"test-key"},"models":{"mock-model":{"name":"Mock Model"}}}} + }); + std::fs::write( + workspace.join("opencode.json"), + serde_json::to_vec(&config).unwrap(), + ) + .unwrap(); + let mut command = support::agent_process::daemon_command(); + command + .args([ + "run", + "--project", + "agent-e2e", + "opencode", + "run", + "--auto", + "--model", + "mock/mock-model", + "--file", + "image.png", + "say hello", + ]) + .current_dir(&workspace) + .envs(opencode_test_env(&workspace, &home)) + .env( + "OPENCODE_BIN", + std::env::var_os("OPENCODE_V2_BIN").expect("set OPENCODE_V2_BIN"), + ) + .env( + "BRAINTRUST_TRACE_OPENCODE_PLUGIN_SPEC", + std::env::var_os("OPENCODE_V2_PLUGIN_DIR").expect("set OPENCODE_V2_PLUGIN_DIR"), + ); + world.configure(&mut command); + let output = world.output(&mut command).await; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + inference + .requests() + .iter() + .any(|request| request.contains_text("braintrust_list_projects")), + "Braintrust tools were not exposed to the managed OpenCode 2 run" + ); + let scenario = IngestScenario::new() + .expect("v2 input", |row| { + row_contains(row, &["Turn 1", "say hello", "data:image/png;base64,"]) + }) + .expect("v2 tool", |row| { + row_contains(row, &[r#""type":"tool""#, "OPENCODE_V2_TOOL_OK"]) + }) + .expect("v2 LLM", |row| { + row_contains(row, &[r#""type":"llm""#, "OPENCODE_V2_OK"]) + }); + let rows = world.wait_for_mock_ingest_scenario(&scenario).await; + let final_llm = rows + .iter() + .find(|row| { + row["span_attributes"]["type"] == "llm" + && row["output"].to_string().contains("OPENCODE_V2_OK") + }) + .unwrap(); + assert_eq!(final_llm["metadata"]["provider"], "mock"); + assert_eq!(final_llm["metadata"]["model"], "mock-model"); + assert_eq!(final_llm["metrics"]["prompt_tokens"].as_f64(), Some(10.0)); + assert_eq!( + final_llm["metrics"]["completion_tokens"].as_f64(), + Some(5.0) + ); + assert_eq!(final_llm["metrics"]["tokens"].as_f64(), Some(15.0)); + assert!( + final_llm["input"] + .as_array() + .unwrap() + .iter() + .any(|message| message["role"] == "tool" + && message["tool_call_id"] == "call_v2" + && message["content"] + .to_string() + .contains("OPENCODE_V2_TOOL_OK")), + "unexpected model input: {}", + final_llm["input"] + ); + + let managed_turn_id = rows + .iter() + .find(|row| row_contains(row, &["Turn 1", "say hello", "data:image/png;base64,"])) + .unwrap()["id"] + .as_str() + .unwrap() + .to_owned(); + world + .wait_for_mock_ingest_scenario( + &IngestScenario::new().expect("managed v2 completed turn", move |row| { + row["id"] == managed_turn_id && row["output"] == "OPENCODE_V2_OK" + }), + ) + .await; + + // Exercise the persistent plugin configuration separately from managed runs. + let mut persistent_config = config.clone(); + persistent_config["plugin"] = json!([std::env::var("OPENCODE_V2_PLUGIN_DIR").unwrap()]); + std::fs::write( + workspace.join("opencode.json"), + serde_json::to_vec(&persistent_config).unwrap(), + ) + .unwrap(); + let config_dir = home.join("config/opencode"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write( + config_dir.join("braintrust.json"), + serde_json::to_vec(&json!({ + "trace_to_braintrust": true, + "route": {"destination":{"type":"project_logs","project_name":"agent-e2e"}} + })) + .unwrap(), + ) + .unwrap(); + let mut persistent = + support::agent_process::command(std::env::var_os("OPENCODE_V2_BIN").unwrap()); + persistent + .args([ + "run", + "--standalone", + "--auto", + "--model", + "mock/mock-model", + "persistent marker", + ]) + .current_dir(&workspace) + .envs(opencode_test_env(&workspace, &home)); + world.configure(&mut persistent); + let output = world.output(&mut persistent).await; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + inference + .requests() + .iter() + .any(|request| request.contains_text("braintrust_list_projects")), + "Braintrust tools were not exposed to OpenCode 2" + ); + world + .wait_for_mock_ingest_scenario(&IngestScenario::new().expect("persistent v2 turn", |row| { + row_contains(row, &["Turn 1", "persistent marker"]) + })) + .await; + + persistent_config["permissions"] = json!([{"action":"shell","resource":"*","effect":"ask"}]); + std::fs::write( + workspace.join("opencode.json"), + serde_json::to_vec(&persistent_config).unwrap(), + ) + .unwrap(); + let plugins = workspace.join(".opencode/plugins"); + std::fs::create_dir_all(&plugins).unwrap(); + std::fs::write(plugins.join("reject.js"), r#"export default { id: 'test.reject', setup(ctx) { const c = new AbortController(); void (async () => { for await (const event of ctx.event.subscribe({signal:c.signal})) { if (event.type === 'permission.asked') await ctx.permission.reply({sessionID:event.data.sessionID,requestID:event.data.id,decision:'reject',message:'test denied'}); } })().catch(() => {}); return () => c.abort(); } };"#).unwrap(); + let mut rejected = + support::agent_process::command(std::env::var_os("OPENCODE_V2_BIN").unwrap()); + rejected + .args([ + "run", + "--standalone", + "--model", + "mock/mock-model", + "permission marker", + ]) + .current_dir(&workspace) + .envs(opencode_test_env(&workspace, &home)); + world.configure(&mut rejected); + // OpenCode 2.0 exits nonzero after a rejection; later releases continue. + let _output = world.output(&mut rejected).await; + world + .wait_for_mock_ingest_scenario( + &IngestScenario::new() + .expect("permission turn", |row| { + row_contains(row, &["Turn 1", "permission marker"]) + }) + .expect("denied shell", |row| { + row["span_attributes"]["type"] == "tool" + && row["metadata"]["tool_approval"] == "denied" + }), + ) + .await; + + std::fs::remove_file(plugins.join("reject.js")).unwrap(); + std::fs::write(plugins.join("interrupt.js"), r#"export default { id: 'test.interrupt', setup(ctx) { const c = new AbortController(); void (async () => { for await (const event of ctx.event.subscribe({signal:c.signal})) { if (event.type === 'session.tool.called') await ctx.session.interrupt({sessionID:event.data.sessionID}); } })().catch(() => {}); return () => c.abort(); } };"#).unwrap(); + persistent_config + .as_object_mut() + .unwrap() + .remove("permissions"); + std::fs::write( + workspace.join("opencode.json"), + serde_json::to_vec(&persistent_config).unwrap(), + ) + .unwrap(); + let mut interrupted = + support::agent_process::command(std::env::var_os("OPENCODE_V2_BIN").unwrap()); + interrupted + .args([ + "run", + "--standalone", + "--auto", + "--model", + "mock/mock-model", + "interrupt marker", + ]) + .current_dir(&workspace) + .envs(opencode_test_env(&workspace, &home)); + world.configure(&mut interrupted); + let _output = world.output(&mut interrupted).await; + world + .wait_for_mock_ingest_scenario(&IngestScenario::new().expect("interrupted turn", |row| { + row_contains( + row, + &["Turn 1", "interrupt marker", "OpenCode session interrupted"], + ) + })) + .await; + + std::fs::remove_file(plugins.join("interrupt.js")).unwrap(); + let mut failed = support::agent_process::command(std::env::var_os("OPENCODE_V2_BIN").unwrap()); + failed + .args([ + "run", + "--standalone", + "--auto", + "--model", + "mock/mock-model", + "model failure marker", + ]) + .current_dir(&workspace) + .envs(opencode_test_env(&workspace, &home)); + world.configure(&mut failed); + let _output = world.output(&mut failed).await; + let failure_rows = world + .wait_for_mock_ingest_scenario( + &IngestScenario::new() + .expect("failed model turn", |row| { + row_contains(row, &["Turn 1", "model failure marker"]) + }) + .expect("model error", |row| { + row["error"].to_string().contains("MODEL_FAILURE_FIXTURE") + }), + ) + .await; + assert!(failure_rows + .iter() + .any(|row| row["error"].to_string().contains("MODEL_FAILURE_FIXTURE"))); + + let failed_turn = failure_rows + .iter() + .find(|row| row_contains(row, &["Turn 1", "model failure marker"])) + .unwrap(); + let failed_root = failed_turn["root_span_id"].clone(); + let native_session = failure_rows + .iter() + .find(|row| row["span_id"] == failed_root && row["metadata"]["session_id"].is_string()) + .expect("failed native session root")["metadata"]["session_id"] + .as_str() + .unwrap(); + let mut resumed = support::agent_process::command(std::env::var_os("OPENCODE_V2_BIN").unwrap()); + resumed + .args([ + "run", + "--standalone", + "--auto", + "--model", + "mock/mock-model", + "--session", + native_session, + "model recovery marker", + ]) + .current_dir(&workspace) + .envs(opencode_test_env(&workspace, &home)); + world.configure(&mut resumed); + let output = world.output(&mut resumed).await; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let recovered_rows = world + .wait_for_mock_ingest_scenario( + &IngestScenario::new().expect("resumed failed session", |row| { + row_contains(row, &["Turn 2", "model recovery marker"]) + }), + ) + .await; + assert!(recovered_rows + .iter() + .any(|row| row["root_span_id"] == failed_root + && row_contains(row, &["Turn 2", "model recovery marker"]))); + + std::fs::write(plugins.join("image.js"), r#"export default { id:'test.image', async setup(ctx) { await ctx.tool.transform(editor => editor.add({name:'fixture_image',description:'Return an image',input:{type:'object',properties:{},additionalProperties:false},options:{codemode:false},async execute() { return {content:[{type:'file',mime:'image/png',uri:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR4nGP4z8AARAwQCgAf7gP9i18U1AAAAABJRU5ErkJggg=='}]}; }})); } };"#).unwrap(); + let mut image_run = support::agent_process::daemon_command(); + image_run + .args([ + "run", + "--project", + "agent-e2e", + "opencode", + "run", + "--auto", + "--model", + "mock/mock-model", + "image tool marker", + ]) + .current_dir(&workspace) + .envs(opencode_test_env(&workspace, &home)) + .env("OPENCODE_BIN", std::env::var_os("OPENCODE_V2_BIN").unwrap()) + .env( + "BRAINTRUST_TRACE_OPENCODE_PLUGIN_SPEC", + std::env::var_os("OPENCODE_V2_PLUGIN_DIR").unwrap(), + ); + world.configure(&mut image_run); + let output = world.output(&mut image_run).await; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let image_rows = world + .wait_for_mock_ingest_scenario( + &IngestScenario::new() + .expect("image tool turn", |row| { + row_contains(row, &["Turn 1", "image tool marker"]) + }) + .expect("image tool result", |row| { + row["span_attributes"]["type"] == "tool" + && row["output"].to_string().contains("image_url") + && row["output"].to_string().contains("image/png") + }) + .expect("model input with tool image", |row| { + row["span_attributes"]["type"] == "llm" + && row["input"].to_string().contains("image tool marker") + && row["input"].to_string().contains("image/png") + && row["input"].to_string().contains("image_url") + }), + ) + .await; + assert!( + image_rows + .iter() + .any(|row| row["span_attributes"]["type"] == "llm" + && row["input"].to_string().contains("image tool marker") + && row["input"].to_string().contains("image/png") + && row["input"].to_string().contains("image_url")), + "model input lost tool image: {image_rows:?}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "requires OpenCode 2 and a built OpenCode package"] +async fn opencode_v2_subagent_is_nested() { + let inference = OpenAiMock::new(|_, request| { + let messages = request.body["messages"].as_array(); + let user = messages + .and_then(|ms| ms.iter().rev().find(|m| m["role"] == "user")) + .map(Value::to_string) + .unwrap_or_default(); + let results = messages + .into_iter() + .flatten() + .filter(|m| m["role"] == "tool") + .collect::>(); + if user.contains("CHILD_MARKER") { + MockReply::response(OpenAiTurn::text("CHILD_DONE")) + } else if request.body["tools"].is_array() && results.is_empty() { + MockReply::response(OpenAiTurn::tool_call( + "subagent_call", + "subagent", + json!({"agent":"general","description":"Test child","prompt":"CHILD_MARKER"}), + )) + } else if results.len() == 1 { + let content = results[0]["content"] + .as_str() + .expect("subagent result text"); + let session = content + .split("sessionID=\"") + .nth(1) + .and_then(|s| s.split('"').next()) + .expect("subagent session ID"); + MockReply::response(OpenAiTurn::tool_call( + "subagent_resume", + "subagent", + json!({"agent":"general","description":"Continue child","sessionID":session,"prompt":"CHILD_MARKER_SECOND"}), + )) + } else { + MockReply::response(OpenAiTurn::text("PARENT_DONE")) + } + }); + let server = TestServer::start(inference.router()).await; + let world = AgentTestWorld::start().await; + let workspace = world.workspace(); + let home = world.temp_path("v2-subagent-home"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write(workspace.join("opencode.json"), serde_json::to_vec(&json!({ + "model":"mock/mock-model", "providers":{"mock":{"package":"@opencode/ai/providers/openai-compatible","settings":{"baseURL":format!("{}/v1",server.uri()),"apiKey":"test"},"models":{"mock-model":{"name":"Mock"}}}} + })).unwrap()).unwrap(); + let mut command = support::agent_process::daemon_command(); + command + .args([ + "run", + "--project", + "agent-e2e", + "opencode", + "run", + "--auto", + "--model", + "mock/mock-model", + "spawn subagent", + ]) + .current_dir(&workspace) + .envs(opencode_test_env(&workspace, &home)) + .env( + "OPENCODE_BIN", + std::env::var_os("OPENCODE_V2_BIN").expect("set OPENCODE_V2_BIN"), + ) + .env( + "BRAINTRUST_TRACE_OPENCODE_PLUGIN_SPEC", + std::env::var_os("OPENCODE_V2_PLUGIN_DIR").expect("set OPENCODE_V2_PLUGIN_DIR"), + ); + world.configure(&mut command); + let output = world.output(&mut command).await; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let rows = world.wait_for_trace_delivery().await; + let root = rows + .iter() + .find(|r| r["span_attributes"]["name"] == "OpenCode") + .expect("root span"); + let child = rows + .iter() + .find(|r| r["metadata"]["is_subagent"] == true) + .expect("child session span"); + assert_eq!(child["root_span_id"], root["root_span_id"]); + assert!(child["span_parents"] + .as_array() + .is_some_and(|parents| !parents.is_empty())); + assert!( + rows.iter().any(|row| row_contains(row, &["CHILD_DONE"])), + "child result missing: {rows:?}" + ); + assert!( + rows.iter() + .any(|row| row["span_attributes"]["name"] == "Turn 2" + && row["span_parents"] + .as_array() + .is_some_and(|parents| parents.contains(&child["span_id"])) + && row.to_string().contains("CHILD_MARKER_SECOND")), + "continued child turn missing: {rows:?}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "requires OpenCode 2 and a built OpenCode package"] +async fn opencode_v2_compaction_after_idle() { + run_v2_compaction_after_idle(false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "requires OpenCode 2.1+ and a built OpenCode package"] +async fn opencode_v2_native_compaction_after_idle() { + run_v2_compaction_after_idle(true).await; +} + +async fn run_v2_compaction_after_idle(native: bool) { + let binary = std::env::var_os("OPENCODE_V2_BIN").expect("set OPENCODE_V2_BIN"); + if native { + let version = support::agent_process::command(&binary) + .arg("--version") + .output() + .await + .unwrap(); + if String::from_utf8_lossy(&version.stdout).contains("v2.0.") { + return; + } + } + let summary = if native { + "NATIVE_COMPACTION_WINDOW" + } else { + "LEGACY_COMPACTION_SUMMARY" + }; + let inference = OpenAiMock::new(|_, _| MockReply::response(OpenAiTurn::text("LEGACY_TEXT"))); + let inference_server = TestServer::start(inference.router()).await; + let world = AgentTestWorld::start().await; + let workspace = world.workspace(); + let home = world.temp_path("v2-legacy-compact-home"); + let config_dir = home.join("config/opencode"); + let plugins = workspace.join(".opencode/plugins"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::create_dir_all(&plugins).unwrap(); + std::fs::write(config_dir.join("braintrust.json"), serde_json::to_vec(&json!({"trace_to_braintrust":true,"route":{"destination":{"type":"project_logs","project_name":"agent-e2e"}}})).unwrap()).unwrap(); + std::fs::write(workspace.join("opencode.json"), serde_json::to_vec(&json!({ + "plugin":[std::env::var("OPENCODE_V2_PLUGIN_DIR").unwrap()], "model":"mock/mock-model", "providers":{"mock":{"package":"@opencode/ai/providers/openai-compatible","settings":{"baseURL":format!("{}/v1",inference_server.uri()),"apiKey":"test"},"models":{"mock-model":{"name":"Mock"}}}} + })).unwrap()).unwrap(); + let hook = if native { + "export default {id:'test.compact',async setup(ctx){await ctx.session.hook('compaction',e=>{e.result={summary:'',native:{messages:[{role:'user',content:[{type:'text',text:'NATIVE_COMPACTION_WINDOW'}]}]}}})}};" + } else { + "export default {id:'test.compact',async setup(ctx){await ctx.session.hook('compaction',e=>{e.result={summary:'LEGACY_COMPACTION_SUMMARY'}})}};" + }; + std::fs::write(plugins.join("compact.js"), hook).unwrap(); + let port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let url = format!("http://127.0.0.1:{port}"); + let mut server = support::agent_process::command(&binary); + server + .args([ + "serve", + "--hostname", + "127.0.0.1", + "--port", + &port.to_string(), + ]) + .current_dir(&workspace) + .envs(opencode_test_env(&workspace, &home)) + .env("OPENCODE_PASSWORD", "integration-test-password") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true); + world.configure(&mut server); + let mut server = server.spawn().unwrap(); + let client = reqwest::Client::builder().no_proxy().build().unwrap(); + let mut ready = false; + for _ in 0..100 { + if client + .get(format!("{url}/api/session")) + .basic_auth("opencode", Some("integration-test-password")) + .send() + .await + .is_ok_and(|r| r.status().is_success()) + { + ready = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + assert!(ready, "OpenCode 2 server did not start"); + let mut run = support::agent_process::command(&binary); + run.args([ + "run", + "--server", + &url, + "--auto", + "--model", + "mock/mock-model", + "--format", + "json", + "legacy compaction", + ]) + .current_dir(&workspace) + .envs(opencode_test_env(&workspace, &home)) + .env("OPENCODE_PASSWORD", "integration-test-password"); + let output = world.output(&mut run).await; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + let session_id = stdout + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find_map(|event| event["sessionID"].as_str().map(str::to_owned)) + .expect("session ID"); + let initial_rows = world + .wait_for_mock_ingest_scenario(&IngestScenario::new().expect("initial turn", |row| { + row_contains(row, &["Turn 1", "legacy compaction"]) + })) + .await; + let root_id = initial_rows + .iter() + .find(|row| row_contains(row, &["Turn 1", "legacy compaction"])) + .unwrap()["root_span_id"] + .clone(); + let compact_request = client + .post(format!("{url}/api/session/{session_id}/compact")) + .basic_auth("opencode", Some("integration-test-password")) + .json(&json!({"delivery":"queue"})) + .build() + .unwrap(); + client + .execute(compact_request.try_clone().unwrap()) + .await + .unwrap() + .error_for_status() + .unwrap(); + let compacted_rows = world + .wait_for_mock_ingest_scenario(&IngestScenario::new().expect("idle compaction", |row| { + row_contains(row, &[r#""type":"llm""#, summary]) + })) + .await; + assert!(compacted_rows + .iter() + .any(|row| row_contains(row, &[r#""type":"llm""#, summary]) + && row["root_span_id"] == root_id)); + let mut resume = support::agent_process::command(&binary); + resume + .args([ + "run", + "--server", + &url, + "--auto", + "--model", + "mock/mock-model", + "--session", + &session_id, + "legacy after compaction", + ]) + .current_dir(&workspace) + .envs(opencode_test_env(&workspace, &home)) + .env("OPENCODE_PASSWORD", "integration-test-password"); + let output = world.output(&mut resume).await; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let resumed_rows = world + .wait_for_mock_ingest_scenario(&IngestScenario::new().expect("resumed context", |row| { + row_contains( + row, + &[r#""type":"llm""#, summary, "legacy after compaction"], + ) + })) + .await; + let resumed_llm = resumed_rows + .iter() + .find(|row| { + row_contains( + row, + &[r#""type":"llm""#, summary, "legacy after compaction"], + ) + }) + .unwrap(); + assert_eq!(resumed_llm["root_span_id"], root_id); + assert!(!resumed_llm["input"] + .to_string() + .contains("legacy compaction")); + if native { + client + .execute(compact_request) + .await + .unwrap() + .error_for_status() + .unwrap(); + let second = world + .wait_for_mock_ingest_scenario(&IngestScenario::new().expect( + "second native compaction", + |row| { + row["tags"] + .as_array() + .is_some_and(|tags| tags.iter().any(|tag| tag == "compaction")) + && row["input"].to_string().contains("legacy after compaction") + }, + )) + .await; + let compact = second + .iter() + .find(|row| { + row["tags"] + .as_array() + .is_some_and(|tags| tags.iter().any(|tag| tag == "compaction")) + && row["input"].to_string().contains("legacy after compaction") + }) + .unwrap(); + assert!(compact["input"] + .to_string() + .contains("NATIVE_COMPACTION_WINDOW")); + assert!(!compact["input"].to_string().contains("legacy compaction")); + } + server.kill().await.unwrap(); + server.wait().await.unwrap(); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "requires OpenCode 2 and a built OpenCode package"] +async fn opencode_v2_standalone_delivers_events_when_daemon_acknowledgements_are_slow() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::net::{UnixListener, UnixStream}; + + let inference = + OpenAiMock::new(|_, _| MockReply::response(OpenAiTurn::text("SLOW_ACK_RESPONSE"))); + let inference_server = TestServer::start(inference.router()).await; + let world = AgentTestWorld::start().await; + let workspace = world.workspace(); + let home = world.temp_path("slow-ack-home"); + let config_dir = home.join("config/opencode"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write(config_dir.join("braintrust.json"), serde_json::to_vec(&json!({"trace_to_braintrust":true,"enable_tools":false,"route":{"destination":{"type":"project_logs","project_name":"agent-e2e"}}})).unwrap()).unwrap(); + std::fs::write(workspace.join("opencode.json"), serde_json::to_vec(&json!({ + "plugin":[std::env::var("OPENCODE_V2_PLUGIN_DIR").unwrap()], "model":"mock/mock-model", "providers":{"mock":{"package":"@opencode/ai/providers/openai-compatible","settings":{"baseURL":format!("{}/v1",inference_server.uri()),"apiKey":"test"},"models":{"mock-model":{"name":"Mock"}}}} + })).unwrap()).unwrap(); + let proxy_path = world.temp_path("slow.sock"); + let listener = UnixListener::bind(&proxy_path).unwrap(); + let target = world.socket().to_owned(); + let proxy = tokio::spawn(async move { + while let Ok((incoming, _)) = listener.accept().await { + let target = target.clone(); + tokio::spawn(async move { + let Ok(outgoing) = UnixStream::connect(target).await else { + return; + }; + let (mut client_read, mut client_write) = incoming.into_split(); + let (daemon_read, mut daemon_write) = outgoing.into_split(); + let request = async { + let _ = tokio::io::copy(&mut client_read, &mut daemon_write).await; + }; + let response = async { + let mut daemon_read = BufReader::new(daemon_read); + let mut buffer = Vec::new(); + let mut client_closed = false; + let mut initializing = true; + loop { + buffer.clear(); + match daemon_read.read_until(b'\n', &mut buffer).await { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + if initializing { + initializing = false; + } else { + tokio::time::sleep(std::time::Duration::from_millis(4000)).await; + } + if !client_closed && client_write.write_all(&buffer).await.is_err() { + client_closed = true; + } + } + }; + tokio::join!(request, response); + }); + } + }); + let mut run = support::agent_process::command(std::env::var_os("OPENCODE_V2_BIN").unwrap()); + run.args([ + "run", + "--standalone", + "--auto", + "--model", + "mock/mock-model", + "slow ack marker", + ]) + .current_dir(&workspace) + .envs(opencode_test_env(&workspace, &home)); + world.configure(&mut run); + run.env("BRAINTRUST_DAEMON_SOCKET", &proxy_path); + let output = world.output(&mut run).await; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let rows = world + .wait_for_mock_ingest_scenario(&IngestScenario::new().expect("standalone turn", |row| { + row_contains(row, &["Turn 1", "slow ack marker"]) + })) + .await; + let turn_id = rows + .iter() + .find(|row| row_contains(row, &["Turn 1", "slow ack marker"])) + .unwrap()["id"] + .as_str() + .unwrap() + .to_owned(); + world + .wait_for_mock_ingest_scenario( + &IngestScenario::new().expect("standalone completed turn", move |row| { + row["id"] == turn_id && row["output"] == "SLOW_ACK_RESPONSE" + }), + ) + .await; + proxy.abort(); +} diff --git a/bt-daemon/tests/opencode_translator.rs b/bt-daemon/tests/opencode_translator.rs index 6e4935f8..7d5564d9 100644 --- a/bt-daemon/tests/opencode_translator.rs +++ b/bt-daemon/tests/opencode_translator.rs @@ -972,3 +972,1407 @@ fn opencode_idle_root_merge_keeps_external_parent() { ); assert_merges_preserve_insert_identity(&ops); } + +#[test] +fn opencode_v2_reduces_native_text_session_without_session_created() { + let mut translator = Registry::default_agents().create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + // Shapes observed from OpenCode 2.2.5 with a local mock model. + let events = [ + ( + "session.inbox.enqueued", + 1, + json!({"sessionID":"native", "inboxID":"user-1", "item":{"type":"user", "payload":{"text":"hello", "files":[],"skills":[{"id":"s1","name":"review"}]}, "delivery":"steer"}}), + ), + ( + "session.inbox.delivered", + 2, + json!({"sessionID":"native", "inboxID":"user-1"}), + ), + ( + "session.step.started", + 3, + json!({"sessionID":"native", "assistantMessageID":"assistant-1", "model":{"id":"mock-model", "providerID":"mock","variant":"high"}, "started":2}), + ), + ( + "session.text.ended", + 4, + json!({"sessionID":"native", "assistantMessageID":"assistant-1", "ordinal":0, "text":"world"}), + ), + ( + "session.text.ended", + 5, + json!({"sessionID":"native", "assistantMessageID":"assistant-1", "ordinal":1, "text":"!"}), + ), + ( + "session.step.ended", + 6, + json!({"sessionID":"native", "assistantMessageID":"assistant-1", "finish":"stop", "tokens":{"input":3,"output":2,"reasoning":0,"cache":{"read":0,"write":0}}}), + ), + ( + "session.execution.succeeded", + 7, + json!({"sessionID":"native"}), + ), + ]; + let mut ops = Vec::new(); + for (name, ts, data) in events { + let mut e = event( + name, + ts, + json!({"id":format!("event-{ts}"),"type":name,"data":data}), + ); + e.source_version = Some("2.2.5".into()); + ops.extend(translator.handle(&e, &ctx).unwrap()); + assert!(translator.handle(&e, &ctx).unwrap().is_empty()); + } + let rows = reduce(ops); + let turn = rows.values().find(|r| r.name == "Turn 1").unwrap(); + assert_eq!(turn.input, Some(json!("hello"))); + assert_eq!( + turn.metadata.as_ref().unwrap()["loaded_skill_names"], + json!(["review"]) + ); + let llm = rows + .values() + .find(|r| r.span_type == SpanType::Llm) + .unwrap(); + assert_eq!(llm.name, "mock/mock-model"); + assert_eq!(llm.metadata.as_ref().unwrap()["variant"], "high"); + assert_eq!( + llm.output, + Some(json!([{"role":"assistant","content":"world!"}])) + ); + assert_eq!(llm.metrics.as_ref().unwrap()["prompt_tokens"], 3); + assert_eq!(llm.parent_span_ids, vec![turn.span_id.clone()]); +} + +#[test] +fn opencode_v2_emits_tool_span_from_native_events() { + let mut translator = Registry::default_agents().create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + let events = [ + ( + "session.inbox.enqueued", + json!({"sessionID":"native","inboxID":"u","item":{"type":"user","payload":{"text":"read"}}}), + ), + ( + "session.inbox.delivered", + json!({"sessionID":"native","inboxID":"u"}), + ), + ( + "session.tool.input.started", + json!({"sessionID":"native","assistantMessageID":"m","id":"call","name":"read"}), + ), + ( + "session.tool.called", + json!({"sessionID":"native","assistantMessageID":"m","id":"call","input":{"path":"README.md"},"executed":false}), + ), + ( + "session.tool.success", + json!({"sessionID":"native","assistantMessageID":"m","id":"call","content":[{"type":"text","text":"contents"}],"executed":true}), + ), + ]; + let mut ops = Vec::new(); + for (i, (name, data)) in events.into_iter().enumerate() { + let e = v2_event(name, i as i64 + 1, data); + ops.extend(translator.handle(&e, &ctx).unwrap()); + } + let rows = reduce(ops); + let tool = rows + .values() + .find(|r| r.span_type == SpanType::Tool) + .unwrap(); + assert_eq!(tool.name, "read"); + assert_eq!(tool.input, Some(json!({"path":"README.md"}))); + assert_eq!( + tool.output, + Some(json!([{"type":"text","text":"contents"}])) + ); + assert!(tool.end_ms.is_some()); +} + +#[test] +fn opencode_v2_preserves_attached_images_in_turn_and_model_input() { + let mut translator = Registry::default_agents().create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + let events = [ + ( + "session.inbox.enqueued", + json!({"sessionID":"native","inboxID":"u","item":{"type":"user","payload":{"text":"describe","files":[{"mime":"image/png","data":"YWJj","source":{"type":"inline"}}]}}}), + ), + ( + "session.inbox.delivered", + json!({"sessionID":"native","inboxID":"u"}), + ), + ( + "session.step.started", + json!({"sessionID":"native","assistantMessageID":"m","model":{"id":"model","providerID":"mock"},"started":3}), + ), + ( + "session.text.ended", + json!({"sessionID":"native","assistantMessageID":"m","ordinal":0,"text":"red"}), + ), + ( + "session.step.ended", + json!({"sessionID":"native","assistantMessageID":"m","tokens":{"input":1,"output":1}}), + ), + ]; + let mut ops = Vec::new(); + for (i, (name, data)) in events.into_iter().enumerate() { + let e = v2_event(name, i as i64 + 1, data); + ops.extend(translator.handle(&e, &ctx).unwrap()); + } + let rows = reduce(ops); + let turn = rows.values().find(|r| r.name == "Turn 1").unwrap(); + let expected = json!([{"type":"text","text":"describe"},{"type":"image_url","image_url":{"url":"data:image/png;base64,YWJj"}}]); + assert_eq!(turn.input, Some(expected.clone())); + let llm = rows + .values() + .find(|r| r.span_type == SpanType::Llm) + .unwrap(); + assert_eq!(llm.input.as_ref().unwrap()[0]["content"], expected); +} + +#[test] +fn opencode_v2_resume_keeps_the_first_native_event_after_synthetic_start() { + let mut translator = Registry::default_agents().create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + let mut ops = Vec::new(); + for (i, name, data) in [ + ( + 1, + "session.inbox.enqueued", + json!({"sessionID":"native","inboxID":"u1","item":{"type":"user","payload":{"text":"first"}}}), + ), + ( + 2, + "session.inbox.delivered", + json!({"sessionID":"native","inboxID":"u1"}), + ), + ( + 3, + "session.execution.succeeded", + json!({"sessionID":"native"}), + ), + ( + 4, + "session_start", + json!({"sessionID":"native","inboxID":"u2","item":{"type":"user","payload":{"text":"second"}}}), + ), + ( + 4, + "session.inbox.enqueued", + json!({"sessionID":"native","inboxID":"u2","item":{"type":"user","payload":{"text":"second"}}}), + ), + ( + 5, + "session.inbox.delivered", + json!({"sessionID":"native","inboxID":"u2"}), + ), + ] { + let mut e = event(name, i, json!({"id":format!("event-{i}"),"data":data})); + e.source_version = Some("2.2.5".into()); + ops.extend(translator.handle(&e, &ctx).unwrap()); + } + let rows = reduce(ops); + assert!(rows + .values() + .any(|row| row.name == "Turn 2" && row.input == Some(json!("second")))); +} + +#[test] +fn opencode_v2_compaction_preserves_native_model_context_after_resume() { + let mut translator = Registry::default_agents().create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + let events = [ + ( + 1, + "session.inbox.enqueued", + json!({"sessionID":"native","inboxID":"u1","item":{"type":"user","payload":{"text":"old"}}}), + ), + ( + 2, + "session.inbox.delivered", + json!({"sessionID":"native","inboxID":"u1"}), + ), + ( + 3, + "session.compaction.started", + json!({"sessionID":"native","reason":"manual","inputID":"c1","recent":""}), + ), + ( + 4, + "session.compaction.ended", + json!({"sessionID":"native","reason":"manual","model":{"providerID":"mock","id":"model"},"text":"summary","recent":""}), + ), + ( + 5, + "session.execution.succeeded", + json!({"sessionID":"native"}), + ), + ( + 6, + "session_start", + json!({"sessionID":"native","inboxID":"u2","item":{"type":"user","payload":{"text":"next"}}}), + ), + ( + 6, + "session.inbox.enqueued", + json!({"sessionID":"native","inboxID":"u2","item":{"type":"user","payload":{"text":"next"}}}), + ), + ( + 7, + "session.inbox.delivered", + json!({"sessionID":"native","inboxID":"u2"}), + ), + ( + 8, + "model.context", + json!({"sessionID":"native","system":["system"],"messages":[{"role":"user","content":[{"type":"text","text":"summary"}]},{"role":"user","content":[{"type":"text","text":"next"}]}]}), + ), + ( + 9, + "session.step.started", + json!({"sessionID":"native","assistantMessageID":"m2","model":{"providerID":"mock","id":"model"},"started":9}), + ), + ( + 10, + "session.text.ended", + json!({"sessionID":"native","assistantMessageID":"m2","ordinal":0,"text":"answer"}), + ), + ( + 11, + "session.step.ended", + json!({"sessionID":"native","assistantMessageID":"m2","tokens":{"input":2,"output":1}}), + ), + ]; + let mut ops = Vec::new(); + for (i, name, data) in events { + let mut e = event(name, i, json!({"id":format!("event-{i}"),"data":data})); + e.source_version = Some("2.0.0".into()); + ops.extend(translator.handle(&e, &ctx).unwrap()); + } + let rows = reduce(ops); + let llm = rows + .values() + .find(|row| { + row.metadata + .as_ref() + .is_some_and(|metadata| metadata["message_id"] == "m2") + }) + .unwrap(); + assert_eq!( + llm.input, + Some( + json!([{"role":"system","content":"system"},{"role":"user","content":"summary"},{"role":"user","content":"next"}]) + ) + ); +} + +#[test] +fn opencode_v2_distinguishes_denied_and_failed_tools() { + let mut translator = Registry::default_agents().create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + let events = [ + ( + "session.inbox.enqueued", + json!({"sessionID":"native","inboxID":"u","item":{"type":"user","payload":{"text":"run"}}}), + ), + ( + "session.inbox.delivered", + json!({"sessionID":"native","inboxID":"u"}), + ), + ( + "session.tool.input.started", + json!({"sessionID":"native","assistantMessageID":"m","id":"denied","name":"read"}), + ), + ( + "session.tool.called", + json!({"sessionID":"native","assistantMessageID":"m","id":"denied","input":{"path":"secret"}}), + ), + ( + "permission.asked", + json!({"sessionID":"native","id":"p1","action":"read","source":{"id":"denied"}}), + ), + ( + "permission.replied", + json!({"sessionID":"native","requestID":"p1","reply":"reject"}), + ), + ( + "session.tool.failed", + json!({"sessionID":"native","assistantMessageID":"m","id":"denied","error":{"type":"permission.rejected","message":"denied"}}), + ), + ( + "session.tool.input.started", + json!({"sessionID":"native","assistantMessageID":"m","id":"failed","name":"read"}), + ), + ( + "session.tool.called", + json!({"sessionID":"native","assistantMessageID":"m","id":"failed","input":{"path":"missing"}}), + ), + ( + "session.tool.failed", + json!({"sessionID":"native","assistantMessageID":"m","id":"failed","error":{"type":"error","message":"not found"}}), + ), + ]; + let mut ops = Vec::new(); + for (i, (name, data)) in events.into_iter().enumerate() { + let e = v2_event(name, i as i64 + 1, data); + ops.extend(translator.handle(&e, &ctx).unwrap()); + } + let rows = reduce(ops); + let tools: Vec<_> = rows + .values() + .filter(|row| row.span_type == SpanType::Tool) + .collect(); + assert_eq!(tools.len(), 2); + let denied = tools + .iter() + .find(|row| { + row.metadata + .as_ref() + .is_some_and(|m| m["call_id"] == "denied") + }) + .unwrap(); + assert_eq!(denied.metadata.as_ref().unwrap()["tool_approval"], "denied"); + let failed = tools + .iter() + .find(|row| { + row.metadata + .as_ref() + .is_some_and(|m| m["call_id"] == "failed") + }) + .unwrap(); + assert_eq!(failed.error.as_deref(), Some("not found")); + assert_eq!( + failed.metadata.as_ref().unwrap()["tool_approval"], + "approved" + ); +} + +#[test] +fn opencode_v2_compaction_keeps_native_tail_without_context_hook() { + let mut translator = Registry::default_agents().create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + let events = [ + ( + "session.inbox.enqueued", + json!({"sessionID":"s","inboxID":"old","item":{"type":"user","payload":{"text":"old"}}}), + ), + ( + "session.inbox.delivered", + json!({"sessionID":"s","inboxID":"old"}), + ), + ( + "session.inbox.enqueued", + json!({"sessionID":"s","inboxID":"keep","item":{"type":"user","payload":{"text":"keep"}}}), + ), + ( + "session.inbox.delivered", + json!({"sessionID":"s","inboxID":"keep"}), + ), + ( + "session.compaction.started", + json!({"sessionID":"s","inputID":"compact"}), + ), + ( + "session.compaction.ended", + json!({"sessionID":"s","text":"summary"}), + ), + ( + "session.step.started", + json!({"sessionID":"s","assistantMessageID":"m","model":{"id":"model","providerID":"test"}}), + ), + ( + "session.text.ended", + json!({"sessionID":"s","assistantMessageID":"m","ordinal":0,"text":"answer"}), + ), + ( + "session.step.ended", + json!({"sessionID":"s","assistantMessageID":"m"}), + ), + ]; + let mut ops = Vec::new(); + for (i, (name, data)) in events.into_iter().enumerate() { + let mut envelope = event( + name, + i as i64 + 1, + json!({"id":format!("tail-{i}"),"metadata":{"keepID":"keep"},"data":data}), + ); + envelope.source_version = Some("2.2.5".into()); + ops.extend(translator.handle(&envelope, &ctx).unwrap()); + } + let rows = reduce(ops); + let llm = rows + .values() + .find(|row| { + row.metadata + .as_ref() + .is_some_and(|m| m["message_id"] == "m") + }) + .unwrap(); + assert_eq!( + llm.input, + Some( + json!([{"role":"assistant","content":"summary","message_type":"compaction_summary"},{"role":"user","content":"keep"}]) + ) + ); + let compact = rows + .values() + .find(|row| { + row.tags + .as_ref() + .is_some_and(|tags| tags.iter().any(|tag| tag == "compaction")) + }) + .unwrap(); + assert_eq!( + compact.input, + Some(json!([{"role":"user","content":"old"}])) + ); +} + +#[test] +fn opencode_v2_permission_denied_before_tool_call_creates_one_tool_span() { + let mut translator = Registry::default_agents().create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + let events = [ + ( + "session.inbox.enqueued", + json!({"sessionID":"s","inboxID":"u","item":{"type":"user","payload":{"text":"read"}}}), + ), + ( + "session.inbox.delivered", + json!({"sessionID":"s","inboxID":"u"}), + ), + ( + "session.tool.input.started", + json!({"sessionID":"s","assistantMessageID":"m","id":"call","name":"read"}), + ), + ( + "permission.asked", + json!({"sessionID":"s","id":"p","action":"read","source":{"id":"call"}}), + ), + ( + "permission.replied", + json!({"sessionID":"s","requestID":"p","reply":"reject"}), + ), + ( + "session.tool.failed", + json!({"sessionID":"s","assistantMessageID":"m","id":"call","error":{"type":"permission.rejected","message":"denied"}}), + ), + ]; + let mut ops = Vec::new(); + for (i, (name, data)) in events.into_iter().enumerate() { + let e = v2_event(name, i as i64 + 1, data); + ops.extend(translator.handle(&e, &ctx).unwrap()); + } + let rows = reduce(ops); + let tools: Vec<_> = rows + .values() + .filter(|row| row.span_type == SpanType::Tool) + .collect(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].name, "read"); + assert_eq!( + tools[0].metadata.as_ref().unwrap()["tool_approval"], + "denied" + ); +} + +#[test] +fn opencode_v2_preserves_provider_defined_context_and_tool_content() { + let mut translator = Registry::default_agents().create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + let events = [ + ( + "session.inbox.enqueued", + json!({"sessionID":"s","inboxID":"u","item":{"type":"user","payload":{"text":"inspect"}}}), + ), + ( + "session.inbox.delivered", + json!({"sessionID":"s","inboxID":"u"}), + ), + ( + "model.context", + json!({"sessionID":"s","system":[],"messages":[{"role":"user","content":[{"type":"provider-specific","payload":{"key":"value"}}]}]}), + ), + ( + "session.step.started", + json!({"sessionID":"s","assistantMessageID":"m","model":{"id":"model","providerID":"test"}}), + ), + ( + "session.tool.input.started", + json!({"sessionID":"s","assistantMessageID":"m","id":"call","name":"custom"}), + ), + ( + "session.tool.called", + json!({"sessionID":"s","assistantMessageID":"m","id":"call","input":{"nested":{"key":42}}}), + ), + ( + "session.tool.success", + json!({"sessionID":"s","assistantMessageID":"m","id":"call","content":[{"type":"vendor-content","payload":{"value":7}}]}), + ), + ( + "session.step.ended", + json!({"sessionID":"s","assistantMessageID":"m"}), + ), + ]; + let mut ops = Vec::new(); + for (i, (name, data)) in events.into_iter().enumerate() { + let envelope = v2_event(name, i as i64 + 1, data); + ops.extend(translator.handle(&envelope, &ctx).unwrap()); + } + let rows = reduce(ops); + let llm = rows + .values() + .find(|row| row.span_type == SpanType::Llm) + .unwrap(); + assert_eq!( + llm.input.as_ref().unwrap()[0]["content"], + json!([{"type":"provider-specific","payload":{"key":"value"}}]) + ); + let tool = rows + .values() + .find(|row| row.span_type == SpanType::Tool) + .unwrap(); + assert_eq!(tool.input, Some(json!({"nested":{"key":42}}))); + assert_eq!( + tool.output, + Some(json!([{"type":"vendor-content","payload":{"value":7}}])) + ); +} + +#[test] +fn opencode_v2_native_compaction_window_is_scoped_to_its_model() { + for (field, model, expected) in [ + ("native", "same", "replacement"), + ("native", "different", "original"), + ("providerContext", "same", "replacement"), + ] { + let mut translator = Registry::default_agents().create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + let mut compact = + json!({"sessionID":"s","text":"","model":{"id":"same","providerID":"test"}}); + compact[field] = json!({"messages":[{"role":"user","content":"replacement"}]}); + let events = [ + ( + "session.inbox.enqueued", + json!({"sessionID":"s","inboxID":"old","item":{"type":"user","payload":{"text":"original"}}}), + ), + ( + "session.inbox.delivered", + json!({"sessionID":"s","inboxID":"old"}), + ), + ("session.compaction.started", json!({"sessionID":"s"})), + ("session.compaction.ended", compact), + ( + "session.step.started", + json!({"sessionID":"s","assistantMessageID":"m","model":{"id":model,"providerID":"test"}}), + ), + ( + "session.text.ended", + json!({"sessionID":"s","assistantMessageID":"m","ordinal":0,"text":"answer"}), + ), + ( + "session.step.ended", + json!({"sessionID":"s","assistantMessageID":"m"}), + ), + ]; + let mut ops = Vec::new(); + for (i, (name, data)) in events.into_iter().enumerate() { + let envelope = v2_event(name, i as i64 + 1, data); + ops.extend(translator.handle(&envelope, &ctx).unwrap()); + } + let rows = reduce(ops); + let llm = rows + .values() + .find(|row| { + row.metadata + .as_ref() + .is_some_and(|m| m["message_id"] == "m") + }) + .unwrap(); + assert_eq!(llm.input, Some(json!([{"role":"user","content":expected}]))); + let compact = rows + .values() + .find(|row| { + row.tags + .as_ref() + .is_some_and(|tags| tags.iter().any(|tag| tag == "compaction")) + }) + .unwrap(); + assert_eq!( + compact.output, + Some(json!([{"role":"user","content":"replacement"}])) + ); + } +} + +#[test] +fn opencode_v2_preserves_opaque_provider_compaction_output() { + let mut translator = Registry::default_agents().create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + let native = + json!([{"role":"assistant","content":null,"provider_specific":{"continuation":"opaque"}}]); + let events = [ + ( + "session.inbox.enqueued", + json!({"sessionID":"s","inboxID":"i","item":{"type":"user","payload":{"text":"hello"}}}), + ), + ( + "session.inbox.delivered", + json!({"sessionID":"s","inboxID":"i"}), + ), + ( + "session.compaction.ended", + json!({"sessionID":"s","text":"","native":{"messages":native}}), + ), + ]; + let mut ops = Vec::new(); + for (i, (name, data)) in events.into_iter().enumerate() { + let envelope = v2_event(name, i as i64 + 1, data); + ops.extend(translator.handle(&envelope, &ctx).unwrap()); + } + let rows = reduce(ops); + let compact = rows + .values() + .find(|row| { + row.tags + .as_ref() + .is_some_and(|tags| tags.iter().any(|tag| tag == "compaction")) + }) + .unwrap(); + assert_eq!(compact.output, Some(native)); +} + +fn v2_event(name: &str, ts: i64, data: serde_json::Value) -> Envelope { + let mut envelope = event(name, ts, json!({"id":format!("parity-{ts}"),"data":data})); + envelope.source_version = Some("2.0.0".into()); + envelope +} + +#[test] +fn opencode_v2_checkpoint_resume_and_finalize_preserve_session_and_close_missing_tool() { + let mut translator = Registry::default_agents().create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + let mut ops = Vec::new(); + for e in [ + v2_event( + "session.inbox.enqueued", + 1, + json!({"sessionID":"s","inboxID":"first","item":{"type":"user","payload":{"text":"first"}}}), + ), + v2_event( + "session.inbox.delivered", + 2, + json!({"sessionID":"s","inboxID":"first"}), + ), + v2_event("session.execution.succeeded", 3, json!({"sessionID":"s"})), + v2_event( + "session.inbox.enqueued", + 4, + json!({"sessionID":"s","inboxID":"second","item":{"type":"user","payload":{"text":"second"}}}), + ), + ] { + ops.extend(translator.handle(&e, &ctx).unwrap()); + } + assert!(translator.checkpoint(&ctx).unwrap().is_empty()); + for e in [ + v2_event( + "session.inbox.delivered", + 5, + json!({"sessionID":"s","inboxID":"second"}), + ), + v2_event( + "session.tool.input.started", + 6, + json!({"sessionID":"s","id":"call","name":"read"}), + ), + v2_event( + "session.tool.called", + 7, + json!({"sessionID":"s","assistantMessageID":"m","id":"call","input":{"path":"x"}}), + ), + ] { + ops.extend(translator.handle(&e, &ctx).unwrap()); + } + ops.extend(translator.finalize(&ctx).unwrap()); + let rows = reduce(ops); + assert_eq!(rows.values().filter(|r| r.name == "OpenCode").count(), 1); + assert_eq!( + rows.values() + .filter(|r| r.name.starts_with("Turn ")) + .count(), + 2 + ); + let root = rows.values().find(|r| r.name == "OpenCode").unwrap(); + assert_eq!(root.metadata.as_ref().unwrap()["total_turns"], 2); + assert_eq!(root.end_ms, Some(7)); + let tool = rows + .values() + .find(|r| r.span_type == SpanType::Tool) + .unwrap(); + assert_eq!(tool.end_ms, Some(7)); + assert_eq!( + tool.error.as_deref(), + Some("Interrupted before tool completion") + ); +} + +#[test] +fn opencode_v2_records_reasoning_usage_timing_and_error() { + let mut translator = Registry::default_agents().create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + let events = [ + v2_event( + "session.inbox.enqueued", + 1, + json!({"sessionID":"s","inboxID":"i","item":{"type":"user","payload":{"text":"think"}}}), + ), + v2_event( + "session.inbox.delivered", + 2, + json!({"sessionID":"s","inboxID":"i"}), + ), + v2_event( + "session.step.started", + 3, + json!({"sessionID":"s","assistantMessageID":"m","started":2,"model":{"id":"model","providerID":"test","variant":"high"}}), + ), + v2_event( + "session.reasoning.ended", + 4, + json!({"sessionID":"s","assistantMessageID":"m","ordinal":1,"text":"second"}), + ), + v2_event( + "session.reasoning.ended", + 5, + json!({"sessionID":"s","assistantMessageID":"m","ordinal":0,"text":"first "}), + ), + v2_event( + "session.text.ended", + 6, + json!({"sessionID":"s","assistantMessageID":"m","text":"answer"}), + ), + v2_event( + "session.step.failed", + 7, + json!({"sessionID":"s","assistantMessageID":"m","tokens":{"input":10,"output":3,"reasoning":2,"cache":{"read":5,"write":4}},"error":{"type":"provider","message":"provider failure"}}), + ), + v2_event( + "session.execution.failed", + 8, + json!({"sessionID":"s","error":{"type":"provider","message":"provider failure"}}), + ), + ]; + let ops = events + .iter() + .flat_map(|e| translator.handle(e, &ctx).unwrap()) + .collect(); + let rows = reduce(ops); + let llm = rows + .values() + .find(|r| r.span_type == SpanType::Llm) + .unwrap(); + assert_eq!((llm.start_ms, llm.end_ms), (Some(2), Some(7))); + assert_eq!(llm.error.as_deref(), Some("provider failure")); + assert_eq!( + llm.output.as_ref().unwrap()[0]["reasoning"][0]["content"], + "first second" + ); + assert_eq!( + llm.metrics, + Some( + json!({"prompt_tokens":19,"completion_tokens":3,"tokens":24,"prompt_cached_tokens":5,"prompt_cache_creation_tokens":4,"reasoning_tokens":2}) + ) + ); + let root = rows.values().find(|r| r.name == "OpenCode").unwrap(); + assert_eq!(root.error.as_deref(), Some("provider failure")); +} + +#[test] +fn opencode_v2_ignores_malformed_unknown_cancelled_and_replayed_events() { + let mut translator = Registry::default_agents().create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + for e in [ + v2_event( + "session.inbox.enqueued", + 1, + json!({"sessionID":"s","inboxID":"cancelled","item":{"type":"user","payload":{"text":"not delivered"}}}), + ), + v2_event( + "session.inbox.cancelled", + 2, + json!({"sessionID":"s","inboxID":"cancelled"}), + ), + v2_event( + "session.inbox.delivered", + 3, + json!({"sessionID":"s","inboxID":"cancelled"}), + ), + v2_event("session.step.ended", 4, json!({"sessionID":42})), + v2_event( + "session.future.event", + 5, + json!({"sessionID":"s","new":"shape"}), + ), + ] { + assert!(translator.handle(&e, &ctx).unwrap().is_empty()); + } + let enqueued = v2_event( + "session.inbox.enqueued", + 6, + json!({"sessionID":"s","inboxID":"real","item":{"type":"user","payload":{"text":"delivered"}}}), + ); + let delivered = v2_event( + "session.inbox.delivered", + 7, + json!({"sessionID":"s","inboxID":"real"}), + ); + assert!(translator.handle(&enqueued, &ctx).unwrap().is_empty()); + assert!(translator.handle(&enqueued, &ctx).unwrap().is_empty()); + let ops = translator.handle(&delivered, &ctx).unwrap(); + assert_eq!( + ops.iter() + .filter(|op| matches!(op, SpanOp::Insert(row) if row.name == "Turn 1")) + .count(), + 1 + ); + assert!(translator.handle(&delivered, &ctx).unwrap().is_empty()); +} + +#[test] +fn opencode_v2_routes_resumed_nested_sessions_under_external_parent() { + let mut translator = Registry::default_agents().create("opencode", "root-session"); + let mut components = SpanComponents::new(SpanObjectType::ProjectLogs); + components.span_id = Some("external-parent".into()); + components.root_span_id = Some("external-root".into()); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: Some( + SessionRoute { + destination: Some(TraceDestination::ParentSpan { components }), + additional_metadata: Some( + json!({"team":"pipeline","source":"override","_bt_private":true}), + ), + tags: vec!["nested".into()], + ..SessionRoute::default() + } + .with_auth(BackendAuth { + token: "test".into(), + api_url: None, + app_url: None, + org_name: None, + org_id: None, + }), + ), + }; + let events = [ + v2_event("session.created", 1, json!({"sessionID":"parent"})), + v2_event( + "session.inbox.enqueued", + 2, + json!({"sessionID":"parent","inboxID":"i","item":{"type":"user","payload":{"text":"delegate"}}}), + ), + v2_event( + "session.inbox.delivered", + 3, + json!({"sessionID":"parent","inboxID":"i"}), + ), + v2_event( + "session.created", + 4, + json!({"sessionID":"child","parentID":"parent","title":"find docs (@research subagent)"}), + ), + v2_event( + "session.execution.succeeded", + 5, + json!({"sessionID":"child"}), + ), + v2_event( + "session.execution.succeeded", + 6, + json!({"sessionID":"parent"}), + ), + v2_event( + "session.inbox.enqueued", + 7, + json!({"sessionID":"parent","inboxID":"j","item":{"type":"user","payload":{"text":"resume"}}}), + ), + v2_event( + "session.inbox.delivered", + 8, + json!({"sessionID":"parent","inboxID":"j"}), + ), + ]; + let ops = events + .iter() + .flat_map(|e| translator.handle(e, &ctx).unwrap()) + .collect::>(); + assert_merges_preserve_insert_identity(&ops); + let root_insert = ops + .iter() + .find_map(|op| match op { + SpanOp::Insert(row) if row.name == "OpenCode" => Some(row), + _ => None, + }) + .unwrap(); + assert_eq!(root_insert.parent_span_ids, ["external-parent"]); + let reopen = ops + .iter() + .find_map(|op| match op { + SpanOp::Merge(row) if row.span_id == root_insert.span_id && row.end_ms.is_none() => { + Some(row) + } + _ => None, + }) + .unwrap(); + assert_eq!(reopen.parent_span_ids, ["external-parent"]); + let child_insert = ops + .iter() + .find_map(|op| match op { + SpanOp::Insert(row) if row.name == "research: find docs" => Some(row), + _ => None, + }) + .unwrap(); + assert_eq!(root_insert.root_span_id, "external-root"); + assert_eq!(child_insert.root_span_id, "external-root"); + assert_ne!(child_insert.parent_span_ids, ["external-parent"]); + for row in [root_insert, child_insert] { + assert_eq!(row.metadata.as_ref().unwrap()["team"], "pipeline"); + assert_eq!(row.metadata.as_ref().unwrap()["source"], "opencode"); + assert!(row.metadata.as_ref().unwrap().get("_bt_private").is_none()); + assert_eq!(row.tags, Some(vec!["nested".into()])); + } +} + +#[test] +fn opencode_v2_reused_tool_call_ids_are_scoped_to_the_assistant_step() { + let mut t = Registry::default_agents().create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + let events = [ + v2_event( + "session.inbox.enqueued", + 1, + json!({"sessionID":"s","inboxID":"a","item":{"type":"user","payload":{"text":"hi"}}}), + ), + v2_event( + "session.inbox.delivered", + 2, + json!({"sessionID":"s","inboxID":"a"}), + ), + v2_event( + "session.tool.input.started", + 3, + json!({"sessionID":"s","assistantMessageID":"m1","id":"call","name":"read"}), + ), + v2_event( + "session.tool.called", + 4, + json!({"sessionID":"s","assistantMessageID":"m1","id":"call","input":{"path":"first"}}), + ), + v2_event( + "session.tool.success", + 5, + json!({"sessionID":"s","assistantMessageID":"m1","id":"call","content":[]}), + ), + v2_event( + "session.tool.input.started", + 6, + json!({"sessionID":"s","assistantMessageID":"m2","id":"call","name":"read"}), + ), + v2_event( + "session.tool.called", + 7, + json!({"sessionID":"s","assistantMessageID":"m2","id":"call","input":{"path":"second"}}), + ), + v2_event( + "session.tool.success", + 8, + json!({"sessionID":"s","assistantMessageID":"m2","id":"call","content":[]}), + ), + ]; + let ops = events + .iter() + .flat_map(|e| t.handle(e, &ctx).unwrap()) + .collect::>(); + let tools = ops + .iter() + .filter_map(|op| match op { + SpanOp::Insert(row) if row.span_type == SpanType::Tool => Some(row), + _ => None, + }) + .collect::>(); + assert_eq!(tools.len(), 2); + assert_ne!(tools[0].span_id, tools[1].span_id); +} + +#[test] +fn opencode_v2_reuses_session_state_after_failure_interruption_and_child_success() { + for terminal in [ + "session.execution.interrupted", + "session.execution.failed", + "session.execution.succeeded", + ] { + let mut t = Registry::default_agents().create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + let events = [ + v2_event( + "session.inbox.enqueued", + 1, + json!({"sessionID":"p","inboxID":"p1","item":{"type":"user","payload":{"text":"parent"}}}), + ), + v2_event( + "session.inbox.delivered", + 2, + json!({"sessionID":"p","inboxID":"p1"}), + ), + v2_event( + "session.created", + 3, + json!({"sessionID":"child","parentID":"p","title":"Child"}), + ), + v2_event( + "session.inbox.enqueued", + 4, + json!({"sessionID":"child","inboxID":"c1","item":{"type":"user","payload":{"text":"child"}}}), + ), + v2_event( + "session.inbox.delivered", + 5, + json!({"sessionID":"child","inboxID":"c1"}), + ), + v2_event( + terminal, + 6, + json!({"sessionID":"child","reason":"user","error":{"type":"test","message":"error"}}), + ), + v2_event( + "session.inbox.enqueued", + 7, + json!({"sessionID":"child","inboxID":"c2","item":{"type":"user","payload":{"text":"continue"}}}), + ), + v2_event( + "session.inbox.delivered", + 8, + json!({"sessionID":"child","inboxID":"c2"}), + ), + ]; + let ops = events + .iter() + .flat_map(|e| t.handle(e, &ctx).unwrap()) + .collect::>(); + let roots = ops + .iter() + .filter_map(|op| match op { + SpanOp::Insert(row) + if row + .metadata + .as_ref() + .is_some_and(|m| m["session_id"] == "child") => + { + Some(row) + } + _ => None, + }) + .collect::>(); + assert_eq!(roots.len(), 1, "{terminal}"); + assert!(!roots[0].parent_span_ids.is_empty()); + assert!(ops.iter().any(|op| matches!(op, SpanOp::Insert(row) if row.name == "Turn 2" && row.root_span_id == roots[0].root_span_id)), "{terminal}"); + } +} + +#[test] +fn opencode_v2_preserves_tool_text_and_model_metadata() { + let mut t = Registry::default_agents().create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + let events = [ + v2_event( + "session.inbox.enqueued", + 1, + json!({"sessionID":"s","inboxID":"a","item":{"type":"user","payload":{"text":"hi"}}}), + ), + v2_event( + "session.inbox.delivered", + 2, + json!({"sessionID":"s","inboxID":"a"}), + ), + v2_event( + "model.context", + 3, + json!({"sessionID":"s","messages":[{"role":"tool","content":[{"type":"text","text":"tool payload"}]}]}), + ), + v2_event( + "session.step.started", + 4, + json!({"sessionID":"s","assistantMessageID":"m","model":{"id":"model","providerID":"p"}}), + ), + v2_event( + "session.text.ended", + 5, + json!({"sessionID":"s","assistantMessageID":"m","text":"answer"}), + ), + v2_event( + "session.step.ended", + 6, + json!({"sessionID":"s","assistantMessageID":"m"}), + ), + ]; + let rows = reduce( + events + .iter() + .flat_map(|e| t.handle(e, &ctx).unwrap()) + .collect(), + ); + let llm = rows + .values() + .find(|row| row.span_type == SpanType::Llm) + .unwrap(); + assert!(llm + .input + .as_ref() + .unwrap() + .to_string() + .contains("tool payload")); + let turn = rows.values().find(|row| row.name == "Turn 1").unwrap(); + assert_eq!(turn.metadata.as_ref().unwrap()["model"], "model"); +} + +#[test] +fn opencode_v2_repeated_native_compaction_uses_replacement_window_as_input() { + let mut t = Registry::default_agents().create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + let events = [ + v2_event( + "session.inbox.enqueued", + 1, + json!({"sessionID":"s","inboxID":"a","item":{"type":"user","payload":{"text":"old"}}}), + ), + v2_event( + "session.inbox.delivered", + 2, + json!({"sessionID":"s","inboxID":"a"}), + ), + v2_event( + "session.compaction.ended", + 3, + json!({"sessionID":"s","text":"","model":{"id":"m","providerID":"p"},"native":{"messages":[{"role":"user","content":"replacement"}]}}), + ), + v2_event( + "session.inbox.enqueued", + 4, + json!({"sessionID":"s","inboxID":"b","item":{"type":"user","payload":{"text":"new"}}}), + ), + v2_event( + "session.inbox.delivered", + 5, + json!({"sessionID":"s","inboxID":"b"}), + ), + v2_event( + "session.compaction.ended", + 6, + json!({"sessionID":"s","text":"second summary","model":{"id":"m","providerID":"p"}}), + ), + ]; + let ops = events + .iter() + .flat_map(|e| t.handle(e, &ctx).unwrap()) + .collect::>(); + let second = ops + .iter() + .find_map(|op| match op { + SpanOp::Insert(row) + if row + .output + .as_ref() + .is_some_and(|v| v.to_string().contains("second summary")) => + { + Some(row) + } + _ => None, + }) + .unwrap(); + assert_eq!( + second.input, + Some(json!([{"role":"user","content":"replacement"},{"role":"user","content":"new"}])) + ); +} + +#[test] +fn opencode_v2_permission_denial_is_scoped_to_the_assistant_step() { + let mut t = Registry::default_agents().create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + let events = [ + v2_event( + "session.inbox.enqueued", + 1, + json!({"sessionID":"s","inboxID":"a","item":{"type":"user","payload":{"text":"hi"}}}), + ), + v2_event( + "session.inbox.delivered", + 2, + json!({"sessionID":"s","inboxID":"a"}), + ), + v2_event( + "session.tool.input.started", + 3, + json!({"sessionID":"s","assistantMessageID":"m1","id":"call","name":"read"}), + ), + v2_event( + "permission.asked", + 4, + json!({"sessionID":"s","id":"p","action":"read","source":{"id":"call","messageID":"m1"}}), + ), + v2_event( + "permission.replied", + 5, + json!({"sessionID":"s","requestID":"p","reply":"reject"}), + ), + v2_event( + "session.tool.failed", + 6, + json!({"sessionID":"s","assistantMessageID":"m1","id":"call","error":{"type":"permission.rejected","message":"denied"}}), + ), + v2_event( + "session.tool.input.started", + 7, + json!({"sessionID":"s","assistantMessageID":"m2","id":"call","name":"read"}), + ), + v2_event( + "session.tool.called", + 8, + json!({"sessionID":"s","assistantMessageID":"m2","id":"call","input":{"path":"allowed"}}), + ), + v2_event( + "session.tool.success", + 9, + json!({"sessionID":"s","assistantMessageID":"m2","id":"call","content":[]}), + ), + ]; + let rows = reduce( + events + .iter() + .flat_map(|e| t.handle(e, &ctx).unwrap()) + .collect(), + ); + let tools = rows + .values() + .filter(|r| r.span_type == SpanType::Tool) + .collect::>(); + assert_eq!(tools.len(), 2); + assert!(tools.iter().any(|r| r + .metadata + .as_ref() + .is_some_and(|m| m["tool_approval"] == "denied"))); + assert!(tools.iter().any(|r| r + .metadata + .as_ref() + .is_some_and(|m| m["tool_approval"] == "approved"))); +} + +#[test] +fn opencode_v2_top_level_resume_after_interruption_keeps_turn_numbers() { + let mut t = Registry::default_agents().create("opencode", "root-session"); + let ctx = SessionCtx { + session_id: "root-session".into(), + config: None, + }; + let events = [ + v2_event( + "session.inbox.enqueued", + 1, + json!({"sessionID":"s","inboxID":"a","item":{"type":"user","payload":{"text":"first"}}}), + ), + v2_event( + "session.inbox.delivered", + 2, + json!({"sessionID":"s","inboxID":"a"}), + ), + v2_event( + "session.execution.interrupted", + 3, + json!({"sessionID":"s","reason":"user"}), + ), + v2_event( + "session.inbox.enqueued", + 4, + json!({"sessionID":"s","inboxID":"b","item":{"type":"user","payload":{"text":"second"}}}), + ), + v2_event( + "session.inbox.delivered", + 5, + json!({"sessionID":"s","inboxID":"b"}), + ), + ]; + let ops = events + .iter() + .flat_map(|e| t.handle(e, &ctx).unwrap()) + .collect::>(); + assert_eq!( + ops.iter() + .filter(|op| matches!(op, SpanOp::Insert(row) if row.name == "OpenCode")) + .count(), + 1 + ); + assert!(ops + .iter() + .any(|op| matches!(op, SpanOp::Insert(row) if row.name == "Turn 2"))); +} diff --git a/bt-daemon/tests/pipeline.rs b/bt-daemon/tests/pipeline.rs index e6ee0146..d488da16 100644 --- a/bt-daemon/tests/pipeline.rs +++ b/bt-daemon/tests/pipeline.rs @@ -1613,6 +1613,66 @@ async fn opencode_lifecycle_flushes_without_an_explicit_client_flush() { handle.await.unwrap(); } +#[tokio::test] +async fn opencode_v2_terminal_events_flush_without_an_explicit_client_flush() { + let (socket, handle, flushes, _tmp) = start_tracking_daemon("test").await; + let host = dummy_host(); + for event in [ + "session.execution.succeeded", + "session.execution.failed", + "session.execution.interrupted", + "session.deleted", + ] { + let session_id = format!("opencode-v2-{event}"); + let mut enqueued = envelope(&session_id, "session.inbox.enqueued", 1); + enqueued.source = "opencode".into(); + enqueued.source_version = Some("2.0.0".into()); + enqueued.payload = serde_json::json!({"id":"enqueue", "data":{"sessionID":"native","inboxID":"i","item":{"type":"user","payload":{"text":"hello"}}}}); + forward_envelope(&enqueued, &socket, &host, false) + .await + .unwrap(); + let mut delivered = enqueued.clone(); + delivered.event = "session.inbox.delivered".into(); + delivered.payload = + serde_json::json!({"id":"delivered","data":{"sessionID":"native","inboxID":"i"}}); + forward_envelope(&delivered, &socket, &host, false) + .await + .unwrap(); + assert!( + flush_session(&session_id, &socket, 5_000) + .await + .unwrap() + .flushed + ); + flushes.lock().unwrap().insert(session_id.clone(), 0); + let mut terminal = enqueued.clone(); + terminal.event = event.into(); + terminal.payload = serde_json::json!({"id":"terminal","data":{"sessionID":"native","reason":"test","error":{"type":"test","message":"test failure"}}}); + forward_envelope(&terminal, &socket, &host, false) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if flushes + .lock() + .unwrap() + .get(&session_id) + .copied() + .unwrap_or_default() + > 0 + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("OpenCode v2 {event} was not flushed by the daemon")); + } + shutdown(&socket).await; + handle.await.unwrap(); +} + #[tokio::test] async fn hook_capture_stops_at_the_durable_journal_boundary() { let (socket, handle, tmp) = start_slow_daemon().await; @@ -2620,6 +2680,33 @@ async fn idle_sessions_are_retired_and_can_resume_from_their_journal() { handle.abort(); } +#[cfg(unix)] +#[tokio::test] +async fn managed_run_flush_times_out_when_daemon_does_not_reply() { + let temp = tempfile::tempdir().unwrap(); + let socket = temp.path().join("stalled.sock"); + let listener = tokio::net::UnixListener::bind(&socket).unwrap(); + let server = tokio::spawn(async move { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + let (connection, _) = listener.accept().await.unwrap(); + let (read, mut write) = connection.into_split(); + let mut lines = BufReader::new(read).lines(); + let _initialize = lines.next_line().await.unwrap().unwrap(); + write.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocol_version\":1,\"daemon_version\":\"test\"}}\n").await.unwrap(); + let request = lines.next_line().await.unwrap().unwrap(); + assert!(request.contains("managed_run.flush")); + std::future::pending::<()>().await; + }); + let result = tokio::time::timeout( + Duration::from_secs(2), + flush_managed_run("test-run", &socket, 100), + ) + .await + .expect("managed run flush must be bounded"); + assert!(result.unwrap_err().to_string().contains("timed out")); + server.abort(); +} + #[tokio::test] async fn managed_run_flush_is_scoped_to_its_accepted_sessions() { let (socket, handle, _flushes, tmp) = start_tracking_daemon("test").await; @@ -4491,3 +4578,29 @@ async fn storage_failure_after_retirement_is_reported_and_recovers_without_nativ shutdown(&socket).await; handle.await.unwrap(); } + +#[cfg(unix)] +#[tokio::test] +async fn event_pipeline_stops_after_a_rejected_request() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + let (socket, handle, _flushes, _tmp) = start_tracking_daemon("test").await; + let stream = tokio::net::UnixStream::connect(&socket).await.unwrap(); + let (read, mut write) = stream.into_split(); + let mut lines = BufReader::new(read).lines(); + write + .write_all( + concat!( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"event.log\",\"params\":{}}\n", + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"status.get\",\"params\":{}}\n", + ) + .as_bytes(), + ) + .await + .unwrap(); + let response: serde_json::Value = + serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap(); + assert_eq!(response["id"], 1); + assert!(response.get("error").is_some()); + assert!(lines.next_line().await.unwrap().is_none()); + handle.abort(); +} diff --git a/bt-daemon/tests/support/agent_process.rs b/bt-daemon/tests/support/agent_process.rs index 83f6e755..6153221e 100644 --- a/bt-daemon/tests/support/agent_process.rs +++ b/bt-daemon/tests/support/agent_process.rs @@ -47,6 +47,14 @@ pub struct AgentTestWorld { config_path: PathBuf, } +pub fn daemon_command() -> Command { + Command::new(env!("CARGO_BIN_EXE_bt-daemon")) +} + +pub fn command(program: impl AsRef) -> Command { + Command::new(program) +} + impl AgentTestWorld { pub async fn start() -> Self { let inference_mode = TestBackendMode::from_env(INFERENCE_MODE_ENV); @@ -63,6 +71,10 @@ impl AgentTestWorld { let project = std::env::var(PROJECT_ENV).unwrap_or_else(|_| "agent-e2e".into()); std::fs::create_dir_all(&wrapper_dir).expect("create wrapper directory"); std::fs::create_dir_all(&data_dir).expect("create daemon data directory"); + // Keep daemon diagnostics available without an unread pipe that can + // fill and block the daemon during longer integration scenarios. + let daemon_stderr = std::fs::File::create(data_dir.join("daemon-stderr.log")) + .expect("create daemon stderr capture"); std::fs::write( &config_path, serde_json::to_vec_pretty(&json!({ @@ -106,7 +118,7 @@ impl AgentTestWorld { .arg("0") .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::piped()) + .stderr(Stdio::from(daemon_stderr)) .kill_on_drop(true); if ingest_mode == TestBackendMode::Mock { command @@ -154,6 +166,11 @@ impl AgentTestWorld { self.root.path().join(name) } + #[cfg(unix)] + pub fn socket(&self) -> &Path { + &self.socket + } + pub fn configure(&self, command: &mut Command) { let path = std::env::var_os("PATH").unwrap_or_default(); let mut entries = vec![self.wrapper_dir.clone()]; @@ -186,15 +203,77 @@ impl AgentTestWorld { } pub async fn output(&self, command: &mut Command) -> std::process::Output { + use std::io::{Read, Seek}; + let program = command + .as_std() + .get_program() + .to_string_lossy() + .into_owned(); + let args: Vec<_> = command + .as_std() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + // An agent's detached descendants may inherit output handles. Files + // let us collect the foreground command's output without waiting for + // those descendants to close a pipe after the command exits. + let mut stdout = tempfile::tempfile().expect("create stdout capture"); + let mut stderr = tempfile::tempfile().expect("create stderr capture"); command - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) + .stdout(Stdio::from( + stdout.try_clone().expect("clone stdout capture"), + )) + .stderr(Stdio::from( + stderr.try_clone().expect("clone stderr capture"), + )) .kill_on_drop(true); - let child = command.spawn().expect("spawn agent command"); - tokio::time::timeout(Duration::from_secs(90), child.wait_with_output()) - .await - .expect("agent command timed out") - .expect("wait for agent command") + let mut child = command.spawn().expect("spawn agent command"); + let status = match tokio::time::timeout(Duration::from_secs(90), child.wait()).await { + Ok(status) => status.expect("wait for agent command"), + Err(_) => { + let mut out = String::new(); + let mut err = String::new(); + let _ = stdout.rewind(); + let _ = stderr.rewind(); + let _ = stdout.read_to_string(&mut out); + let _ = stderr.read_to_string(&mut err); + #[cfg(unix)] + let processes = match tokio::time::timeout( + Duration::from_secs(3), + self::command("ps") + .args(["-eo", "pid,ppid,stat,comm"]) + .output(), + ) + .await + { + Ok(Ok(output)) => String::from_utf8_lossy(&output.stdout).into_owned(), + _ => String::from("process listing unavailable"), + }; + #[cfg(not(unix))] + let processes = String::from("process listing unavailable"); + panic!( + "agent command timed out: {program} {args:?} pid {:?}; stdout:\n{out}\nstderr:\n{err}\nprocesses:\n{processes}\n{}; daemon files:\n{}", + child.id(), + self.collector.diagnostics(), + directory_contents(&self.data_dir) + ); + } + }; + let mut stdout_bytes = Vec::new(); + let mut stderr_bytes = Vec::new(); + stdout.rewind().expect("rewind stdout capture"); + stderr.rewind().expect("rewind stderr capture"); + stdout + .read_to_end(&mut stdout_bytes) + .expect("read stdout capture"); + stderr + .read_to_end(&mut stderr_bytes) + .expect("read stderr capture"); + std::process::Output { + status, + stdout: stdout_bytes, + stderr: stderr_bytes, + } } pub async fn wait_for_trace_rows(&self) -> Vec { diff --git a/bt-daemon/tests/support/inference/openai.rs b/bt-daemon/tests/support/inference/openai.rs index c9b959a9..1bdff75d 100644 --- a/bt-daemon/tests/support/inference/openai.rs +++ b/bt-daemon/tests/support/inference/openai.rs @@ -360,6 +360,7 @@ async fn chat_completions( Err(error) => return json_response(StatusCode::BAD_REQUEST, json!({"error": error})), }; let request = OpenAiRequest { body }; + let streaming = request.body["stream"] == true; state .requests .lock() @@ -408,6 +409,18 @@ async fn chat_completions( arguments, .. }) => { + if streaming { + let chunks = [ + json!({"id":format!("chatcmpl_mock_{index}"),"object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":call_id,"type":"function","function":{"name":name,"arguments":arguments.to_string()}}]}}]}), + json!({"id":format!("chatcmpl_mock_{index}"),"object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}), + ]; + let mut body = chunks + .iter() + .map(|chunk| format!("data: {chunk}\n\n")) + .collect::(); + body.push_str("data: [DONE]\n\n"); + return raw_response(StatusCode::OK, "text/event-stream", body.into_bytes()); + } let body = json!({ "id": format!("chatcmpl_mock_{index}"), "object": "chat.completion", diff --git a/src/plugins/opencode/content/README.md b/src/plugins/opencode/content/README.md index aaf09358..fede56c5 100644 --- a/src/plugins/opencode/content/README.md +++ b/src/plugins/opencode/content/README.md @@ -33,9 +33,18 @@ Historical import and live attach are not supported for OpenCode. ## Compatibility +The plugin supports OpenCode 1.x and has experimental support for OpenCode 2 +through its `@opencode/cli` distribution. Installed-package tests exercise +text and tool sessions, subagents, attachments, denied permissions, interrupted +sessions, compaction, and resumed sessions using a mock model and trace backend. +Managed runs start a private OpenCode 2 server to keep routing scoped to the run. +Standalone delivery is tested with delayed event acknowledgements. As with +other fail-open integrations, abrupt termination or an unavailable daemon can +prevent trace events from being delivered. + The package declares `@opencode-ai/plugin` and `@opencode-ai/sdk` peers at -`>=1.2.25`. CI checks package installation at that minimum and at `latest`, -and runs real-agent integration tests with the latest OpenCode CLI. +`>=1.2.25`. CI checks package installation at that minimum and at the latest +1.x peers, and runs real-agent integration tests with both distributions. ## Configuration diff --git a/src/plugins/opencode/content/package.json b/src/plugins/opencode/content/package.json index 2bdc02a8..5997a440 100644 --- a/src/plugins/opencode/content/package.json +++ b/src/plugins/opencode/content/package.json @@ -19,6 +19,7 @@ }, "files": [ "dist", + "server.js", "README.md", "LICENSE" ], @@ -30,6 +31,10 @@ "types": "./dist/index.d.mts", "import": "./dist/index.mjs" }, + "./server": { + "types": "./dist/server.d.mts", + "import": "./dist/server.mjs" + }, "./tracing": { "types": "./dist/tracing.d.mts", "import": "./dist/tracing.mjs" @@ -46,7 +51,7 @@ "fmt": "vp fmt", "lint": "vp lint", "publish:dry-run": "pnpm run build && pnpm publish --dry-run --no-git-checks", - "smoke": "node -e \"Promise.all([import('./dist/index.mjs'), import('./dist/tracing.mjs')])\"", + "smoke": "node -e \"Promise.all([import('./dist/index.mjs'), import('./dist/tracing.mjs'), import('./dist/server.mjs')])\"", "test": "pnpm run prepare:daemon-client && vitest run", "test:watch": "pnpm run prepare:daemon-client && vitest", "typecheck": "pnpm run check" diff --git a/src/plugins/opencode/content/server.js b/src/plugins/opencode/content/server.js new file mode 100644 index 00000000..a8bd667d --- /dev/null +++ b/src/plugins/opencode/content/server.js @@ -0,0 +1,2 @@ +// OpenCode 2.0 and 2.1 resolve local plugin directories by filename. +export { default } from "./dist/server.mjs"; diff --git a/src/plugins/opencode/content/src/server.test.ts b/src/plugins/opencode/content/src/server.test.ts new file mode 100644 index 00000000..06d1ca48 --- /dev/null +++ b/src/plugins/opencode/content/src/server.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + logs: [] as Array>, + closed: false, + gate: undefined as Promise | undefined, +})); +vi.mock("./runtime/daemon-client", () => ({ + claimManagedTracingInstance: () => true, + resolveDaemonTraceSettings: (settings: unknown) => settings, + DaemonClient: class { + async prepare() { + await state.gate; + return true; + } + async log(event: Record) { + state.logs.push(event); + return true; + } + async logPipelined(event: Record) { + state.logs.push(event); + await state.gate; + return true; + } + async close() { + state.closed = true; + } + }, +})); +vi.mock("./tracing/plugin", () => ({ + readPluginConfig: async () => ({ trace_to_braintrust: true, project: "test" }), +})); +vi.mock("./tools/bt-cli", () => ({ + BtCliToolsClient: class { + async listProjects() { + return [{ name: "Example", id: "p1" }]; + } + }, +})); +import plugin from "./server"; + +describe("OpenCode 2 capture", () => { + it.each([undefined, "managed-run"])( + "registers executable Braintrust tools (managed: %s)", + async (managedRun) => { + vi.stubEnv("BT_TRACE_MANAGED_RUN_ID", managedRun); + const definitions: Array<{ + name: string; + input: object; + execute(input: unknown): Promise<{ content: string }>; + }> = []; + const cleanup = await plugin.setup({ + app: { version: "2.2.5" }, + session: { + async hook() {}, + async get() { + return {}; + }, + }, + location: { directory: "/test" }, + event: { async *subscribe() {} }, + tool: { + async transform(callback) { + callback({ + add(definition) { + definitions.push(definition); + }, + }); + }, + }, + }); + const projects = definitions.find( + (definition) => definition.name === "braintrust_list_projects", + ); + expect(projects).toBeDefined(); + expect(projects?.input).toMatchObject({ type: "object" }); + expect(await projects?.execute({})).toEqual({ content: "- Example (p1)" }); + await cleanup?.(); + vi.unstubAllEnvs(); + }, + ); + it("forwards native events in order and closes the daemon", async () => { + state.logs.length = 0; + state.closed = false; + const events = [ + { type: "session.inbox.enqueued", created: 1, data: { sessionID: "s", inboxID: "i" } }, + { type: "session.step.started", created: 2, data: { sessionID: "s" } }, + ]; + const cleanup = await plugin.setup({ + app: { version: "2.2.5" }, + session: { + async hook() {}, + async get() { + return {}; + }, + }, + location: { directory: "/test" }, + tool: { + async transform(callback) { + callback({ add() {} }); + }, + }, + event: { + async *subscribe() { + yield* events; + }, + }, + }); + await vi.waitFor(() => expect(state.logs.length).toBeGreaterThan(0)); + await cleanup?.(); + await vi.waitFor(() => expect(state.closed).toBe(true)); + expect(state.logs.map((event) => event.event)).toEqual([ + "session_start", + ...events.map((e) => e.type), + ]); + expect(state.logs[1]).toMatchObject({ + source: "opencode", + source_version: "2.2.5", + session_id: "s", + ts_ms: 1, + payload: events[0], + }); + expect(state.closed).toBe(true); + }); + + it("routes an early child model context through its parent session", async () => { + state.logs.length = 0; + let capture: (event: { + sessionID: string; + system: Array<{ text: string }>; + messages: unknown[]; + }) => void = () => {}; + const cleanup = await plugin.setup({ + app: { version: "2.2.5" }, + location: { directory: "/test" }, + session: { + async get() { + return {}; + }, + async hook(_name, callback) { + capture = callback; + }, + }, + tool: { + async transform(callback) { + callback({ add() {} }); + }, + }, + event: { + async *subscribe() { + capture({ + sessionID: "child", + system: [{ text: "system" }], + messages: [{ role: "user", content: "hello" }], + }); + yield { type: "session.created", data: { sessionID: "child", parentID: "parent" } }; + yield { type: "session.step.started", data: { sessionID: "child" } }; + }, + }, + }); + await vi.waitFor(() => expect(state.logs.length).toBeGreaterThan(0)); + await cleanup?.(); + await vi.waitFor(() => expect(state.closed).toBe(true)); + expect(state.logs.map((entry) => entry.event)).toEqual([ + "session.created", + "model.context", + "session.step.started", + ]); + expect(state.logs[1]).toMatchObject({ + session_id: "parent", + payload: { data: { sessionID: "child" } }, + }); + }); + it("starts and forwards without waiting for daemon acknowledgements", async () => { + state.logs.length = 0; + state.closed = false; + let release = () => {}; + state.gate = new Promise((resolve) => { + release = resolve; + }); + const cleanup = await plugin.setup({ + app: { version: "2.0.0" }, + location: { directory: "/test" }, + session: { + async hook() {}, + async get() { + return {}; + }, + }, + tool: { + async transform(callback) { + callback({ add() {} }); + }, + }, + event: { + async *subscribe() { + yield { type: "session.created", data: { sessionID: "s" } }; + yield { type: "session.execution.succeeded", data: { sessionID: "s" } }; + }, + }, + }); + await vi.waitFor(() => expect(state.logs).toHaveLength(2)); + const closing = cleanup?.(); + expect(state.logs.map((entry) => entry.event)).toEqual([ + "session.created", + "session.execution.succeeded", + ]); + release(); + await closing; + await vi.waitFor(() => expect(state.closed).toBe(true)); + state.gate = undefined; + }); + it("resolves a resumed nested session to its original root", async () => { + state.logs.length = 0; + state.closed = false; + const parents: Record = { child: "parent", parent: "root" }; + const cleanup = await plugin.setup({ + app: { version: "2.2.5" }, + location: { directory: "/test" }, + session: { + async hook() {}, + async get({ sessionID }) { + return { parentID: parents[sessionID] }; + }, + }, + tool: { + async transform(callback) { + callback({ add() {} }); + }, + }, + event: { + async *subscribe() { + yield { type: "session.step.started", data: { sessionID: "child" } }; + }, + }, + }); + await vi.waitFor(() => expect(state.logs).toHaveLength(2)); + expect(state.logs.map((entry) => entry.session_id)).toEqual(["root", "root"]); + await cleanup?.(); + await vi.waitFor(() => expect(state.closed).toBe(true)); + }); +}); diff --git a/src/plugins/opencode/content/src/server.ts b/src/plugins/opencode/content/src/server.ts new file mode 100644 index 00000000..a72c1970 --- /dev/null +++ b/src/plugins/opencode/content/src/server.ts @@ -0,0 +1,185 @@ +/** OpenCode 2 server entrypoint. OpenCode 1 continues to load the root export. */ +import { loadConfig } from "./config"; +import { claimManagedTracingInstance, DaemonClient } from "./runtime/daemon-client"; +import { readPluginConfig } from "./tracing/plugin"; +import { PLUGIN_VERSION } from "./version"; +import { tool, type ToolContext } from "@opencode-ai/plugin"; +import { createBraintrustTools } from "./tools"; +import { BtCliToolsClient } from "./tools/bt-cli"; + +interface NativeEvent { + type: string; + created?: number; + location?: { directory?: string }; + data?: { sessionID?: string; parentID?: string }; +} + +interface Context { + app: { version: string }; + location: { directory: string }; + event: { subscribe(options: { signal: AbortSignal }): AsyncIterable }; + session: { + get(input: { sessionID: string }): Promise<{ parentID?: string | null }>; + hook( + name: "context", + callback: (event: { + sessionID: string; + system: Array<{ text: string }>; + messages: unknown[]; + }) => void, + ): Promise; + }; + tool: { + transform( + callback: (editor: { + add(definition: { + name: string; + description: string; + input: object; + execute(input: unknown): Promise<{ content: string }>; + }): void; + }) => void, + ): Promise; + }; +} + +const forwarded = new Set([ + "session.created", + "session.inbox.enqueued", + "session.inbox.delivered", + "session.inbox.cancelled", + "session.execution.started", + "session.execution.succeeded", + "session.execution.failed", + "session.execution.interrupted", + "session.step.started", + "session.step.ended", + "session.step.failed", + "session.text.ended", + "session.reasoning.ended", + "session.tool.input.started", + "session.tool.called", + "session.tool.success", + "session.tool.failed", + "permission.asked", + "permission.replied", + "session.compaction.started", + "session.compaction.ended", + "session.compaction.failed", + "session.deleted", +]); + +export default { + id: "braintrust.trace", + async setup(ctx: Context) { + const config = loadConfig(await readPluginConfig(ctx.location)); + if (config.enableTools) { + const definitions = createBraintrustTools(new BtCliToolsClient(config)); + await ctx.tool.transform((editor) => { + for (const [name, definition] of Object.entries(definitions)) { + editor.add({ + name, + description: definition.description, + input: tool.schema.toJSONSchema(tool.schema.object(definition.args)), + async execute(input) { + const result = await definition.execute( + input as Record, + {} as ToolContext, + ); + return { content: typeof result === "string" ? result : result.output }; + }, + }); + } + }); + } + if (!config.tracingEnabled || !claimManagedTracingInstance("opencode")) return; + const daemon = new DaemonClient({ source: "opencode", pluginVersion: PLUGIN_VERSION }); + const abort = new AbortController(); + const roots = new Map(); + const started = new Set(); + const rootFor = async (session: string): Promise => { + const path: string[] = []; + const seen = new Set(); + let current = session; + while (!roots.has(current) && !seen.has(current)) { + seen.add(current); + path.push(current); + try { + const info = await ctx.session.get({ sessionID: current }); + if (!info.parentID) break; + current = info.parentID; + } catch { + break; + } + } + const root = roots.get(current) ?? current; + for (const id of path) roots.set(id, root); + return root; + }; + const contexts = new Map>(); + await ctx.session.hook("context", (event) => { + try { + const queue = contexts.get(event.sessionID) ?? []; + const payload = JSON.parse( + JSON.stringify({ + data: { + sessionID: event.sessionID, + system: event.system.map((part) => part.text), + messages: event.messages, + }, + location: ctx.location, + }), + ) as unknown; + queue.push({ ts_ms: Date.now(), payload }); + contexts.set(event.sessionID, queue); + } catch { + // A malformed capture must not interrupt the agent's model request. + } + }); + const task = (async () => { + for await (const event of ctx.event.subscribe({ signal: abort.signal })) { + if (!forwarded.has(event.type)) continue; + const session = event.data?.sessionID; + if (!session) continue; + if (event.type === "session.created") { + const parent = event.data?.parentID; + roots.set(session, parent ? await rootFor(parent) : session); + } + const root = roots.get(session) ?? (await rootFor(session)); + const envelope = { + source: "opencode", + source_version: ctx.app.version, + session_id: root, + event: event.type, + ts_ms: event.created ?? Date.now(), + payload: event, + route: config.route, + }; + const first = !started.has(root); + if (first) { + started.add(root); + } + const context = + event.type === "session.step.started" ? contexts.get(session)?.shift() : undefined; + if (contexts.get(session)?.length === 0) contexts.delete(session); + if ( + event.type === "session.execution.succeeded" || + event.type === "session.execution.failed" || + event.type === "session.execution.interrupted" || + event.type === "session.deleted" + ) + contexts.delete(session); + if (first && event.type !== "session.created") { + void daemon.logPipelined({ ...envelope, event: "session_start" }); + } + if (context) void daemon.logPipelined({ ...envelope, event: "model.context", ...context }); + void daemon.logPipelined(envelope); + } + })().catch(() => {}); + return async () => { + abort.abort(); + await task; + await daemon.close(); + }; + }, +}; diff --git a/src/plugins/opencode/content/src/tracing/plugin.ts b/src/plugins/opencode/content/src/tracing/plugin.ts index 83ada55c..11d6d7e0 100644 --- a/src/plugins/opencode/content/src/tracing/plugin.ts +++ b/src/plugins/opencode/content/src/tracing/plugin.ts @@ -3,7 +3,9 @@ import { loadConfig, type PluginConfig } from "../config"; import { claimManagedTracingInstance } from "../runtime/daemon-client"; import { createDaemonTracingHooks } from "./daemon"; -export async function readPluginConfig(input: PluginInput): Promise { +export async function readPluginConfig(input: { + directory: string; +}): Promise { let pluginConfig: PluginConfig | undefined; try { const fs = await import("node:fs"); diff --git a/src/plugins/opencode/content/vite.config.ts b/src/plugins/opencode/content/vite.config.ts index e6dc5d6f..8fe12d48 100644 --- a/src/plugins/opencode/content/vite.config.ts +++ b/src/plugins/opencode/content/vite.config.ts @@ -49,7 +49,7 @@ export default defineConfig({ include: ["src/**/*.test.ts"], }, pack: { - entry: ["src/index.ts", "src/tracing.ts"], + entry: ["src/index.ts", "src/tracing.ts", "src/server.ts"], dts: true, format: ["esm"], sourcemap: true, diff --git a/src/plugins/opencode/validate.sh b/src/plugins/opencode/validate.sh index 3f277b1f..8186ead8 100755 --- a/src/plugins/opencode/validate.sh +++ b/src/plugins/opencode/validate.sh @@ -21,7 +21,7 @@ node -e ' const parsed = JSON.parse(process.argv[1]) const result = Array.isArray(parsed) ? parsed[0] : parsed const files = new Set(result.files.map((file) => file.path)) - for (const required of ["dist/index.mjs", "dist/index.d.mts", "README.md", "LICENSE"]) { + for (const required of ["dist/index.mjs", "dist/index.d.mts", "dist/server.mjs", "dist/server.d.mts", "server.js", "README.md", "LICENSE"]) { if (!files.has(required)) throw new Error(`package omits ${required}`) } for (const file of files) { diff --git a/src/runtime/js-daemon-client/src/index.ts b/src/runtime/js-daemon-client/src/index.ts index eed4c226..b709a862 100644 --- a/src/runtime/js-daemon-client/src/index.ts +++ b/src/runtime/js-daemon-client/src/index.ts @@ -125,10 +125,6 @@ interface PendingRequest { timer: ReturnType } -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - export function daemonSocketPath( env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, @@ -158,7 +154,10 @@ export class DaemonClient { private nextId = 1 private pending = new Map() private connecting?: Promise + private orderedEventPipeline = false private queue: Promise = Promise.resolve() + private fallbackQueue: Promise = Promise.resolve() + private inFlightEvents = new Set>() private warned = new Set() constructor(options: DaemonClientOptions) { @@ -173,30 +172,44 @@ export class DaemonClient { } async log(envelope: DaemonEnvelope): Promise { - return this.serial(async () => { - const event = { - ...envelope, - ...(this.options.pluginVersion ? { plugin_version: this.options.pluginVersion } : {}), - ...(this.options.managedRunId ? { managed_run_id: this.options.managedRunId } : {}), - } + return this.serial(() => this.sendEvent(envelope)) + } + + /** Submit events in call order without waiting for earlier acknowledgements. */ + logPipelined(envelope: DaemonEnvelope): Promise { + const request = this.ensureConnected().then( + () => this.orderedEventPipeline ? this.sendEvent(envelope) : this.serialFallback(envelope), + () => this.serialFallback(envelope), + ) + this.inFlightEvents.add(request) + void request.finally(() => this.inFlightEvents.delete(request)) + return request + } + + private async sendEvent(envelope: DaemonEnvelope): Promise { + const event = { + ...envelope, + ...(this.options.pluginVersion ? { plugin_version: this.options.pluginVersion } : {}), + ...(this.options.managedRunId ? { managed_run_id: this.options.managedRunId } : {}), + } + try { + const result = (await this.request("event.log", event)) as { accepted?: boolean } + return result.accepted === true + } catch (error) { + if (this.socket) this.disconnect(error) try { const result = (await this.request("event.log", event)) as { accepted?: boolean } return result.accepted === true - } catch (error) { - this.disconnect(error) - try { - const result = (await this.request("event.log", event)) as { accepted?: boolean } - return result.accepted === true - } catch (retryError) { - this.warnOnce(`event:${String(retryError)}`) - return false - } + } catch (retryError) { + this.warnOnce(`event:${String(retryError)}`) + return false } - }) + } } async flush(sessionId: string, timeoutMs = 10_000): Promise { return this.serial(async () => { + await Promise.all(this.inFlightEvents) try { const result = (await this.request("session.flush", { session_id: sessionId, @@ -226,9 +239,16 @@ export class DaemonClient { async close(): Promise { await this.queue + await Promise.all(this.inFlightEvents) this.disconnect(new Error("daemon client closed")) } + private serialFallback(envelope: DaemonEnvelope): Promise { + const next = this.fallbackQueue.then(() => this.sendEvent(envelope)) + this.fallbackQueue = next.then(() => undefined, () => undefined) + return next + } + private serial(operation: () => Promise): Promise { const next = this.queue.then(operation, operation) this.queue = next.then( @@ -256,12 +276,11 @@ export class DaemonClient { } private async ensureConnected(): Promise { + if (this.connecting) return this.connecting if (this.socket && !this.socket.destroyed) return - if (!this.connecting) { - this.connecting = this.connectWithStartup().finally(() => { - this.connecting = undefined - }) - } + this.connecting = this.connectWithStartup().finally(() => { + this.connecting = undefined + }) return this.connecting } @@ -272,7 +291,7 @@ export class DaemonClient { this.startDaemon() let lastError: unknown for (let attempt = 0; attempt < this.options.connectAttempts; attempt++) { - await sleep(this.options.connectDelayMs) + await new Promise((resolve) => setTimeout(resolve, this.options.connectDelayMs)) try { await this.connectOnce() return @@ -305,7 +324,7 @@ export class DaemonClient { }, })) as { protocol_version: number - capabilities?: { sources?: string[] } + capabilities?: { sources?: string[]; ordered_event_pipeline?: boolean } } if (result.protocol_version !== DAEMON_PROTOCOL_VERSION) { throw new Error(`unsupported daemon protocol ${result.protocol_version}`) @@ -313,9 +332,10 @@ export class DaemonClient { if (!result.capabilities?.sources?.includes(this.options.source)) { throw new Error(`daemon does not support ${this.options.source}; update bt`) } + this.orderedEventPipeline = result.capabilities?.ordered_event_pipeline === true resolve() } catch (error) { - this.disconnect(error) + if (this.socket === socket) this.disconnect(error) reject(error) } }) @@ -342,8 +362,12 @@ export class DaemonClient { this.input = "" socket.setEncoding("utf8") socket.on("data", (chunk: string) => this.onData(chunk)) - socket.on("error", (error) => this.disconnect(error)) - socket.on("close", () => this.disconnect(new Error("daemon connection closed"))) + socket.on("error", (error) => { + if (this.socket === socket) this.disconnect(error) + }) + socket.on("close", () => { + if (this.socket === socket) this.disconnect(new Error("daemon connection closed")) + }) } private onData(chunk: string): void { diff --git a/src/runtime/js-daemon-client/tests/client.test.ts b/src/runtime/js-daemon-client/tests/client.test.ts index 1583917b..89ca33f2 100644 --- a/src/runtime/js-daemon-client/tests/client.test.ts +++ b/src/runtime/js-daemon-client/tests/client.test.ts @@ -193,3 +193,173 @@ test("fails open when the daemon and bt executable are absent", async () => { assert.ok(warnings.length > 0) await client.close() }) + +test("pipelines ordered events before receiving daemon acknowledgements", async () => { + const temp = mkdtempSync(join(tmpdir(), "bt-js-pipeline-")) + const endpoint = process.platform === "win32" + ? `\\\\.\\pipe\\bt-js-pipeline-${process.pid}-${Date.now()}` + : join(temp, "daemon.sock") + const received: string[] = [] + const pending: Array<{ id: number; socket: import("node:net").Socket }> = [] + const server = createServer((socket) => { + socket.setEncoding("utf8") + let input = "" + socket.on("data", (chunk: string) => { + input += chunk + while (input.includes("\n")) { + const newline = input.indexOf("\n") + const request = JSON.parse(input.slice(0, newline)) + input = input.slice(newline + 1) + if (request.method === "initialize") { + setTimeout(() => socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: request.id, result: { protocol_version: 1, capabilities: { sources: ["opencode"], ordered_event_pipeline: true } } })}\n`), 100) + } else if (request.method === "session.flush") { + received.push("flush") + socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: request.id, result: { flushed: true } })}\n`) + } else { + received.push(request.params.event) + pending.push({ id: request.id, socket }) + if (pending.length === 3) { + for (const item of pending) item.socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: item.id, result: { accepted: true } })}\n`) + } + } + } + }) + }) + await new Promise((resolve, reject) => server.listen(endpoint, resolve).once("error", reject)) + const client = new DaemonClient({ source: "opencode", socketPath: endpoint, requestTimeoutMs: 2000 }) + const event = (name: string) => ({ source: "opencode", session_id: "session", event: name, ts_ms: 1, payload: {} }) + const first = client.logPipelined(event("one")) + await new Promise((resolve) => setTimeout(resolve, 20)) + const second = client.logPipelined(event("two")) + const third = client.logPipelined(event("three")) + const flush = client.flush("session") + assert.deepEqual(await Promise.all([first, second, third]), [true, true, true]) + assert.equal(await flush, true) + assert.deepEqual(received, ["one", "two", "three", "flush"]) + await client.close() + await new Promise((resolve) => server.close(() => resolve())) + rmSync(temp, { recursive: true, force: true }) +}) + +test("retries pipelined events in order after a lost connection", async () => { + const temp = mkdtempSync(join(tmpdir(), "bt-js-retry-")) + const endpoint = process.platform === "win32" ? `\\\\.\\pipe\\bt-js-retry-${process.pid}-${Date.now()}` : join(temp, "daemon.sock") + const received: string[] = [] + let connections = 0 + const server = createServer((socket) => { + const connection = ++connections + socket.setEncoding("utf8") + let input = "" + socket.on("data", (chunk: string) => { + input += chunk + while (input.includes("\n")) { + const newline = input.indexOf("\n") + const request = JSON.parse(input.slice(0, newline)) + input = input.slice(newline + 1) + if (request.method === "initialize") { + socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: request.id, result: { protocol_version: 1, capabilities: { sources: ["opencode"], ordered_event_pipeline: true } } })}\n`) + } else if (connection === 1) { + socket.destroy() + return + } else { + received.push(request.params.event) + socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: request.id, result: { accepted: true } })}\n`) + } + } + }) + }) + await new Promise((resolve, reject) => server.listen(endpoint, resolve).once("error", reject)) + const client = new DaemonClient({ source: "opencode", socketPath: endpoint, requestTimeoutMs: 2000 }) + const event = (name: string) => ({ source: "opencode", session_id: "session", event: name, ts_ms: 1, payload: {} }) + assert.deepEqual(await Promise.all([client.logPipelined(event("one")), client.logPipelined(event("two")), client.logPipelined(event("three"))]), [true, true, true]) + assert.deepEqual(received, ["one", "two", "three"]) + await client.close() + await new Promise((resolve) => server.close(() => resolve())) + rmSync(temp, { recursive: true, force: true }) +}) + +test("pipelined events do not report success when daemon initialization is incompatible", async () => { + const temp = mkdtempSync(join(tmpdir(), "bt-js-version-")) + const endpoint = process.platform === "win32" ? `\\\\.\\pipe\\bt-js-version-${process.pid}-${Date.now()}` : join(temp, "daemon.sock") + let eventsReceived = 0 + const server = createServer((socket) => { + // The client intentionally closes this connection after rejecting the + // incompatible handshake; a pending server write may then fail on Windows. + socket.on("error", () => {}) + socket.setEncoding("utf8") + let input = "" + socket.on("data", (chunk: string) => { + input += chunk + while (input.includes("\n")) { + const newline = input.indexOf("\n") + const request = JSON.parse(input.slice(0, newline)) + input = input.slice(newline + 1) + if (request.method === "event.log") eventsReceived++ + const result = request.method === "initialize" ? { protocol_version: 99, capabilities: { sources: ["opencode"] } } : { accepted: true } + socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: request.id, result })}\n`) + } + }) + }) + await new Promise((resolve, reject) => server.listen(endpoint, resolve).once("error", reject)) + const warnings: string[] = [] + const client = new DaemonClient({ source: "opencode", socketPath: endpoint, connectAttempts: 1, connectDelayMs: 1, requestTimeoutMs: 500, warn: (warning) => warnings.push(warning) }) + assert.equal(await client.logPipelined({ source: "opencode", session_id: "s", event: "test", ts_ms: 1, payload: {} }), false) + assert.ok(warnings.length > 0) + assert.equal(eventsReceived, 0) + await client.close() + await new Promise((resolve) => server.close(() => resolve())) + rmSync(temp, { recursive: true, force: true }) +}) + +for (const orderedPipeline of [false, true]) test(`does not deliver later events ahead of a rejected event (pipeline: ${orderedPipeline})`, async () => { + const temp = mkdtempSync(join(tmpdir(), "bt-js-order-")) + const endpoint = process.platform === "win32" ? `\\\\.\\pipe\\bt-js-order-${process.pid}-${Date.now()}` : join(temp, "daemon.sock") + const accepted: string[] = [] + const sockets = new Set() + let failed = false + const server = createServer((socket) => { + sockets.add(socket) + socket.on("error", () => {}) + socket.on("close", () => sockets.delete(socket)) + let input = "" + socket.on("data", (chunk) => { + input += chunk.toString() + while (input.includes("\n")) { + const end = input.indexOf("\n") + const request = JSON.parse(input.slice(0, end)) + input = input.slice(end + 1) + let response: object + if (request.method === "initialize") { + response = { result: { protocol_version: 1, capabilities: { sources: ["opencode"], ordered_event_pipeline: orderedPipeline } } } + } else if (!failed && request.params.event === "first") { + failed = true + response = { error: { code: -32603, message: "temporary" } } + if (orderedPipeline) { + // The daemon stops reading as soon as an event fails. socket.end() + // only closes the writable side; later data callbacks can still + // fire for pipelined requests, especially on Windows named pipes. + socket.pause() + socket.end(`${JSON.stringify({jsonrpc:"2.0",id:request.id,...response})}\n`) + return + } + } else { + accepted.push(request.params.event) + response = { result: { accepted: true } } + } + socket.write(`${JSON.stringify({jsonrpc:"2.0",id:request.id,...response})}\n`) + } + }) + }) + await new Promise((resolve, reject) => server.listen(endpoint, resolve).once("error", reject)) + const client = new DaemonClient({source:"opencode",socketPath:endpoint,requestTimeoutMs:500,connectAttempts:1,connectDelayMs:1}) + const event = (name: string) => ({source:"opencode",session_id:"s",event:name,ts_ms:1,payload:{}}) + try { + assert.deepEqual(await Promise.all([client.logPipelined(event("first")),client.logPipelined(event("second"))]), [true,true]) + assert.deepEqual(accepted, ["first","second"]) + } finally { + await client.close() + for (const socket of sockets) socket.destroy() + await new Promise((resolve) => server.close(() => resolve())) + rmSync(temp, {recursive:true,force:true}) + } +})