From f93005c9f042fd5cefd67778ccf38079cdfd7e21 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Tue, 3 Nov 2026 03:02:39 +0800 Subject: [PATCH 01/21] Begin translating OpenCode 2 events --- bt-daemon/src/translate/mod.rs | 1 + bt-daemon/src/translate/opencode.rs | 68 +++++++++++-- bt-daemon/src/translate/opencode_v2.rs | 132 +++++++++++++++++++++++++ bt-daemon/tests/opencode_translator.rs | 120 ++++++++++++++++++++++ src/plugins/opencode/content/README.md | 10 +- 5 files changed, 323 insertions(+), 8 deletions(-) create mode 100644 bt-daemon/src/translate/opencode_v2.rs 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..204b9a83 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 self.version.is_none() && 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. diff --git a/bt-daemon/src/translate/opencode_v2.rs b/bt-daemon/src/translate/opencode_v2.rs new file mode 100644 index 00000000..d2bc6f03 --- /dev/null +++ b/bt-daemon/src/translate/opencode_v2.rs @@ -0,0 +1,132 @@ +//! OpenCode 2 event translation. The v2 event stream has a different schema +//! from v1; this adapter keeps its correlation state separate while sharing the +//! existing span construction and history logic. + +use super::{opencode, recent::RecentSet, AgentTranslator, SessionCtx, SpanOp}; +use crate::wire::Envelope; +use serde_json::{json, Value}; +use std::collections::HashMap; + +pub(super) struct OpenCodeV2Translator { + spans: Box, + messages: HashMap<(String, String), (i64, Value)>, + pending_inputs: HashMap<(String, String), Value>, + tool_names: HashMap<(String, String), String>, + seen: RecentSet, +} + +impl OpenCodeV2Translator { + pub(super) fn new(session_id: &str) -> Self { + Self { + spans: opencode::new_translator(session_id), + messages: HashMap::new(), + pending_inputs: HashMap::new(), + tool_names: HashMap::new(), + seen: RecentSet::default(), + } + } + + fn emit( + &mut self, + original: &Envelope, + ctx: &SessionCtx, + name: &str, + mut payload: Value, + ) -> anyhow::Result> { + let mut event = original.clone(); + event.event = name.to_string(); + if let (Some(directory), Some(object)) = ( + original.payload.pointer("/location/directory"), + payload.as_object_mut(), + ) { + object.insert("directory".into(), directory.clone()); + } + event.payload = payload; + self.spans.handle(&event, ctx) + } +} + +impl AgentTranslator for OpenCodeV2Translator { + fn handle(&mut self, event: &Envelope, ctx: &SessionCtx) -> anyhow::Result> { + let Some(data) = event.payload.get("data") else { + return Ok(Vec::new()); + }; + let Some(sid) = data.get("sessionID").and_then(Value::as_str) else { + return Ok(Vec::new()); + }; + if let Some(id) = event.payload.get("id").and_then(Value::as_str) { + if !self.seen.insert(id.to_string()) { + return Ok(Vec::new()); + } + } + let mid = data + .get("assistantMessageID") + .and_then(Value::as_str) + .unwrap_or_default(); + match event.event.as_str() { + "session.created" => self.emit(event, ctx, "session.created", json!({"properties":{"info":{"id":sid,"parentID":data.get("parentID"),"title":data.get("title")}}})), + "session.inbox.enqueued" if data.pointer("/item/type").and_then(Value::as_str) == Some("user") => { + if let Some(id) = data.get("inboxID").and_then(Value::as_str) { + self.pending_inputs.insert((sid.into(), id.into()), data.pointer("/item/payload").cloned().unwrap_or(Value::Null)); + } + Ok(Vec::new()) + } + "session.inbox.delivered" => { + let Some(id) = data.get("inboxID").and_then(Value::as_str) else { return Ok(Vec::new()); }; + let Some(input) = self.pending_inputs.remove(&(sid.into(), id.into())) else { return Ok(Vec::new()); }; + let text = input.get("text").and_then(Value::as_str).unwrap_or_default(); + self.emit(event, ctx, "chat.message", json!({"input":{"sessionID":sid},"output":{"message":{"id":id},"parts":[{"type":"text","text":text}]}})) + } + "session.inbox.cancelled" => { + if let Some(id) = data.get("inboxID").and_then(Value::as_str) { self.pending_inputs.remove(&(sid.into(), id.into())); } + Ok(Vec::new()) + } + "session.step.started" => { + self.messages.insert((sid.into(), mid.into()), (data.get("started").and_then(Value::as_i64).unwrap_or(event.ts_ms), data.get("model").cloned().unwrap_or(Value::Null))); + Ok(Vec::new()) + } + "session.tool.input.started" => { + if let (Some(id), Some(name)) = (data.get("id").and_then(Value::as_str), data.get("name").and_then(Value::as_str)) { + self.tool_names.insert((sid.into(), id.into()), name.into()); + } + Ok(Vec::new()) + } + "session.tool.called" => { + let Some(id) = data.get("id").and_then(Value::as_str) else { return Ok(Vec::new()); }; + let name = self.tool_names.get(&(sid.into(), id.into())).cloned().unwrap_or_else(|| "tool".into()); + let input = data.get("input").cloned().unwrap_or(Value::Null); + let mut ops = self.emit(event, ctx, "message.part.updated", json!({"properties":{"part":{"sessionID":sid,"messageID":mid,"type":"tool","callID":id,"tool":name,"state":{"status":"running","input":input}}}}))?; + ops.extend(self.emit(event, ctx, "tool.execute.before", json!({"input":{"sessionID":sid,"callID":id,"tool":name},"output":{"args":input}}))?); + Ok(ops) + } + "session.tool.success" | "session.tool.failed" => { + let Some(id) = data.get("id").and_then(Value::as_str) else { return Ok(Vec::new()); }; + let name = self.tool_names.remove(&(sid.into(), id.into())).unwrap_or_else(|| "tool".into()); + let failed = event.event == "session.tool.failed"; + let output = if failed { data.get("error") } else { data.get("content") }.cloned().unwrap_or(Value::Null); + let state = if failed { json!({"status":"error","error":output}) } else { json!({"status":"completed","output":output}) }; + let mut ops = self.emit(event, ctx, "message.part.updated", json!({"properties":{"part":{"sessionID":sid,"messageID":mid,"type":"tool","callID":id,"tool":name,"state":state}}}))?; + ops.extend(self.emit(event, ctx, "tool.execute.after", json!({"input":{"sessionID":sid,"callID":id,"tool":name},"result":{"output":output}}))?); + Ok(ops) + } + "session.text.ended" | "session.reasoning.ended" => { + let kind = if event.event == "session.text.ended" { "text" } else { "reasoning" }; + self.emit(event, ctx, "message.part.updated", json!({"properties":{"part":{"sessionID":sid,"messageID":mid,"id":data.get("ordinal").map(Value::to_string),"type":kind,"text":data.get("text"),"time":{"end":event.ts_ms}}}})) + } + "session.step.ended" | "session.step.failed" => { + let (started, model) = self.messages.remove(&(sid.into(), mid.into())).unwrap_or((event.ts_ms, Value::Null)); + self.emit(event, ctx, "message.updated", json!({"properties":{"info":{"sessionID":sid,"id":mid,"role":"assistant","providerID":model.get("providerID"),"modelID":model.get("id"),"time":{"created":started,"completed":event.ts_ms},"tokens":data.get("tokens"),"error":data.get("error")}}})) + } + "session.execution.succeeded" => self.emit(event, ctx, "session.idle", json!({"properties":{"sessionID":sid}})), + "session.execution.failed" | "session.execution.interrupted" => self.emit(event, ctx, "session.error", json!({"properties":{"sessionID":sid,"error":data.get("error")}})), + "session.deleted" => self.emit(event, ctx, "session.deleted", json!({"properties":{"sessionID":sid}})), + _ => Ok(Vec::new()), + } + } + fn checkpoint(&mut self, ctx: &SessionCtx) -> anyhow::Result> { + self.spans.checkpoint(ctx) + } + fn finalize(&mut self, ctx: &SessionCtx) -> anyhow::Result> { + self.spans.finalize(ctx) + } +} diff --git a/bt-daemon/tests/opencode_translator.rs b/bt-daemon/tests/opencode_translator.rs index 6e4935f8..88bd6e57 100644 --- a/bt-daemon/tests/opencode_translator.rs +++ b/bt-daemon/tests/opencode_translator.rs @@ -972,3 +972,123 @@ 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":[]}, "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"}, "started":2}), + ), + ( + "session.text.ended", + 4, + json!({"sessionID":"native", "assistantMessageID":"assistant-1", "ordinal":0, "text":"world"}), + ), + ( + "session.step.ended", + 5, + json!({"sessionID":"native", "assistantMessageID":"assistant-1", "finish":"stop", "tokens":{"input":3,"output":2,"reasoning":0,"cache":{"read":0,"write":0}}}), + ), + ( + "session.execution.succeeded", + 6, + 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"))); + let llm = rows + .values() + .find(|r| r.span_type == SpanType::Llm) + .unwrap(); + assert_eq!(llm.name, "mock/mock-model"); + 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 mut e = event( + name, + i as i64 + 1, + json!({"id":format!("event-{i}"),"type":name,"data":data}), + ); + e.source_version = Some("2.2.5".into()); + 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()); +} diff --git a/src/plugins/opencode/content/README.md b/src/plugins/opencode/content/README.md index aaf09358..387220b4 100644 --- a/src/plugins/opencode/content/README.md +++ b/src/plugins/opencode/content/README.md @@ -33,9 +33,15 @@ Historical import and live attach are not supported for OpenCode. ## Compatibility +This plugin supports OpenCode 1.x. OpenCode 2 uses a different plugin API and +is not supported yet; its `@opencode/cli` distribution cannot run this plugin. +Use the 1.x `opencode-ai` distribution for tracing. See the +[OpenCode 2 migration guide](https://opencode.ai/v2/docs/build/plugins/migrate-v1). + 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 the latest `opencode-ai` +CLI. ## Configuration From 1b3f7cba9817c803c3e8e76d2490ebd1f23489ba Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Tue, 3 Nov 2026 03:20:52 +0800 Subject: [PATCH 02/21] Capture OpenCode 2 sessions --- .github/workflows/ci.yml | 7 ++ bt-daemon/src/hook.rs | 8 +- bt-daemon/src/managed_run/opencode.rs | 63 +++++++++- bt-daemon/tests/agent_integration.rs | 62 ++++++++++ bt-daemon/tests/support/agent_process.rs | 4 + src/plugins/opencode/content/README.md | 12 +- src/plugins/opencode/content/package.json | 4 + .../opencode/content/src/server.test.ts | 93 ++++++++++++++ src/plugins/opencode/content/src/server.ts | 117 ++++++++++++++++++ .../opencode/content/src/tracing/plugin.ts | 4 +- src/plugins/opencode/content/vite.config.ts | 2 +- 11 files changed, 362 insertions(+), 14 deletions(-) create mode 100644 src/plugins/opencode/content/src/server.test.ts create mode 100644 src/plugins/opencode/content/src/server.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2123d71a..7bebcf4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -227,6 +227,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 +237,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 +321,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 +344,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 +382,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 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/opencode.rs b/bt-daemon/src/managed_run/opencode.rs index 9e658672..77208420 100644 --- a/bt-daemon/src/managed_run/opencode.rs +++ b/bt-daemon/src/managed_run/opencode.rs @@ -3,8 +3,24 @@ 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 { +fn is_v2() -> bool { + let executable = crate::env::var_os("OPENCODE_BIN").unwrap_or_else(|| "opencode".into()); + crate::subprocess::background_command(executable) + .arg("--version") + .output() + .ok() + .filter(|output| output.status.success()) + .is_some_and(|output| { + String::from_utf8_lossy(&output.stdout) + .split_whitespace() + .last() + .is_some_and(|v| v.trim_start_matches('v').starts_with("2.")) + }) +} + +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 +34,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,15 +53,32 @@ impl ManagedRun for OpenCode { ("OPENCODE_BIN", "opencode") } + fn check(&self, args: &[OsString], _terminal: super::Terminal) -> anyhow::Result<()> { + if is_v2() + && args + .iter() + .any(|arg| arg == "--server" || arg.to_string_lossy().starts_with("--server=")) + { + anyhow::bail!("OpenCode 2 managed tracing requires a private server; remove --server"); + } + Ok(()) + } + fn inject( &self, _hook_command: &RunHookCommand, _managed_run_id: &str, ) -> anyhow::Result { + let v2 = is_v2(); let config = - opencode_managed_config(std::env::var("OPENCODE_CONFIG_CONTENT").ok().as_deref())?; + 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() }) } @@ -52,7 +91,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 +111,17 @@ 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"]) + ); + } } diff --git a/bt-daemon/tests/agent_integration.rs b/bt-daemon/tests/agent_integration.rs index fec5de37..e2986ce0 100644 --- a/bt-daemon/tests/agent_integration.rs +++ b/bt-daemon/tests/agent_integration.rs @@ -519,3 +519,65 @@ 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() { + let inference = OpenAiMock::new(|_, _| 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 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", + "--model", + "mock/mock-model", + "say hello", + ]) + .current_dir(&workspace) + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("XDG_CACHE_HOME", home.join("cache")) + .env("OPENCODE_CONFIG_CONTENT", config.to_string()) + .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 scenario = IngestScenario::new() + .expect("v2 input", |row| { + row_contains(row, &["Turn 1", "say hello"]) + }) + .expect("v2 LLM", |row| { + row_contains(row, &[r#""type":"llm""#, "OPENCODE_V2_OK"]) + }); + world.wait_for_mock_ingest_scenario(&scenario).await; +} diff --git a/bt-daemon/tests/support/agent_process.rs b/bt-daemon/tests/support/agent_process.rs index 83f6e755..8dc71e5b 100644 --- a/bt-daemon/tests/support/agent_process.rs +++ b/bt-daemon/tests/support/agent_process.rs @@ -47,6 +47,10 @@ pub struct AgentTestWorld { config_path: PathBuf, } +pub fn daemon_command() -> Command { + Command::new(env!("CARGO_BIN_EXE_bt-daemon")) +} + impl AgentTestWorld { pub async fn start() -> Self { let inference_mode = TestBackendMode::from_env(INFERENCE_MODE_ENV); diff --git a/src/plugins/opencode/content/README.md b/src/plugins/opencode/content/README.md index 387220b4..7c21578c 100644 --- a/src/plugins/opencode/content/README.md +++ b/src/plugins/opencode/content/README.md @@ -33,15 +33,15 @@ Historical import and live attach are not supported for OpenCode. ## Compatibility -This plugin supports OpenCode 1.x. OpenCode 2 uses a different plugin API and -is not supported yet; its `@opencode/cli` distribution cannot run this plugin. -Use the 1.x `opencode-ai` distribution for tracing. See the -[OpenCode 2 migration guide](https://opencode.ai/v2/docs/build/plugins/migrate-v1). +The plugin supports OpenCode 1.x and has experimental support for OpenCode 2 +through its `@opencode/cli` distribution. OpenCode 2 text sessions are tested +with the installed package. Coverage for tool calls, subagents, attachments, +permissions, compaction and interrupted sessions is still being verified. +Managed runs start a private OpenCode 2 server to keep routing scoped to the run. The package declares `@opencode-ai/plugin` and `@opencode-ai/sdk` peers at `>=1.2.25`. CI checks package installation at that minimum and at the latest -1.x peers, and runs real-agent integration tests with the latest `opencode-ai` -CLI. +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..79e8b6ad 100644 --- a/src/plugins/opencode/content/package.json +++ b/src/plugins/opencode/content/package.json @@ -30,6 +30,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" 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..1b47bd49 --- /dev/null +++ b/src/plugins/opencode/content/src/server.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ logs: [] as Array>, closed: false })); +vi.mock("./runtime/daemon-client", () => ({ + claimManagedTracingInstance: () => true, + resolveDaemonTraceSettings: (settings: unknown) => settings, + DaemonClient: class { + async log(event: Record) { + state.logs.push(event); + 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("registers executable Braintrust tools", async () => { + const definitions: Array<{ + name: string; + input: object; + execute(input: unknown): Promise<{ content: string }>; + }> = []; + const cleanup = await plugin.setup({ + app: { version: "2.2.5" }, + 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?.(); + }); + 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" }, + location: { directory: "/test" }, + tool: { + async transform(callback) { + callback({ add() {} }); + }, + }, + event: { + async *subscribe() { + yield* events; + }, + }, + }); + await cleanup?.(); + 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); + }); +}); diff --git a/src/plugins/opencode/content/src/server.ts b/src/plugins/opencode/content/src/server.ts new file mode 100644 index 00000000..2265b4d6 --- /dev/null +++ b/src/plugins/opencode/content/src/server.ts @@ -0,0 +1,117 @@ +/** 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 }; + 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", + "session.deleted", +]); + +export default { + id: "braintrust.trace", + async setup(ctx: Context) { + const config = loadConfig(await readPluginConfig(ctx.location)); + if (config.enableTools && !process.env.BT_TRACE_MANAGED_RUN_ID) { + 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 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 ? (roots.get(parent) ?? parent) : session); + } + const root = roots.get(session) ?? 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, + }; + if (!started.has(root)) { + started.add(root); + if (event.type !== "session.created") + await daemon.log({ ...envelope, event: "session_start" }); + } + await daemon.log(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, From d86f0708f74cd3c26bef6f827ec5715953b2ffee Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Tue, 3 Nov 2026 03:29:40 +0800 Subject: [PATCH 03/21] Verify OpenCode 2 tool traces --- bt-daemon/tests/agent_integration.rs | 79 ++++++++++++++++++++- bt-daemon/tests/support/agent_process.rs | 4 ++ bt-daemon/tests/support/inference/openai.rs | 13 ++++ src/plugins/opencode/content/README.md | 4 +- src/plugins/opencode/content/src/server.ts | 14 ++-- 5 files changed, 106 insertions(+), 8 deletions(-) diff --git a/bt-daemon/tests/agent_integration.rs b/bt-daemon/tests/agent_integration.rs index e2986ce0..b2d46024 100644 --- a/bt-daemon/tests/agent_integration.rs +++ b/bt-daemon/tests/agent_integration.rs @@ -523,7 +523,21 @@ fn request_helpers_recognize_tool_results_and_advertised_tools() { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[ignore = "requires OpenCode 2 and a built OpenCode package"] async fn opencode_v2_session_emits_traces() { - let inference = OpenAiMock::new(|_, _| MockReply::response(OpenAiTurn::text("OPENCODE_V2_OK"))); + let inference = OpenAiMock::new(|_, request| { + 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 { + MockReply::response(OpenAiTurn::tool_call( + "call_v2", + "shell", + json!({"command":tool_command("OPENCODE_V2_TOOL_OK"),"description":"Print a marker"}), + )) + } 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(); @@ -546,17 +560,18 @@ async fn opencode_v2_session_emits_traces() { "agent-e2e", "opencode", "run", + "--auto", "--model", "mock/mock-model", "say hello", ]) .current_dir(&workspace) + .env("PWD", &workspace) .env("HOME", &home) .env("USERPROFILE", &home) .env("XDG_CONFIG_HOME", home.join("config")) .env("XDG_DATA_HOME", home.join("data")) .env("XDG_CACHE_HOME", home.join("cache")) - .env("OPENCODE_CONFIG_CONTENT", config.to_string()) .env( "OPENCODE_BIN", std::env::var_os("OPENCODE_V2_BIN").expect("set OPENCODE_V2_BIN"), @@ -576,8 +591,68 @@ async fn opencode_v2_session_emits_traces() { .expect("v2 input", |row| { row_contains(row, &["Turn 1", "say hello"]) }) + .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"]) }); world.wait_for_mock_ingest_scenario(&scenario).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([ + "--standalone", + "run", + "--auto", + "--model", + "mock/mock-model", + "persistent marker", + ]) + .current_dir(&workspace) + .env("PWD", &workspace) + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("XDG_CACHE_HOME", home.join("cache")); + 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; } diff --git a/bt-daemon/tests/support/agent_process.rs b/bt-daemon/tests/support/agent_process.rs index 8dc71e5b..e20f7393 100644 --- a/bt-daemon/tests/support/agent_process.rs +++ b/bt-daemon/tests/support/agent_process.rs @@ -51,6 +51,10 @@ 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); 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 7c21578c..10bcf2c6 100644 --- a/src/plugins/opencode/content/README.md +++ b/src/plugins/opencode/content/README.md @@ -34,8 +34,8 @@ 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. OpenCode 2 text sessions are tested -with the installed package. Coverage for tool calls, subagents, attachments, +through its `@opencode/cli` distribution. OpenCode 2 text and shell-tool sessions +are tested with the installed package. Coverage for subagents, attachments, permissions, compaction and interrupted sessions is still being verified. Managed runs start a private OpenCode 2 server to keep routing scoped to the run. diff --git a/src/plugins/opencode/content/src/server.ts b/src/plugins/opencode/content/src/server.ts index 2265b4d6..a7e01a0d 100644 --- a/src/plugins/opencode/content/src/server.ts +++ b/src/plugins/opencode/content/src/server.ts @@ -81,6 +81,7 @@ export default { const abort = new AbortController(); const roots = new Map(); const started = new Set(); + let pending = Promise.resolve(); const task = (async () => { for await (const event of ctx.event.subscribe({ signal: abort.signal })) { if (!forwarded.has(event.type)) continue; @@ -100,17 +101,22 @@ export default { payload: event, route: config.route, }; - if (!started.has(root)) { + const first = !started.has(root); + if (first) { started.add(root); - if (event.type !== "session.created") - await daemon.log({ ...envelope, event: "session_start" }); } - await daemon.log(envelope); + pending = pending.then(async () => { + if (first && event.type !== "session.created") { + await daemon.log({ ...envelope, event: "session_start" }); + } + await daemon.log(envelope); + }); } })().catch(() => {}); return async () => { abort.abort(); await task; + await pending; await daemon.close(); }; }, From 2dfc298b83ceb81957c78e47b7cfe458899cd72a Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Tue, 3 Nov 2026 19:35:01 +0800 Subject: [PATCH 04/21] Verify OpenCode 2 tracing across native versions --- .github/workflows/ci.yml | 31 + bt-daemon/src/managed_run/opencode.rs | 45 +- bt-daemon/src/translate/opencode.rs | 72 ++- bt-daemon/src/translate/opencode_v2.rs | 272 ++++++--- bt-daemon/src/translate/opencode_v2/event.rs | 370 ++++++++++++ bt-daemon/tests/agent_integration.rs | 546 +++++++++++++++++- bt-daemon/tests/opencode_translator.rs | 203 ++++++- src/plugins/opencode/content/package.json | 3 +- src/plugins/opencode/content/server.js | 2 + .../opencode/content/src/server.test.ts | 105 +++- src/plugins/opencode/content/src/server.ts | 48 +- src/plugins/opencode/validate.sh | 2 +- 12 files changed, 1576 insertions(+), 123 deletions(-) create mode 100644 bt-daemon/src/translate/opencode_v2/event.rs create mode 100644 src/plugins/opencode/content/server.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bebcf4b..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 @@ -433,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/managed_run/opencode.rs b/bt-daemon/src/managed_run/opencode.rs index 77208420..78476b66 100644 --- a/bt-daemon/src/managed_run/opencode.rs +++ b/bt-daemon/src/managed_run/opencode.rs @@ -55,15 +55,38 @@ impl ManagedRun for OpenCode { fn check(&self, args: &[OsString], _terminal: super::Terminal) -> anyhow::Result<()> { if is_v2() - && args - .iter() - .any(|arg| arg == "--server" || arg.to_string_lossy().starts_with("--server=")) + && 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"); } Ok(()) } + 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, @@ -124,4 +147,20 @@ mod tests { serde_json::json!(["other", "@braintrust/trace-opencode"]) ); } + + #[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/translate/opencode.rs b/bt-daemon/src/translate/opencode.rs index 204b9a83..de3028af 100644 --- a/bt-daemon/src/translate/opencode.rs +++ b/bt-daemon/src/translate/opencode.rs @@ -43,7 +43,7 @@ struct OpenCodeVersionedTranslator { impl AgentTranslator for OpenCodeVersionedTranslator { fn handle(&mut self, event: &Envelope, ctx: &SessionCtx) -> anyhow::Result> { - if self.version.is_none() && event.event == "session_start" { + if event.event == "session_start" { return Ok(Vec::new()); } let v2 = *self @@ -353,6 +353,8 @@ struct MessageInfo { #[serde(default)] summary: bool, #[serde(default)] + compaction: bool, + #[serde(default)] error: Option, } @@ -411,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>, @@ -575,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(), @@ -758,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") @@ -775,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")) @@ -805,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(), ); @@ -827,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}), ), @@ -1022,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})) @@ -1040,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"); @@ -1053,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 @@ -1079,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 index d2bc6f03..12317f88 100644 --- a/bt-daemon/src/translate/opencode_v2.rs +++ b/bt-daemon/src/translate/opencode_v2.rs @@ -1,20 +1,33 @@ -//! OpenCode 2 event translation. The v2 event stream has a different schema -//! from v1; this adapter keeps its correlation state separate while sharing the -//! existing span construction and history logic. +//! OpenCode 2 event translation. Native event DTOs are decoded before they +//! reach the reducer; open-ended tool values remain JSON. +mod event; +use self::event::*; use super::{opencode, recent::RecentSet, AgentTranslator, SessionCtx, SpanOp}; use crate::wire::Envelope; use serde_json::{json, Value}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; pub(super) struct OpenCodeV2Translator { spans: Box, - messages: HashMap<(String, String), (i64, Value)>, - pending_inputs: HashMap<(String, String), Value>, + messages: HashMap<(String, String), (i64, Option)>, + pending_inputs: HashMap<(String, String), Prompt>, tool_names: HashMap<(String, String), String>, + compactions: HashMap, + pending_context: HashMap, + message_context: HashMap<(String, String), Context>, + text_parts: HashMap<(String, String, bool), BTreeMap>, seen: RecentSet, } +struct ToolCompletion<'a> { + session_id: &'a str, + message_id: &'a str, + id: &'a str, + output: Value, + failed: bool, +} + impl OpenCodeV2Translator { pub(super) fn new(session_id: &str) -> Self { Self { @@ -22,6 +35,10 @@ impl OpenCodeV2Translator { messages: HashMap::new(), pending_inputs: HashMap::new(), tool_names: HashMap::new(), + compactions: HashMap::new(), + pending_context: HashMap::new(), + message_context: HashMap::new(), + text_parts: HashMap::new(), seen: RecentSet::default(), } } @@ -30,97 +47,148 @@ impl OpenCodeV2Translator { &mut self, original: &Envelope, ctx: &SessionCtx, + directory: Option<&str>, name: &str, mut payload: Value, ) -> anyhow::Result> { let mut event = original.clone(); event.event = name.to_string(); - if let (Some(directory), Some(object)) = ( - original.payload.pointer("/location/directory"), - payload.as_object_mut(), - ) { - object.insert("directory".into(), directory.clone()); + if let (Some(directory), Some(object)) = (directory, payload.as_object_mut()) { + object.insert("directory".into(), directory.into()); } event.payload = payload; self.spans.handle(&event, ctx) } + + fn tool_finished( + &mut self, + event: &Envelope, + ctx: &SessionCtx, + directory: Option<&str>, + completion: ToolCompletion<'_>, + ) -> anyhow::Result> { + let ToolCompletion { + session_id: sid, + message_id: mid, + id, + output, + failed, + } = completion; + let name = self + .tool_names + .remove(&(sid.into(), id.into())) + .unwrap_or_else(|| "tool".into()); + let state = if failed { + json!({"status":"error","error":output}) + } else { + json!({"status":"completed","output":output}) + }; + let mut ops = self.emit(event, ctx, directory, "message.part.updated", json!({"properties":{"part":{"sessionID":sid,"messageID":mid,"type":"tool","callID":id,"tool":name,"state":state}}}))?; + ops.extend(self.emit( + event, + ctx, + directory, + "tool.execute.after", + json!({"input":{"sessionID":sid,"callID":id,"tool":name},"result":{"output":output}}), + )?); + Ok(ops) + } } impl AgentTranslator for OpenCodeV2Translator { fn handle(&mut self, event: &Envelope, ctx: &SessionCtx) -> anyhow::Result> { - let Some(data) = event.payload.get("data") else { + // The event name is authoritative on the daemon envelope. The + // synthetic model-context payload has no native `type` field. + let mut payload = event.payload.clone(); + let Some(object) = payload.as_object_mut() else { return Ok(Vec::new()); }; - let Some(sid) = data.get("sessionID").and_then(Value::as_str) else { + object.insert("type".into(), event.event.clone().into()); + let Ok(native) = serde_json::from_value::(payload) else { return Ok(Vec::new()); }; - if let Some(id) = event.payload.get("id").and_then(Value::as_str) { - if !self.seen.insert(id.to_string()) { + if let Some(id) = &native.id { + if !self.seen.insert(id.clone()) { return Ok(Vec::new()); } } - let mid = data - .get("assistantMessageID") - .and_then(Value::as_str) - .unwrap_or_default(); - match event.event.as_str() { - "session.created" => self.emit(event, ctx, "session.created", json!({"properties":{"info":{"id":sid,"parentID":data.get("parentID"),"title":data.get("title")}}})), - "session.inbox.enqueued" if data.pointer("/item/type").and_then(Value::as_str) == Some("user") => { - if let Some(id) = data.get("inboxID").and_then(Value::as_str) { - self.pending_inputs.insert((sid.into(), id.into()), data.pointer("/item/payload").cloned().unwrap_or(Value::Null)); - } + let directory = native + .location + .as_ref() + .and_then(|location| location.directory.as_deref()); + match native.event { + NativeEvent::Created(data) => self.emit(event, ctx, directory, "session.created", json!({"properties":{"info":{"id":data.session_id,"parentID":data.parent_id,"title":data.title}}})), + NativeEvent::Enqueued(data) => { + if let InboxItem::User { payload } = data.item { self.pending_inputs.insert((data.session_id, data.inbox_id), payload); } Ok(Vec::new()) } - "session.inbox.delivered" => { - let Some(id) = data.get("inboxID").and_then(Value::as_str) else { return Ok(Vec::new()); }; - let Some(input) = self.pending_inputs.remove(&(sid.into(), id.into())) else { return Ok(Vec::new()); }; - let text = input.get("text").and_then(Value::as_str).unwrap_or_default(); - self.emit(event, ctx, "chat.message", json!({"input":{"sessionID":sid},"output":{"message":{"id":id},"parts":[{"type":"text","text":text}]}})) + NativeEvent::Delivered(data) => { + let Some(input) = self.pending_inputs.remove(&(data.session_id.clone(), data.inbox_id.clone())) else { return Ok(Vec::new()); }; + let mut parts = vec![json!({"type":"text","text":input.text})]; + for file in input.files { if file.mime.starts_with("image/") { parts.push(json!({"type":"image_url","image_url":{"url":format!("data:{};base64,{}",file.mime,file.data)}})); } } + self.emit(event, ctx, directory, "chat.message", json!({"input":{"sessionID":data.session_id,"turnID":data.inbox_id},"output":{"message":{"id":data.inbox_id},"parts":parts}})) } - "session.inbox.cancelled" => { - if let Some(id) = data.get("inboxID").and_then(Value::as_str) { self.pending_inputs.remove(&(sid.into(), id.into())); } - Ok(Vec::new()) + NativeEvent::Cancelled(data) => { self.pending_inputs.remove(&(data.session_id, data.inbox_id)); Ok(Vec::new()) } + NativeEvent::Context(data) => { let sid = data.session_id.clone(); self.pending_context.insert(sid, data); Ok(Vec::new()) } + NativeEvent::StepStarted(data) => { + let key = (data.session_id.clone(), data.message_id); + if let Some(context) = self.pending_context.remove(&data.session_id) { self.message_context.insert(key.clone(), context); } + self.messages.insert(key, (data.started.unwrap_or(event.ts_ms), data.model)); Ok(Vec::new()) } - "session.step.started" => { - self.messages.insert((sid.into(), mid.into()), (data.get("started").and_then(Value::as_i64).unwrap_or(event.ts_ms), data.get("model").cloned().unwrap_or(Value::Null))); - Ok(Vec::new()) + NativeEvent::ToolInputStarted(data) => { self.tool_names.insert((data.session_id, data.id), data.name); Ok(Vec::new()) } + NativeEvent::PermissionAsked(data) => { + let call = data.source.map(|source| source.id); + let name = call.as_ref().and_then(|id| self.tool_names.get(&(data.session_id.clone(), id.clone()))).cloned().unwrap_or_else(|| data.action.clone()); + self.emit(event, ctx, directory, "permission.asked", json!({"properties":{"id":data.id,"sessionID":data.session_id,"tool":{"callID":call,"name":name},"permissionType":data.action}})) } - "session.tool.input.started" => { - if let (Some(id), Some(name)) = (data.get("id").and_then(Value::as_str), data.get("name").and_then(Value::as_str)) { - self.tool_names.insert((sid.into(), id.into()), name.into()); - } - Ok(Vec::new()) - } - "session.tool.called" => { - let Some(id) = data.get("id").and_then(Value::as_str) else { return Ok(Vec::new()); }; - let name = self.tool_names.get(&(sid.into(), id.into())).cloned().unwrap_or_else(|| "tool".into()); - let input = data.get("input").cloned().unwrap_or(Value::Null); - let mut ops = self.emit(event, ctx, "message.part.updated", json!({"properties":{"part":{"sessionID":sid,"messageID":mid,"type":"tool","callID":id,"tool":name,"state":{"status":"running","input":input}}}}))?; - ops.extend(self.emit(event, ctx, "tool.execute.before", json!({"input":{"sessionID":sid,"callID":id,"tool":name},"output":{"args":input}}))?); + NativeEvent::PermissionReplied(data) => self.emit(event, ctx, directory, "permission.replied", json!({"properties":{"requestID":data.request_id,"sessionID":data.session_id,"reply":data.reply}})), + NativeEvent::CompactionStarted(data) => { self.compactions.insert(data.session_id, (event.ts_ms, data.input_id.unwrap_or_else(|| "compaction".into()))); Ok(Vec::new()) } + NativeEvent::CompactionEnded(data) => { + let (started, trigger) = self.compactions.remove(&data.session_id).unwrap_or((event.ts_ms, "compaction".into())); + if data.native.as_ref().and_then(|native| native.messages.as_ref()).is_some() { return Ok(Vec::new()); } + let summary = native.id.as_deref().unwrap_or("compaction-summary"); + let keep_id = native.metadata.and_then(|metadata| metadata.keep_id); + let mut ops = self.emit(event, ctx, directory, "message.part.updated", json!({"properties":{"part":{"sessionID":data.session_id,"messageID":trigger,"type":"compaction","tail_start_id":keep_id}}}))?; + ops.extend(self.emit(event, ctx, directory, "message.part.updated", json!({"properties":{"part":{"sessionID":data.session_id,"messageID":summary,"type":"text","text":data.text,"time":{"end":event.ts_ms}}}}))?); + ops.extend(self.emit(event, ctx, directory, "message.updated", json!({"properties":{"info":{"sessionID":data.session_id,"id":summary,"role":"assistant","summary":true,"providerID":data.model.as_ref().and_then(|m| m.provider_id.as_ref()),"modelID":data.model.as_ref().and_then(|m| m.id.as_ref()),"time":{"created":started,"completed":event.ts_ms},"tokens":data.tokens}}}))?); Ok(ops) } - "session.tool.success" | "session.tool.failed" => { - let Some(id) = data.get("id").and_then(Value::as_str) else { return Ok(Vec::new()); }; - let name = self.tool_names.remove(&(sid.into(), id.into())).unwrap_or_else(|| "tool".into()); - let failed = event.event == "session.tool.failed"; - let output = if failed { data.get("error") } else { data.get("content") }.cloned().unwrap_or(Value::Null); - let state = if failed { json!({"status":"error","error":output}) } else { json!({"status":"completed","output":output}) }; - let mut ops = self.emit(event, ctx, "message.part.updated", json!({"properties":{"part":{"sessionID":sid,"messageID":mid,"type":"tool","callID":id,"tool":name,"state":state}}}))?; - ops.extend(self.emit(event, ctx, "tool.execute.after", json!({"input":{"sessionID":sid,"callID":id,"tool":name},"result":{"output":output}}))?); + NativeEvent::CompactionFailed(data) => { + let (started, _) = self.compactions.remove(&data.session_id).unwrap_or((event.ts_ms, "compaction".into())); + let id = native.id.as_deref().unwrap_or("compaction-failed"); + self.emit(event, ctx, directory, "message.updated", json!({"properties":{"info":{"sessionID":data.session_id,"id":id,"role":"assistant","compaction":true,"time":{"created":started,"completed":event.ts_ms},"tokens":data.tokens,"error":data.error}}})) + } + NativeEvent::ToolCalled(data) => { + let name = self.tool_names.get(&(data.session_id.clone(), data.id.clone())).cloned().unwrap_or_else(|| "tool".into()); + let mut ops = self.emit(event, ctx, directory, "message.part.updated", json!({"properties":{"part":{"sessionID":data.session_id,"messageID":data.message_id,"type":"tool","callID":data.id,"tool":name,"state":{"status":"running","input":data.input}}}}))?; + ops.extend(self.emit(event, ctx, directory, "tool.execute.before", json!({"input":{"sessionID":data.session_id,"callID":data.id,"tool":name},"output":{"args":data.input}}))?); Ok(ops) } - "session.text.ended" | "session.reasoning.ended" => { - let kind = if event.event == "session.text.ended" { "text" } else { "reasoning" }; - self.emit(event, ctx, "message.part.updated", json!({"properties":{"part":{"sessionID":sid,"messageID":mid,"id":data.get("ordinal").map(Value::to_string),"type":kind,"text":data.get("text"),"time":{"end":event.ts_ms}}}})) + NativeEvent::ToolSuccess(data) => self.tool_finished(event, ctx, directory, ToolCompletion { session_id: &data.session_id, message_id: &data.message_id, id: &data.id, output: normalize_tool_content(data.content.unwrap_or_default()), failed: false }), + NativeEvent::ToolFailed(data) => self.tool_finished(event, ctx, directory, ToolCompletion { session_id: &data.session_id, message_id: &data.message_id, id: &data.id, output: json!(data.error), failed: true }), + NativeEvent::TextEnded(data) | NativeEvent::ReasoningEnded(data) => { + let reasoning = event.event == "session.reasoning.ended"; + let kind = if reasoning { "reasoning" } else { "text" }; + let parts = self.text_parts.entry((data.session_id.clone(), data.message_id.clone(), reasoning)).or_default(); + parts.insert(data.ordinal.unwrap_or(0), data.text); + let text = parts.values().map(String::as_str).collect::(); + self.emit(event, ctx, directory, "message.part.updated", json!({"properties":{"part":{"sessionID":data.session_id,"messageID":data.message_id,"type":kind,"text":text,"time":{"end":event.ts_ms}}}})) } - "session.step.ended" | "session.step.failed" => { - let (started, model) = self.messages.remove(&(sid.into(), mid.into())).unwrap_or((event.ts_ms, Value::Null)); - self.emit(event, ctx, "message.updated", json!({"properties":{"info":{"sessionID":sid,"id":mid,"role":"assistant","providerID":model.get("providerID"),"modelID":model.get("id"),"time":{"created":started,"completed":event.ts_ms},"tokens":data.get("tokens"),"error":data.get("error")}}})) + NativeEvent::StepEnded(data) | NativeEvent::StepFailed(data) => { + let key = (data.session_id.clone(), data.message_id.clone()); + self.text_parts.remove(&(data.session_id.clone(), data.message_id.clone(), false)); + self.text_parts.remove(&(data.session_id.clone(), data.message_id.clone(), true)); + let (started, model) = self.messages.remove(&key).unwrap_or((event.ts_ms, None)); + let mut ops = Vec::new(); + if let Some(context) = self.message_context.remove(&key) { ops.extend(self.emit(event, ctx, directory, "model.context", json!({"sessionID":data.session_id,"messages":normalize_context(context)}))?); } + ops.extend(self.emit(event, ctx, directory, "message.updated", json!({"properties":{"info":{"sessionID":data.session_id,"id":data.message_id,"role":"assistant","providerID":model.as_ref().and_then(|m| m.provider_id.as_ref()),"modelID":model.as_ref().and_then(|m| m.id.as_ref()),"time":{"created":started,"completed":event.ts_ms},"tokens":data.tokens,"error":data.error}}}))?); + Ok(ops) } - "session.execution.succeeded" => self.emit(event, ctx, "session.idle", json!({"properties":{"sessionID":sid}})), - "session.execution.failed" | "session.execution.interrupted" => self.emit(event, ctx, "session.error", json!({"properties":{"sessionID":sid,"error":data.get("error")}})), - "session.deleted" => self.emit(event, ctx, "session.deleted", json!({"properties":{"sessionID":sid}})), - _ => Ok(Vec::new()), + NativeEvent::Succeeded(data) => self.emit(event, ctx, directory, "session.idle", json!({"properties":{"sessionID":data.session_id}})), + NativeEvent::Failed(data) => self.emit(event, ctx, directory, "session.error", json!({"properties":{"sessionID":data.session_id,"error":data.error}})), + NativeEvent::Interrupted(data) => self.emit(event, ctx, directory, "session.error", json!({"properties":{"sessionID":data.session_id,"error":format!("OpenCode session interrupted ({})", data.reason.as_deref().unwrap_or("unknown"))}})), + NativeEvent::Deleted(data) => self.emit(event, ctx, directory, "session.deleted", json!({"properties":{"sessionID":data.session_id}})), + NativeEvent::Other => Ok(Vec::new()), } } fn checkpoint(&mut self, ctx: &SessionCtx) -> anyhow::Result> { @@ -130,3 +198,75 @@ impl AgentTranslator for OpenCodeV2Translator { self.spans.finalize(ctx) } } + +fn normalize_context(context: Context) -> Vec { + let mut messages = Vec::new(); + let system = context.system.join("\n\n"); + if !system.is_empty() { + messages.push(json!({"role":"system","content":system})); + } + for message in context.messages { + match message.content { + MessageContent::Text(text) => { + messages.push(json!({"role":message.role,"content":text})) + } + MessageContent::Other(content) => { + messages.push(json!({"role":message.role,"content":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(json!({"type":"text","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.as_ref().and_then(|source| source.media_type.as_deref()).or(media_type.as_deref()); + let bytes = source.as_ref().and_then(|source| source.data.as_deref()).or(data.as_deref()); + let image = mime.zip(bytes).filter(|(mime, _)| mime.starts_with("image/")); + if let Some((mime, bytes)) = image { content.push(json!({"type":"image_url","image_url":{"url":format!("data:{mime};base64,{bytes}")}})); } + else if let Ok(value) = serde_json::to_value(KnownContextPart::Media { media_type, data, media, extra }) { content.push(value); } + } + ContextPart::Known(KnownContextPart::ToolCall { id, name, input }) => calls.push(json!({"id":id,"type":"function","function":{"name":name,"arguments":input.to_string()}})), + ContextPart::Known(KnownContextPart::ToolResult { id, result }) => { + let value = match serde_json::from_value::>(result.value.clone()) { Ok(parts) => normalize_tool_content(parts), Err(_) => result.value }; + messages.push(json!({"role":"tool","tool_call_id":id,"content":value})); + } + ContextPart::Other(value) => content.push(value), + } + } + if message.role == "tool" { + continue; + } + let value = match content.as_slice() { + [] => json!(""), + [_] if single_text.is_some() => json!(single_text), + _ => Value::Array(content), + }; + if calls.is_empty() { + messages.push(json!({"role":message.role,"content":value})); + } else { + messages.push(json!({"role":message.role,"content":value,"tool_calls":calls})); + } + } + } + } + messages +} + +fn normalize_tool_content(parts: Vec) -> Value { + Value::Array( + parts + .into_iter() + .map(|part| match part { + ToolContent::Known(KnownToolContent::File { mime, uri, .. }) + if mime.starts_with("image/") => + { + json!({"type":"image_url","image_url":{"url":uri}}) + } + other => serde_json::to_value(other).unwrap_or(Value::Null), + }) + .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..5bdee0db --- /dev/null +++ b/bt-daemon/src/translate/opencode_v2/event.rs @@ -0,0 +1,370 @@ +//! 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, +} +#[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, +} +#[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, + 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(Deserialize, Serialize)] +#[serde(untagged)] +pub(super) enum ToolContent { + Known(KnownToolContent), + Other(Value), +} +#[derive(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, +} +#[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, +} +#[derive(Deserialize)] +pub(super) struct NativeCompaction { + pub messages: Option, +} +#[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(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(Deserialize, Serialize)] +pub(super) struct Media { + pub source: Option, + #[serde(flatten)] + pub extra: serde_json::Map, +} +#[derive(Deserialize, Serialize)] +pub(super) struct MediaSource { + #[serde(rename = "mediaType")] + pub media_type: Option, + pub data: Option, + #[serde(flatten)] + pub extra: serde_json::Map, +} +#[derive(Deserialize, Serialize)] +pub(super) struct ToolResult { + pub value: Value, +} diff --git a/bt-daemon/tests/agent_integration.rs b/bt-daemon/tests/agent_integration.rs index b2d46024..0f58a021 100644 --- a/bt-daemon/tests/agent_integration.rs +++ b/bt-daemon/tests/agent_integration.rs @@ -523,16 +523,33 @@ fn request_helpers_recognize_tool_results_and_advertised_tools() { #[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| { 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":tool_command("OPENCODE_V2_TOOL_OK"),"description":"Print a marker"}), + json!({"command":command,"description":"Run a test command"}), )) } else { MockReply::response(OpenAiTurn::text("OPENCODE_V2_OK")) @@ -541,6 +558,14 @@ async fn opencode_v2_session_emits_traces() { let inference_server = TestServer::start(inference.router()).await; let world = AgentTestWorld::start().await; let workspace = world.workspace(); + let mut version_command = support::agent_process::command( + std::env::var_os("OPENCODE_V2_BIN").expect("set OPENCODE_V2_BIN"), + ); + version_command.arg("--version"); + let version = world.output(&mut version_command).await; + let legacy_v2 = String::from_utf8_lossy(&version.stdout).contains("v2.0."); + 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!({ @@ -563,6 +588,8 @@ async fn opencode_v2_session_emits_traces() { "--auto", "--model", "mock/mock-model", + "--file", + "image.png", "say hello", ]) .current_dir(&workspace) @@ -587,9 +614,16 @@ async fn opencode_v2_session_emits_traces() { "{}", 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"]) + 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"]) @@ -597,7 +631,27 @@ async fn opencode_v2_session_emits_traces() { .expect("v2 LLM", |row| { row_contains(row, &[r#""type":"llm""#, "OPENCODE_V2_OK"]) }); - world.wait_for_mock_ingest_scenario(&scenario).await; + 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!( + 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"] + ); // Exercise the persistent plugin configuration separately from managed runs. let mut persistent_config = config.clone(); @@ -622,8 +676,8 @@ async fn opencode_v2_session_emits_traces() { support::agent_process::command(std::env::var_os("OPENCODE_V2_BIN").unwrap()); persistent .args([ - "--standalone", "run", + "--standalone", "--auto", "--model", "mock/mock-model", @@ -655,4 +709,488 @@ async fn opencode_v2_session_emits_traces() { 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) + .env("PWD", &workspace) + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("XDG_CACHE_HOME", home.join("cache")); + 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) + .env("PWD", &workspace) + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("XDG_CACHE_HOME", home.join("cache")); + 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(); + // OpenCode 2.0 exposes compaction through its HTTP API rather than the + // in-process session API used by this fixture. + if !legacy_v2 { + std::fs::write(plugins.join("compact.js"), r#"export default { id: 'test.compact', async setup(ctx) { const c = new AbortController(); let queued = false; void (async () => { for await (const event of ctx.event.subscribe({signal:c.signal})) { if (event.type === 'session.inbox.delivered' && !queued) { queued = true; await ctx.session.compact({sessionID:event.data.sessionID,delivery:'queue'}); } } })().catch(() => {}); await ctx.session.hook('compaction', event => { event.result = {summary:'COMPACTION_SUMMARY'}; }); return () => c.abort(); } };"#).unwrap(); + let mut compacted = + support::agent_process::command(std::env::var_os("OPENCODE_V2_BIN").unwrap()); + compacted + .args([ + "run", + "--standalone", + "--auto", + "--model", + "mock/mock-model", + "compaction marker", + ]) + .current_dir(&workspace) + .env("PWD", &workspace) + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("XDG_CACHE_HOME", home.join("cache")); + world.configure(&mut compacted); + let output = world.output(&mut compacted).await; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let compaction_rows = world + .wait_for_mock_ingest_scenario( + &IngestScenario::new() + .expect("compaction turn", |row| { + row_contains(row, &["Turn 1", "compaction marker"]) + }) + .expect("compaction summary", |row| { + row_contains(row, &[r#""type":"llm""#, "COMPACTION_SUMMARY"]) + }), + ) + .await; + let turn = compaction_rows + .iter() + .find(|row| row_contains(row, &["Turn 1", "compaction marker"])) + .unwrap(); + let root_id = &turn["root_span_id"]; + let root = compaction_rows + .iter() + .find(|row| &row["span_id"] == root_id) + .unwrap(); + let session_id = root["metadata"]["session_id"].as_str().unwrap(); + std::fs::remove_file(plugins.join("compact.js")).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", + session_id, + "after compaction", + ]) + .current_dir(&workspace) + .env("PWD", &workspace) + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("XDG_CACHE_HOME", home.join("cache")); + world.configure(&mut resumed); + let output = world.output(&mut resumed).await; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let rows = world + .wait_for_mock_ingest_scenario(&IngestScenario::new().expect( + "post-compaction model input", + |row| { + row["span_attributes"]["type"] == "llm" + && row["input"].to_string().contains("COMPACTION_SUMMARY") + && row["input"].to_string().contains("after compaction") + }, + )) + .await; + let resumed_llm = rows + .iter() + .find(|row| { + row["span_attributes"]["type"] == "llm" + && row["input"].to_string().contains("after compaction") + }) + .unwrap(); + assert!(!resumed_llm["input"] + .to_string() + .contains("compaction 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::command(std::env::var_os("OPENCODE_V2_BIN").unwrap()); + image_run + .args([ + "run", + "--standalone", + "--auto", + "--model", + "mock/mock-model", + "image tool marker", + ]) + .current_dir(&workspace) + .env("PWD", &workspace) + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("XDG_CACHE_HOME", home.join("cache")); + 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 tool_result = messages.is_some_and(|ms| ms.iter().any(|m| m["role"] == "tool")); + if user.contains("CHILD_MARKER") { + MockReply::response(OpenAiTurn::text("CHILD_DONE")) + } else if request.body["tools"].is_array() && !tool_result { + MockReply::response(OpenAiTurn::tool_call( + "subagent_call", + "subagent", + json!({"agent":"general","description":"Test child","prompt":"CHILD_MARKER"}), + )) + } 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) + .env("PWD", &workspace) + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("XDG_CACHE_HOME", home.join("cache")) + .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:?}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "requires OpenCode 2 and a built OpenCode package"] +async fn opencode_v2_legacy_compaction_after_idle() { + let binary = std::env::var_os("OPENCODE_V2_BIN").expect("set OPENCODE_V2_BIN"); + let mut version = support::agent_process::command(&binary); + version.arg("--version"); + let version = version.output().await.unwrap(); + if !String::from_utf8_lossy(&version.stdout).contains("v2.0.") { + return; + } + 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(); + std::fs::write(plugins.join("compact.js"), "export default {id:'test.compact',async setup(ctx){await ctx.session.hook('compaction',e=>{e.result={summary:'LEGACY_COMPACTION_SUMMARY'}})}};").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) + .env("PWD", &workspace) + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("XDG_CACHE_HOME", home.join("cache")) + .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.0 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) + .env("PWD", &workspace) + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("XDG_CACHE_HOME", home.join("cache")) + .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"); + world + .wait_for_mock_ingest_scenario(&IngestScenario::new().expect("initial turn", |row| { + row_contains(row, &["Turn 1", "legacy compaction"]) + })) + .await; + client + .post(format!("{url}/api/session/{session_id}/compact")) + .basic_auth("opencode", Some("integration-test-password")) + .json(&json!({"delivery":"queue"})) + .send() + .await + .unwrap() + .error_for_status() + .unwrap(); + world + .wait_for_mock_ingest_scenario(&IngestScenario::new().expect("idle compaction", |row| { + row_contains(row, &[r#""type":"llm""#, "LEGACY_COMPACTION_SUMMARY"]) + })) + .await; + 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) + .env("PWD", &workspace) + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("XDG_CACHE_HOME", home.join("cache")) + .env("OPENCODE_PASSWORD", "integration-test-password"); + let output = world.output(&mut resume).await; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + world + .wait_for_mock_ingest_scenario(&IngestScenario::new().expect("resumed context", |row| { + row_contains( + row, + &[ + r#""type":"llm""#, + "LEGACY_COMPACTION_SUMMARY", + "legacy after compaction", + ], + ) + })) + .await; + server.kill().await.unwrap(); + server.wait().await.unwrap(); } diff --git a/bt-daemon/tests/opencode_translator.rs b/bt-daemon/tests/opencode_translator.rs index 88bd6e57..fd352be3 100644 --- a/bt-daemon/tests/opencode_translator.rs +++ b/bt-daemon/tests/opencode_translator.rs @@ -1003,13 +1003,18 @@ fn opencode_v2_reduces_native_text_session_without_session_created() { json!({"sessionID":"native", "assistantMessageID":"assistant-1", "ordinal":0, "text":"world"}), ), ( - "session.step.ended", + "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", - 6, + 7, json!({"sessionID":"native"}), ), ]; @@ -1034,7 +1039,7 @@ fn opencode_v2_reduces_native_text_session_without_session_created() { assert_eq!(llm.name, "mock/mock-model"); assert_eq!( llm.output, - Some(json!([{"role":"assistant","content":"world"}])) + 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()]); @@ -1092,3 +1097,195 @@ fn opencode_v2_emits_tool_span_from_native_events() { ); 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 mut e = event( + name, + i as i64 + 1, + json!({"id":format!("event-{i}"),"type":name,"data":data}), + ); + e.source_version = Some("2.2.5".into()); + 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"}]) + ) + ); +} diff --git a/src/plugins/opencode/content/package.json b/src/plugins/opencode/content/package.json index 79e8b6ad..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" ], @@ -50,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 index 1b47bd49..b320806b 100644 --- a/src/plugins/opencode/content/src/server.test.ts +++ b/src/plugins/opencode/content/src/server.test.ts @@ -27,34 +27,40 @@ vi.mock("./tools/bt-cli", () => ({ import plugin from "./server"; describe("OpenCode 2 capture", () => { - it("registers executable Braintrust tools", async () => { - const definitions: Array<{ - name: string; - input: object; - execute(input: unknown): Promise<{ content: string }>; - }> = []; - const cleanup = await plugin.setup({ - app: { version: "2.2.5" }, - location: { directory: "/test" }, - event: { async *subscribe() {} }, - tool: { - async transform(callback) { - callback({ - add(definition) { - definitions.push(definition); - }, - }); + 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() {} }, + 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?.(); - }); + }); + 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; @@ -64,6 +70,7 @@ describe("OpenCode 2 capture", () => { ]; const cleanup = await plugin.setup({ app: { version: "2.2.5" }, + session: { async hook() {} }, location: { directory: "/test" }, tool: { async transform(callback) { @@ -90,4 +97,48 @@ describe("OpenCode 2 capture", () => { }); 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 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 cleanup?.(); + 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" } }, + }); + }); }); diff --git a/src/plugins/opencode/content/src/server.ts b/src/plugins/opencode/content/src/server.ts index a7e01a0d..7d17ed6c 100644 --- a/src/plugins/opencode/content/src/server.ts +++ b/src/plugins/opencode/content/src/server.ts @@ -18,6 +18,16 @@ interface Context { app: { version: string }; location: { directory: string }; event: { subscribe(options: { signal: AbortSignal }): AsyncIterable }; + session: { + hook( + name: "context", + callback: (event: { + sessionID: string; + system: Array<{ text: string }>; + messages: unknown[]; + }) => void, + ): Promise; + }; tool: { transform( callback: (editor: { @@ -50,6 +60,11 @@ const forwarded = new Set([ "session.tool.called", "session.tool.success", "session.tool.failed", + "permission.asked", + "permission.replied", + "session.compaction.started", + "session.compaction.ended", + "session.compaction.failed", "session.deleted", ]); @@ -57,7 +72,7 @@ export default { id: "braintrust.trace", async setup(ctx: Context) { const config = loadConfig(await readPluginConfig(ctx.location)); - if (config.enableTools && !process.env.BT_TRACE_MANAGED_RUN_ID) { + if (config.enableTools) { const definitions = createBraintrustTools(new BtCliToolsClient(config)); await ctx.tool.transform((editor) => { for (const [name, definition] of Object.entries(definitions)) { @@ -81,7 +96,27 @@ export default { const abort = new AbortController(); const roots = new Map(); const started = new Set(); + const contexts = new Map>(); let pending = Promise.resolve(); + 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; @@ -105,10 +140,21 @@ export default { 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); pending = pending.then(async () => { if (first && event.type !== "session.created") { await daemon.log({ ...envelope, event: "session_start" }); } + if (context) await daemon.log({ ...envelope, event: "model.context", ...context }); await daemon.log(envelope); }); } 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) { From 6812a562e99fc53d300a497b2f32b750772125cc Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Tue, 3 Nov 2026 19:45:11 +0800 Subject: [PATCH 05/21] Keep OpenCode running through compaction test --- bt-daemon/tests/agent_integration.rs | 148 ++++++--------------------- 1 file changed, 31 insertions(+), 117 deletions(-) diff --git a/bt-daemon/tests/agent_integration.rs b/bt-daemon/tests/agent_integration.rs index 0f58a021..392b5870 100644 --- a/bt-daemon/tests/agent_integration.rs +++ b/bt-daemon/tests/agent_integration.rs @@ -558,12 +558,6 @@ async fn opencode_v2_session_emits_traces() { let inference_server = TestServer::start(inference.router()).await; let world = AgentTestWorld::start().await; let workspace = world.workspace(); - let mut version_command = support::agent_process::command( - std::env::var_os("OPENCODE_V2_BIN").expect("set OPENCODE_V2_BIN"), - ); - version_command.arg("--version"); - let version = world.output(&mut version_command).await; - let legacy_v2 = String::from_utf8_lossy(&version.stdout).contains("v2.0."); 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"); @@ -793,106 +787,6 @@ async fn opencode_v2_session_emits_traces() { .await; std::fs::remove_file(plugins.join("interrupt.js")).unwrap(); - // OpenCode 2.0 exposes compaction through its HTTP API rather than the - // in-process session API used by this fixture. - if !legacy_v2 { - std::fs::write(plugins.join("compact.js"), r#"export default { id: 'test.compact', async setup(ctx) { const c = new AbortController(); let queued = false; void (async () => { for await (const event of ctx.event.subscribe({signal:c.signal})) { if (event.type === 'session.inbox.delivered' && !queued) { queued = true; await ctx.session.compact({sessionID:event.data.sessionID,delivery:'queue'}); } } })().catch(() => {}); await ctx.session.hook('compaction', event => { event.result = {summary:'COMPACTION_SUMMARY'}; }); return () => c.abort(); } };"#).unwrap(); - let mut compacted = - support::agent_process::command(std::env::var_os("OPENCODE_V2_BIN").unwrap()); - compacted - .args([ - "run", - "--standalone", - "--auto", - "--model", - "mock/mock-model", - "compaction marker", - ]) - .current_dir(&workspace) - .env("PWD", &workspace) - .env("HOME", &home) - .env("USERPROFILE", &home) - .env("XDG_CONFIG_HOME", home.join("config")) - .env("XDG_DATA_HOME", home.join("data")) - .env("XDG_CACHE_HOME", home.join("cache")); - world.configure(&mut compacted); - let output = world.output(&mut compacted).await; - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let compaction_rows = world - .wait_for_mock_ingest_scenario( - &IngestScenario::new() - .expect("compaction turn", |row| { - row_contains(row, &["Turn 1", "compaction marker"]) - }) - .expect("compaction summary", |row| { - row_contains(row, &[r#""type":"llm""#, "COMPACTION_SUMMARY"]) - }), - ) - .await; - let turn = compaction_rows - .iter() - .find(|row| row_contains(row, &["Turn 1", "compaction marker"])) - .unwrap(); - let root_id = &turn["root_span_id"]; - let root = compaction_rows - .iter() - .find(|row| &row["span_id"] == root_id) - .unwrap(); - let session_id = root["metadata"]["session_id"].as_str().unwrap(); - std::fs::remove_file(plugins.join("compact.js")).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", - session_id, - "after compaction", - ]) - .current_dir(&workspace) - .env("PWD", &workspace) - .env("HOME", &home) - .env("USERPROFILE", &home) - .env("XDG_CONFIG_HOME", home.join("config")) - .env("XDG_DATA_HOME", home.join("data")) - .env("XDG_CACHE_HOME", home.join("cache")); - world.configure(&mut resumed); - let output = world.output(&mut resumed).await; - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let rows = world - .wait_for_mock_ingest_scenario(&IngestScenario::new().expect( - "post-compaction model input", - |row| { - row["span_attributes"]["type"] == "llm" - && row["input"].to_string().contains("COMPACTION_SUMMARY") - && row["input"].to_string().contains("after compaction") - }, - )) - .await; - let resumed_llm = rows - .iter() - .find(|row| { - row["span_attributes"]["type"] == "llm" - && row["input"].to_string().contains("after compaction") - }) - .unwrap(); - assert!(!resumed_llm["input"] - .to_string() - .contains("compaction 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::command(std::env::var_os("OPENCODE_V2_BIN").unwrap()); @@ -1035,14 +929,8 @@ async fn opencode_v2_subagent_is_nested() { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[ignore = "requires OpenCode 2 and a built OpenCode package"] -async fn opencode_v2_legacy_compaction_after_idle() { +async fn opencode_v2_compaction_after_idle() { let binary = std::env::var_os("OPENCODE_V2_BIN").expect("set OPENCODE_V2_BIN"); - let mut version = support::agent_process::command(&binary); - version.arg("--version"); - let version = version.output().await.unwrap(); - if !String::from_utf8_lossy(&version.stdout).contains("v2.0.") { - return; - } let inference = OpenAiMock::new(|_, _| MockReply::response(OpenAiTurn::text("LEGACY_TEXT"))); let inference_server = TestServer::start(inference.router()).await; let world = AgentTestWorld::start().await; @@ -1100,7 +988,7 @@ async fn opencode_v2_legacy_compaction_after_idle() { } tokio::time::sleep(std::time::Duration::from_millis(100)).await; } - assert!(ready, "OpenCode 2.0 server did not start"); + assert!(ready, "OpenCode 2 server did not start"); let mut run = support::agent_process::command(&binary); run.args([ "run", @@ -1133,11 +1021,16 @@ async fn opencode_v2_legacy_compaction_after_idle() { .filter_map(|line| serde_json::from_str::(line).ok()) .find_map(|event| event["sessionID"].as_str().map(str::to_owned)) .expect("session ID"); - world + 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(); client .post(format!("{url}/api/session/{session_id}/compact")) .basic_auth("opencode", Some("integration-test-password")) @@ -1147,11 +1040,15 @@ async fn opencode_v2_legacy_compaction_after_idle() { .unwrap() .error_for_status() .unwrap(); - world + let compacted_rows = world .wait_for_mock_ingest_scenario(&IngestScenario::new().expect("idle compaction", |row| { row_contains(row, &[r#""type":"llm""#, "LEGACY_COMPACTION_SUMMARY"]) })) .await; + assert!(compacted_rows.iter().any(|row| row_contains( + row, + &[r#""type":"llm""#, "LEGACY_COMPACTION_SUMMARY"] + ) && row["root_span_id"] == root_id)); let mut resume = support::agent_process::command(&binary); resume .args([ @@ -1179,7 +1076,7 @@ async fn opencode_v2_legacy_compaction_after_idle() { "{}", String::from_utf8_lossy(&output.stderr) ); - world + let resumed_rows = world .wait_for_mock_ingest_scenario(&IngestScenario::new().expect("resumed context", |row| { row_contains( row, @@ -1191,6 +1088,23 @@ async fn opencode_v2_legacy_compaction_after_idle() { ) })) .await; + let resumed_llm = resumed_rows + .iter() + .find(|row| { + row_contains( + row, + &[ + r#""type":"llm""#, + "LEGACY_COMPACTION_SUMMARY", + "legacy after compaction", + ], + ) + }) + .unwrap(); + assert_eq!(resumed_llm["root_span_id"], root_id); + assert!(!resumed_llm["input"] + .to_string() + .contains("legacy compaction")); server.kill().await.unwrap(); server.wait().await.unwrap(); } From e9c4301fe3d69cde6299a97d13db131f2c0b96d3 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Tue, 3 Nov 2026 21:28:13 +0800 Subject: [PATCH 06/21] Exercise image tracing with managed run finalization --- bt-daemon/tests/agent_integration.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/bt-daemon/tests/agent_integration.rs b/bt-daemon/tests/agent_integration.rs index 392b5870..5b655a76 100644 --- a/bt-daemon/tests/agent_integration.rs +++ b/bt-daemon/tests/agent_integration.rs @@ -788,12 +788,14 @@ async fn opencode_v2_session_emits_traces() { std::fs::remove_file(plugins.join("interrupt.js")).unwrap(); 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::command(std::env::var_os("OPENCODE_V2_BIN").unwrap()); + let mut image_run = support::agent_process::daemon_command(); image_run .args([ "run", - "--standalone", + "--project", + "agent-e2e", + "opencode", + "run", "--auto", "--model", "mock/mock-model", @@ -805,7 +807,12 @@ async fn opencode_v2_session_emits_traces() { .env("USERPROFILE", &home) .env("XDG_CONFIG_HOME", home.join("config")) .env("XDG_DATA_HOME", home.join("data")) - .env("XDG_CACHE_HOME", home.join("cache")); + .env("XDG_CACHE_HOME", home.join("cache")) + .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!( From b4fd22136e8cf23ebc68edec574c60f2940ced78 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Tue, 3 Nov 2026 21:41:11 +0800 Subject: [PATCH 07/21] Report the agent command on integration timeouts --- bt-daemon/tests/support/agent_process.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/bt-daemon/tests/support/agent_process.rs b/bt-daemon/tests/support/agent_process.rs index e20f7393..d91b829d 100644 --- a/bt-daemon/tests/support/agent_process.rs +++ b/bt-daemon/tests/support/agent_process.rs @@ -194,6 +194,16 @@ impl AgentTestWorld { } pub async fn output(&self, command: &mut Command) -> std::process::Output { + 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(); command .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -201,7 +211,13 @@ impl AgentTestWorld { 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") + .unwrap_or_else(|_| { + panic!( + "agent command timed out: {program} {args:?}; {}; daemon files:\n{}", + self.collector.diagnostics(), + directory_contents(&self.data_dir) + ) + }) .expect("wait for agent command") } From 0829ffea374dbfe8f294cfa00d041da6da1250a8 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Tue, 3 Nov 2026 22:36:05 +0800 Subject: [PATCH 08/21] Avoid waiting on inherited agent output pipes --- bt-daemon/tests/agent_integration.rs | 16 +++++++++++ bt-daemon/tests/support/agent_process.rs | 35 ++++++++++++++++++++---- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/bt-daemon/tests/agent_integration.rs b/bt-daemon/tests/agent_integration.rs index 5b655a76..26aac854 100644 --- a/bt-daemon/tests/agent_integration.rs +++ b/bt-daemon/tests/agent_integration.rs @@ -53,6 +53,22 @@ fn row_contains(row: &Value, fragments: &[&str]) -> bool { .all(|fragment| serialized.contains(fragment)) } +#[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() { diff --git a/bt-daemon/tests/support/agent_process.rs b/bt-daemon/tests/support/agent_process.rs index d91b829d..47f4077a 100644 --- a/bt-daemon/tests/support/agent_process.rs +++ b/bt-daemon/tests/support/agent_process.rs @@ -194,6 +194,7 @@ 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() @@ -204,12 +205,21 @@ impl AgentTestWorld { .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()) + let mut child = command.spawn().expect("spawn agent command"); + let status = tokio::time::timeout(Duration::from_secs(90), child.wait()) .await .unwrap_or_else(|_| { panic!( @@ -218,7 +228,22 @@ impl AgentTestWorld { directory_contents(&self.data_dir) ) }) - .expect("wait for agent command") + .expect("wait for agent command"); + 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 { From 9d6c20a8e939aa96eb8e237f84e7a5415ad24c85 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Tue, 3 Nov 2026 22:51:08 +0800 Subject: [PATCH 09/21] Capture process state for agent test timeouts --- bt-daemon/tests/support/agent_process.rs | 35 +++++++++++++++++++----- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/bt-daemon/tests/support/agent_process.rs b/bt-daemon/tests/support/agent_process.rs index 47f4077a..6e122de4 100644 --- a/bt-daemon/tests/support/agent_process.rs +++ b/bt-daemon/tests/support/agent_process.rs @@ -219,16 +219,37 @@ impl AgentTestWorld { )) .kill_on_drop(true); let mut child = command.spawn().expect("spawn agent command"); - let status = tokio::time::timeout(Duration::from_secs(90), child.wait()) - .await - .unwrap_or_else(|_| { + 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:?}; {}; daemon files:\n{}", + "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) - ) - }) - .expect("wait for agent command"); + ); + } + }; let mut stdout_bytes = Vec::new(); let mut stderr_bytes = Vec::new(); stdout.rewind().expect("rewind stdout capture"); From 0eb1e4d55a44aca8b5aa2fc6619dc03f587931aa Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 4 Nov 2026 00:37:35 +0800 Subject: [PATCH 10/21] Bound managed run flush RPC duration --- bt-daemon/src/client.rs | 44 ++++++++++++++++++++----------------- bt-daemon/tests/pipeline.rs | 27 +++++++++++++++++++++++ 2 files changed, 51 insertions(+), 20 deletions(-) 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/tests/pipeline.rs b/bt-daemon/tests/pipeline.rs index e6ee0146..9a27bce3 100644 --- a/bt-daemon/tests/pipeline.rs +++ b/bt-daemon/tests/pipeline.rs @@ -2620,6 +2620,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; From 19f8d02a9bc35d4e190da8956dddbd8a726a9347 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 4 Nov 2026 00:48:58 +0800 Subject: [PATCH 11/21] Capture daemon diagnostics without blocking the process --- bt-daemon/tests/support/agent_process.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bt-daemon/tests/support/agent_process.rs b/bt-daemon/tests/support/agent_process.rs index 6e122de4..d80391de 100644 --- a/bt-daemon/tests/support/agent_process.rs +++ b/bt-daemon/tests/support/agent_process.rs @@ -71,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!({ @@ -114,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 From 33f631138ba3c01f3fc467ea77b8dab326a375b5 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 4 Nov 2026 07:23:10 +0800 Subject: [PATCH 12/21] Translate OpenCode 2 events independently --- bt-daemon/src/translate/opencode_v2.rs | 920 ++++++++++++++++--- bt-daemon/src/translate/opencode_v2/event.rs | 7 + bt-daemon/tests/opencode_translator.rs | 238 ++++- 3 files changed, 1026 insertions(+), 139 deletions(-) diff --git a/bt-daemon/src/translate/opencode_v2.rs b/bt-daemon/src/translate/opencode_v2.rs index 12317f88..cbbe6e19 100644 --- a/bt-daemon/src/translate/opencode_v2.rs +++ b/bt-daemon/src/translate/opencode_v2.rs @@ -1,204 +1,850 @@ -//! OpenCode 2 event translation. Native event DTOs are decoded before they -//! reach the reducer; open-ended tool values remain JSON. +//! OpenCode 2 native event translation. mod event; use self::event::*; -use super::{opencode, recent::RecentSet, AgentTranslator, SessionCtx, SpanOp}; -use crate::wire::Envelope; +use super::{ + git::GitMetadataCache, + local_username, + recent::RecentSet, + tool::{with_tool_approval, ToolApproval}, + AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, +}; +use crate::{ids, wire::Envelope}; use serde_json::{json, Value}; -use std::collections::{BTreeMap, HashMap}; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + sync::Arc, +}; + +#[derive(Default)] +struct SessionState { + root: String, + trace_root: String, + parents: Vec, + parent_session: Option, + owner_turn: Option, + turn: Option, + turn_number: u32, + tool_count: u32, + idle: bool, + output: Option, + history: Vec, + calls: HashMap>, + results: HashMap>, + tools: HashMap, + denied_tools: HashSet, + 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, + action: String, +} +struct HistoryEntry { + id: String, + messages: Vec, +} +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, +} pub(super) struct OpenCodeV2Translator { - spans: Box, - messages: HashMap<(String, String), (i64, Option)>, + daemon_session_id: String, + sessions: HashMap, pending_inputs: HashMap<(String, String), Prompt>, tool_names: HashMap<(String, String), String>, - compactions: HashMap, + steps: HashMap<(String, String), Step>, pending_context: HashMap, - message_context: HashMap<(String, String), Context>, text_parts: HashMap<(String, String, bool), BTreeMap>, + permissions: HashMap, + compactions: HashMap, seen: RecentSet, + last_ts_ms: i64, + git: Arc, } - -struct ToolCompletion<'a> { - session_id: &'a str, - message_id: &'a str, - id: &'a str, - output: Value, - failed: bool, -} - impl OpenCodeV2Translator { pub(super) fn new(session_id: &str) -> Self { Self { - spans: opencode::new_translator(session_id), - messages: HashMap::new(), + daemon_session_id: session_id.into(), + sessions: HashMap::new(), pending_inputs: HashMap::new(), tool_names: HashMap::new(), - compactions: HashMap::new(), + steps: HashMap::new(), pending_context: HashMap::new(), - message_context: HashMap::new(), text_parts: HashMap::new(), + permissions: HashMap::new(), + compactions: HashMap::new(), seen: RecentSet::default(), + last_ts_ms: 0, + git: GitMetadataCache::shared(), } } - - fn emit( + fn open( &mut self, - original: &Envelope, - ctx: &SessionCtx, - directory: Option<&str>, - name: &str, - mut payload: Value, - ) -> anyhow::Result> { - let mut event = original.clone(); - event.event = name.to_string(); - if let (Some(directory), Some(object)) = (directory, payload.as_object_mut()) { - object.insert("directory".into(), directory.into()); + 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 mut metadata = json!({"session_id":sid,"source":"opencode","_bt_defer_root":true,"username":local_username()}); + if let Some(parent_id) = parent_id { + metadata["parent_session_id"] = json!(parent_id); + metadata["is_subagent"] = json!(true); } - event.payload = payload; - self.spans.handle(&event, ctx) + self.sessions.insert( + sid.into(), + SessionState { + root: root.clone(), + trace_root: trace_root.clone(), + parents: parents.clone(), + parent_session: parent_id.map(str::to_owned), + 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(metadata), + ..Default::default() + })] } - - fn tool_finished( - &mut self, - event: &Envelope, - ctx: &SessionCtx, - directory: Option<&str>, - completion: ToolCompletion<'_>, - ) -> anyhow::Result> { - let ToolCompletion { - session_id: sid, - message_id: mid, - id, - output, - failed, - } = completion; - let name = self - .tool_names - .remove(&(sid.into(), id.into())) - .unwrap_or_else(|| "tool".into()); - let state = if failed { - json!({"status":"error","error":output}) + 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![json!({"type":"text","text":prompt.text})]; + for file in prompt.files { + if file.mime.starts_with("image/") { + content.push(json!({"type":"image_url","image_url":{"url":format!("data:{};base64,{}",file.mime,file.data)}})); + } + } + let input = if content.len() == 1 { + json!(prompt.text) } else { - json!({"status":"completed","output":output}) + json!(content) }; - let mut ops = self.emit(event, ctx, directory, "message.part.updated", json!({"properties":{"part":{"sessionID":sid,"messageID":mid,"type":"tool","callID":id,"tool":name,"state":state}}}))?; - ops.extend(self.emit( - event, - ctx, - directory, - "tool.execute.after", - json!({"input":{"sessionID":sid,"callID":id,"tool":name},"result":{"output":output}}), - )?); - Ok(ops) + let has_input = !prompt.text.is_empty() || input.is_array(); + s.history.push(HistoryEntry { + id: id.into(), + messages: has_input + .then(|| json!({"role":"user","content":input})) + .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()); + 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_some(input), + metadata: Some( + json!({"turn_number":s.turn_number,"model":null,"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(json!({"total_turns":s.turn_number,"total_tool_calls":s.tool_count})), + error, + ..Default::default() + })); + if !permanent && s.parent_session.is_none() { + s.idle = true; + self.sessions.insert(sid.into(), s); + } + ops } } - impl AgentTranslator for OpenCodeV2Translator { - fn handle(&mut self, event: &Envelope, ctx: &SessionCtx) -> anyhow::Result> { - // The event name is authoritative on the daemon envelope. The - // synthetic model-context payload has no native `type` field. + 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::new()); + return Ok(vec![]); }; object.insert("type".into(), event.event.clone().into()); let Ok(native) = serde_json::from_value::(payload) else { - return Ok(Vec::new()); + return Ok(vec![]); }; if let Some(id) = &native.id { if !self.seen.insert(id.clone()) { - return Ok(Vec::new()); + return Ok(vec![]); } } let directory = native .location .as_ref() .and_then(|location| location.directory.as_deref()); - match native.event { - NativeEvent::Created(data) => self.emit(event, ctx, directory, "session.created", json!({"properties":{"info":{"id":data.session_id,"parentID":data.parent_id,"title":data.title}}})), + 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); } - Ok(Vec::new()) + if let InboxItem::User { payload } = data.item { + self.pending_inputs + .insert((data.session_id, data.inbox_id), payload); + } + vec![] } NativeEvent::Delivered(data) => { - let Some(input) = self.pending_inputs.remove(&(data.session_id.clone(), data.inbox_id.clone())) else { return Ok(Vec::new()); }; - let mut parts = vec![json!({"type":"text","text":input.text})]; - for file in input.files { if file.mime.starts_with("image/") { parts.push(json!({"type":"image_url","image_url":{"url":format!("data:{};base64,{}",file.mime,file.data)}})); } } - self.emit(event, ctx, directory, "chat.message", json!({"input":{"sessionID":data.session_id,"turnID":data.inbox_id},"output":{"message":{"id":data.inbox_id},"parts":parts}})) + 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)); Ok(Vec::new()) } - NativeEvent::Context(data) => { let sid = data.session_id.clone(); self.pending_context.insert(sid, data); Ok(Vec::new()) } - NativeEvent::StepStarted(data) => { - let key = (data.session_id.clone(), data.message_id); - if let Some(context) = self.pending_context.remove(&data.session_id) { self.message_context.insert(key.clone(), context); } - self.messages.insert(key, (data.started.unwrap_or(event.ts_ms), data.model)); Ok(Vec::new()) + NativeEvent::Cancelled(data) => { + self.pending_inputs + .remove(&(data.session_id, data.inbox_id)); + vec![] } - NativeEvent::ToolInputStarted(data) => { self.tool_names.insert((data.session_id, data.id), data.name); Ok(Vec::new()) } - NativeEvent::PermissionAsked(data) => { - let call = data.source.map(|source| source.id); - let name = call.as_ref().and_then(|id| self.tool_names.get(&(data.session_id.clone(), id.clone()))).cloned().unwrap_or_else(|| data.action.clone()); - self.emit(event, ctx, directory, "permission.asked", json!({"properties":{"id":data.id,"sessionID":data.session_id,"tool":{"callID":call,"name":name},"permissionType":data.action}})) + NativeEvent::Context(data) => { + self.pending_context.insert(data.session_id.clone(), data); + vec![] } - NativeEvent::PermissionReplied(data) => self.emit(event, ctx, directory, "permission.replied", json!({"properties":{"requestID":data.request_id,"sessionID":data.session_id,"reply":data.reply}})), - NativeEvent::CompactionStarted(data) => { self.compactions.insert(data.session_id, (event.ts_ms, data.input_id.unwrap_or_else(|| "compaction".into()))); Ok(Vec::new()) } - NativeEvent::CompactionEnded(data) => { - let (started, trigger) = self.compactions.remove(&data.session_id).unwrap_or((event.ts_ms, "compaction".into())); - if data.native.as_ref().and_then(|native| native.messages.as_ref()).is_some() { return Ok(Vec::new()); } - let summary = native.id.as_deref().unwrap_or("compaction-summary"); - let keep_id = native.metadata.and_then(|metadata| metadata.keep_id); - let mut ops = self.emit(event, ctx, directory, "message.part.updated", json!({"properties":{"part":{"sessionID":data.session_id,"messageID":trigger,"type":"compaction","tail_start_id":keep_id}}}))?; - ops.extend(self.emit(event, ctx, directory, "message.part.updated", json!({"properties":{"part":{"sessionID":data.session_id,"messageID":summary,"type":"text","text":data.text,"time":{"end":event.ts_ms}}}}))?); - ops.extend(self.emit(event, ctx, directory, "message.updated", json!({"properties":{"info":{"sessionID":data.session_id,"id":summary,"role":"assistant","summary":true,"providerID":data.model.as_ref().and_then(|m| m.provider_id.as_ref()),"modelID":data.model.as_ref().and_then(|m| m.id.as_ref()),"time":{"created":started,"completed":event.ts_ms},"tokens":data.tokens}}}))?); - Ok(ops) + NativeEvent::StepStarted(data) => { + let context = self.pending_context.remove(&data.session_id); + self.steps.insert( + (data.session_id, data.message_id), + Step { + started: data.started.unwrap_or(ts), + model: data.model, + context, + }, + ); + vec![] } - NativeEvent::CompactionFailed(data) => { - let (started, _) = self.compactions.remove(&data.session_id).unwrap_or((event.ts_ms, "compaction".into())); - let id = native.id.as_deref().unwrap_or("compaction-failed"); - self.emit(event, ctx, directory, "message.updated", json!({"properties":{"info":{"sessionID":data.session_id,"id":id,"role":"assistant","compaction":true,"time":{"created":started,"completed":event.ts_ms},"tokens":data.tokens,"error":data.error}}})) + NativeEvent::ToolInputStarted(data) => { + self.tool_names + .insert((data.session_id, data.id), data.name); + vec![] } NativeEvent::ToolCalled(data) => { - let name = self.tool_names.get(&(data.session_id.clone(), data.id.clone())).cloned().unwrap_or_else(|| "tool".into()); - let mut ops = self.emit(event, ctx, directory, "message.part.updated", json!({"properties":{"part":{"sessionID":data.session_id,"messageID":data.message_id,"type":"tool","callID":data.id,"tool":name,"state":{"status":"running","input":data.input}}}}))?; - ops.extend(self.emit(event, ctx, directory, "tool.execute.before", json!({"input":{"sessionID":data.session_id,"callID":data.id,"tool":name},"output":{"args":data.input}}))?); - Ok(ops) + let name = self + .tool_names + .get(&(data.session_id.clone(), 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![]); + }; + if s.tools.contains_key(&data.id) { + return Ok(vec![]); + } + if s.denied_tools.contains(&data.id) { + return Ok(vec![]); + } + let span_id = ids::span_id( + &self.daemon_session_id, + &format!("tool:{}:{}", data.session_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(json!({"id":data.id,"type":"function","function":{"name":name,"arguments":data.input.to_string()}})); + s.tools.insert( + data.id.clone(), + 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(json!({"tool_name":name,"call_id":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, + output, + None, + ts, + ) + } + NativeEvent::ToolFailed(data) => { + let error = data.error.message; + self.finish_tool( + &data.session_id, + &data.message_id, + &data.id, + Value::Null, + Some(error), + ts, + ) + } + NativeEvent::PermissionAsked(data) => { + self.permissions.insert( + data.id, + Permission { + session_id: data.session_id, + call_id: data.source.map(|source| source.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 tool_name = self + .tool_names + .get(&(p.session_id.clone(), call.clone())) + .cloned() + .unwrap_or_else(|| p.action.clone()); + let existing = s.tools.get_mut(&call); + 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:{}:{call}", p.session_id), + ), + parent, + false, + ) + }; + s.denied_tools.insert(call.clone()); + 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(with_tool_approval( + json!({"tool_name":tool_name,"call_id":call,"permission_id":data.request_id,"permission_type":p.action}), + Some(ToolApproval::Denied), + )), + ..Default::default() + }; + vec![if had_start { + SpanOp::Merge(row) + } else { + SpanOp::Insert(row) + }] } - NativeEvent::ToolSuccess(data) => self.tool_finished(event, ctx, directory, ToolCompletion { session_id: &data.session_id, message_id: &data.message_id, id: &data.id, output: normalize_tool_content(data.content.unwrap_or_default()), failed: false }), - NativeEvent::ToolFailed(data) => self.tool_finished(event, ctx, directory, ToolCompletion { session_id: &data.session_id, message_id: &data.message_id, id: &data.id, output: json!(data.error), failed: true }), NativeEvent::TextEnded(data) | NativeEvent::ReasoningEnded(data) => { let reasoning = event.event == "session.reasoning.ended"; - let kind = if reasoning { "reasoning" } else { "text" }; - let parts = self.text_parts.entry((data.session_id.clone(), data.message_id.clone(), reasoning)).or_default(); - parts.insert(data.ordinal.unwrap_or(0), data.text); - let text = parts.values().map(String::as_str).collect::(); - self.emit(event, ctx, directory, "message.part.updated", json!({"properties":{"part":{"sessionID":data.session_id,"messageID":data.message_id,"type":kind,"text":text,"time":{"end":event.ts_ms}}}})) + 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) => { - let key = (data.session_id.clone(), data.message_id.clone()); - self.text_parts.remove(&(data.session_id.clone(), data.message_id.clone(), false)); - self.text_parts.remove(&(data.session_id.clone(), data.message_id.clone(), true)); - let (started, model) = self.messages.remove(&key).unwrap_or((event.ts_ms, None)); - let mut ops = Vec::new(); - if let Some(context) = self.message_context.remove(&key) { ops.extend(self.emit(event, ctx, directory, "model.context", json!({"sessionID":data.session_id,"messages":normalize_context(context)}))?); } - ops.extend(self.emit(event, ctx, directory, "message.updated", json!({"properties":{"info":{"sessionID":data.session_id,"id":data.message_id,"role":"assistant","providerID":model.as_ref().and_then(|m| m.provider_id.as_ref()),"modelID":model.as_ref().and_then(|m| m.id.as_ref()),"time":{"created":started,"completed":event.ts_ms},"tokens":data.tokens,"error":data.error}}}))?); - Ok(ops) + 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, + }); + if data + .native + .as_ref() + .and_then(|n| n.messages.as_ref()) + .is_some() + { + vec![] + } else { + 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, + }) + } } - NativeEvent::Succeeded(data) => self.emit(event, ctx, directory, "session.idle", json!({"properties":{"sessionID":data.session_id}})), - NativeEvent::Failed(data) => self.emit(event, ctx, directory, "session.error", json!({"properties":{"sessionID":data.session_id,"error":data.error}})), - NativeEvent::Interrupted(data) => self.emit(event, ctx, directory, "session.error", json!({"properties":{"sessionID":data.session_id,"error":format!("OpenCode session interrupted ({})", data.reason.as_deref().unwrap_or("unknown"))}})), - NativeEvent::Deleted(data) => self.emit(event, ctx, directory, "session.deleted", json!({"properties":{"sessionID":data.session_id}})), - NativeEvent::Other => Ok(Vec::new()), + 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, + }) + } + NativeEvent::Succeeded(data) => self.finish(&data.session_id, ts, false, None), + NativeEvent::Failed(data) => { + self.finish(&data.session_id, ts, true, Some(data.error.message)) + } + NativeEvent::Interrupted(data) => self.finish( + &data.session_id, + ts, + true, + 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: Value, + error: Option, + ts: i64, + ) -> Vec { + self.tool_names.remove(&(sid.into(), call.into())); + let Some(s) = self.sessions.get_mut(sid) else { + return vec![]; + }; + if s.denied_tools.remove(call) { + s.tools.remove(call); + return vec![]; + } + if !s + .tools + .get(call) + .is_some_and(|tool| tool.message_id == message_id) + { + return vec![]; + } + let Some(tool) = s.tools.remove(call) else { + return vec![]; + }; + if tool.denied { + return vec![]; } + s.tool_count += 1; + let result = if let Some(error) = &error { + json!(error) + } else { + output.clone() + }; + s.results + .entry(tool.message_id) + .or_default() + .push(json!({"role":"tool","tool_call_id":call,"content":result})); + let mut metadata = with_tool_approval( + json!({"tool_name":tool.name,"call_id":call}), + Some(ToolApproval::Approved), + ); + if tool.name == "skill" { + metadata["tool_kind"] = json!("skill"); + metadata["skill_name"] = json!(tool.skill_name); + } + 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(output), + error, + metadata: Some(metadata), + ..Default::default() + })] } - fn checkpoint(&mut self, ctx: &SessionCtx) -> anyhow::Result> { - self.spans.checkpoint(ctx) + 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 = json!({"role":"assistant","content":text}); + let calls = s.calls.remove(&data.message_id).unwrap_or_default(); + if !calls.is_empty() { + assistant["tool_calls"] = json!(calls); + } + if let Some(reasoning) = reasoning { + assistant["reasoning"] = json!([{"id":"reasoning","content":reasoning}]); + } + let input = step.context.map(normalize_context).unwrap_or_else(|| { + s.history + .iter() + .flat_map(|entry| entry.messages.iter().cloned()) + .collect() + }); + 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 tokens = data.tokens.unwrap_or_default(); + let prompt = tokens.input + tokens.cache.read + tokens.cache.write; + 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 mut metadata = json!({"provider":provider,"model":model,"message_id":data.message_id}); + if let Some(variant) = step.model.as_ref().and_then(|m| m.variant.as_deref()) { + metadata["variant"] = json!(variant); + } + 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(json!(input)), + output: Some(json!([assistant])), + metadata: Some(metadata), + metrics: Some( + json!({"prompt_tokens":prompt,"completion_tokens":tokens.output,"tokens":prompt+tokens.output+tokens.reasoning,"prompt_cached_tokens":tokens.cache.read,"prompt_cache_creation_tokens":tokens.cache.write,"reasoning_tokens":tokens.reasoning}), + ), + error: data.error.map(|e| e.message), + ..Default::default() + })] } - fn finalize(&mut self, ctx: &SessionCtx) -> anyhow::Result> { - self.spans.finalize(ctx) + fn compact(&mut self, result: CompactionResult<'_>) -> Vec { + let CompactionResult { + session_id: sid, + id, + text, + model, + tokens, + started, + ended: ts, + error, + keep_id, + input_id, + } = 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 { + 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 input: Vec<_> = s.history[..tail] + .iter() + .flat_map(|entry| entry.messages.iter().cloned()) + .collect(); + if error.is_none() { + let kept = s.history.drain(tail..).collect::>(); + s.history.clear(); + s.history.push(HistoryEntry { + id: id.into(), + messages: vec![ + json!({"role":"assistant","content":text,"message_type":"compaction_summary"}), + ], + }); + s.history.extend(kept); + } + 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 tokens = tokens.unwrap_or_default(); + let prompt = tokens.input + tokens.cache.read + tokens.cache.write; + 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(json!(input)), + output: Some(json!([{"role":"assistant","content":text}])), + metadata: Some(json!({"provider":provider,"model":model,"message_id":id})), + metrics: Some( + json!({"prompt_tokens":prompt,"completion_tokens":tokens.output,"tokens":prompt+tokens.output+tokens.reasoning,"prompt_cached_tokens":tokens.cache.read,"prompt_cache_creation_tokens":tokens.cache.write,"reasoning_tokens":tokens.reasoning}), + ), + 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"); diff --git a/bt-daemon/src/translate/opencode_v2/event.rs b/bt-daemon/src/translate/opencode_v2/event.rs index 5bdee0db..e842deff 100644 --- a/bt-daemon/src/translate/opencode_v2/event.rs +++ b/bt-daemon/src/translate/opencode_v2/event.rs @@ -116,6 +116,12 @@ pub(super) struct Prompt { 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 { @@ -127,6 +133,7 @@ pub(super) struct Model { pub id: Option, #[serde(rename = "providerID")] pub provider_id: Option, + pub variant: Option, } #[derive(Deserialize)] pub(super) struct StepStarted { diff --git a/bt-daemon/tests/opencode_translator.rs b/bt-daemon/tests/opencode_translator.rs index fd352be3..d9a3c16a 100644 --- a/bt-daemon/tests/opencode_translator.rs +++ b/bt-daemon/tests/opencode_translator.rs @@ -985,7 +985,7 @@ fn opencode_v2_reduces_native_text_session_without_session_created() { ( "session.inbox.enqueued", 1, - json!({"sessionID":"native", "inboxID":"user-1", "item":{"type":"user", "payload":{"text":"hello", "files":[]}, "delivery":"steer"}}), + json!({"sessionID":"native", "inboxID":"user-1", "item":{"type":"user", "payload":{"text":"hello", "files":[],"skills":[{"id":"s1","name":"review"}]}, "delivery":"steer"}}), ), ( "session.inbox.delivered", @@ -995,7 +995,7 @@ fn opencode_v2_reduces_native_text_session_without_session_created() { ( "session.step.started", 3, - json!({"sessionID":"native", "assistantMessageID":"assistant-1", "model":{"id":"mock-model", "providerID":"mock"}, "started":2}), + json!({"sessionID":"native", "assistantMessageID":"assistant-1", "model":{"id":"mock-model", "providerID":"mock","variant":"high"}, "started":2}), ), ( "session.text.ended", @@ -1032,11 +1032,16 @@ fn opencode_v2_reduces_native_text_session_without_session_created() { 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!"}])) @@ -1289,3 +1294,232 @@ fn opencode_v2_compaction_preserves_native_model_context_after_resume() { ) ); } + +#[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 mut e = event( + name, + i as i64 + 1, + json!({"id":format!("v2-{i}"),"data":data}), + ); + e.source_version = Some("2.2.5".into()); + 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 mut e = event( + name, + i as i64 + 1, + json!({"id":format!("permission-{i}"),"data":data}), + ); + e.source_version = Some("2.2.5".into()); + 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" + ); +} From acc39000268fb2b25cf67a3c2210b19edde91191 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Thu, 5 Nov 2026 01:32:36 +0800 Subject: [PATCH 13/21] Use typed OpenCode 2 span data --- bt-daemon/src/translate/opencode_v2.rs | 276 +++++++++++-------- bt-daemon/src/translate/opencode_v2/event.rs | 12 +- bt-daemon/src/translate/opencode_v2/span.rs | 192 +++++++++++++ bt-daemon/src/translate/tool.rs | 4 +- bt-daemon/tests/opencode_translator.rs | 71 +++++ 5 files changed, 438 insertions(+), 117 deletions(-) create mode 100644 bt-daemon/src/translate/opencode_v2/span.rs diff --git a/bt-daemon/src/translate/opencode_v2.rs b/bt-daemon/src/translate/opencode_v2.rs index cbbe6e19..ea82705f 100644 --- a/bt-daemon/src/translate/opencode_v2.rs +++ b/bt-daemon/src/translate/opencode_v2.rs @@ -1,21 +1,33 @@ //! OpenCode 2 native event translation. mod event; +mod span; use self::event::*; +use self::span::*; use super::{ - git::GitMetadataCache, - local_username, - recent::RecentSet, - tool::{with_tool_approval, ToolApproval}, - AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, + git::GitMetadataCache, local_username, recent::RecentSet, tool::ToolApproval, AgentTranslator, + SessionCtx, SpanOp, SpanRow, SpanType, }; use crate::{ids, wire::Envelope}; -use serde_json::{json, Value}; +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, @@ -29,8 +41,8 @@ struct SessionState { idle: bool, output: Option, history: Vec, - calls: HashMap>, - results: HashMap>, + calls: HashMap>, + results: HashMap>, tools: HashMap, denied_tools: HashSet, completed_messages: RecentSet, @@ -55,7 +67,7 @@ struct Permission { } struct HistoryEntry { id: String, - messages: Vec, + messages: Vec, } struct Compaction { started: i64, @@ -138,11 +150,14 @@ impl OpenCodeV2Translator { } else { "OpenCode".into() }; - let mut metadata = json!({"session_id":sid,"source":"opencode","_bt_defer_root":true,"username":local_username()}); - if let Some(parent_id) = parent_id { - metadata["parent_session_id"] = json!(parent_id); - metadata["is_subagent"] = json!(true); - } + 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 { @@ -162,7 +177,7 @@ impl OpenCodeV2Translator { name, span_type: SpanType::Task, start_ms: Some(ts), - metadata: Some(metadata), + metadata: Some(span_value(metadata)), ..Default::default() })] } @@ -192,22 +207,25 @@ impl OpenCodeV2Translator { } s.turn_number += 1; let turn = ids::span_id(&self.daemon_session_id, &format!("turn:{sid}:{id}")); - let mut content = vec![json!({"type":"text","text":prompt.text})]; + let mut content = vec![ContentPart::text(prompt.text.clone())]; for file in prompt.files { if file.mime.starts_with("image/") { - content.push(json!({"type":"image_url","image_url":{"url":format!("data:{};base64,{}",file.mime,file.data)}})); + content.push(ContentPart::image(format!( + "data:{};base64,{}", + file.mime, file.data + ))); } } let input = if content.len() == 1 { - json!(prompt.text) + Content::Text(prompt.text.clone()) } else { - json!(content) + Content::Parts(content) }; - let has_input = !prompt.text.is_empty() || input.is_array(); + let has_input = !prompt.text.is_empty() || matches!(input, Content::Parts(_)); s.history.push(HistoryEntry { id: id.into(), messages: has_input - .then(|| json!({"role":"user","content":input})) + .then(|| Message::new("user", input.clone())) .into_iter() .collect(), }); @@ -236,10 +254,12 @@ impl OpenCodeV2Translator { span_type: SpanType::Task, is_turn: true, start_ms: Some(ts), - input: has_input.then_some(input), - metadata: Some( - json!({"turn_number":s.turn_number,"model":null,"loaded_skill_names":skills}), - ), + 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 @@ -283,7 +303,10 @@ impl OpenCodeV2Translator { root_span_id: s.trace_root.clone(), parent_span_ids: s.parents.clone(), end_ms: Some(ts), - metadata: Some(json!({"total_turns":s.turn_number,"total_tool_calls":s.tool_count})), + metadata: Some(span_value(RootTotals { + total_turns: s.turn_number, + total_tool_calls: s.tool_count, + })), error, ..Default::default() })); @@ -403,7 +426,10 @@ impl AgentTranslator for OpenCodeV2Translator { .map(str::to_owned) }) .flatten(); - s.calls.entry(data.message_id.clone()).or_default().push(json!({"id":data.id,"type":"function","function":{"name":name,"arguments":data.input.to_string()}})); + s.calls + .entry(data.message_id.clone()) + .or_default() + .push(ToolCall::new(data.id.clone(), name.clone(), &data.input)); s.tools.insert( data.id.clone(), ToolState { @@ -424,7 +450,7 @@ impl AgentTranslator for OpenCodeV2Translator { span_type: SpanType::Tool, start_ms: Some(ts), input: Some(data.input), - metadata: Some(json!({"tool_name":name,"call_id":data.id})), + metadata: Some(span_value(ToolMetadata::new(&name, &data.id))), ..Default::default() })] } @@ -434,7 +460,7 @@ impl AgentTranslator for OpenCodeV2Translator { &data.session_id, &data.message_id, &data.id, - output, + Some(output), None, ts, ) @@ -445,7 +471,7 @@ impl AgentTranslator for OpenCodeV2Translator { &data.session_id, &data.message_id, &data.id, - Value::Null, + None, Some(error), ts, ) @@ -500,6 +526,10 @@ impl AgentTranslator for OpenCodeV2Translator { ) }; s.denied_tools.insert(call.clone()); + 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(), @@ -515,10 +545,7 @@ impl AgentTranslator for OpenCodeV2Translator { span_type: SpanType::Tool, start_ms: (!had_start).then_some(ts), end_ms: Some(ts), - metadata: Some(with_tool_approval( - json!({"tool_name":tool_name,"call_id":call,"permission_id":data.request_id,"permission_type":p.action}), - Some(ToolApproval::Denied), - )), + metadata: Some(span_value(metadata)), ..Default::default() }; vec![if had_start { @@ -632,7 +659,7 @@ impl OpenCodeV2Translator { sid: &str, message_id: &str, call: &str, - output: Value, + output: Option>, error: Option, ts: i64, ) -> Vec { @@ -659,30 +686,28 @@ impl OpenCodeV2Translator { } s.tool_count += 1; let result = if let Some(error) = &error { - json!(error) + Content::Text(error.clone()) } else { - output.clone() + Content::Parts(output.clone().unwrap_or_default()) }; s.results .entry(tool.message_id) .or_default() - .push(json!({"role":"tool","tool_call_id":call,"content":result})); - let mut metadata = with_tool_approval( - json!({"tool_name":tool.name,"call_id":call}), - Some(ToolApproval::Approved), - ); + .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"] = json!("skill"); - metadata["skill_name"] = json!(tool.skill_name); + 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(output), + output: Some(span_value(output)), error, - metadata: Some(metadata), + metadata: Some(span_value(metadata)), ..Default::default() })] } @@ -711,13 +736,16 @@ impl OpenCodeV2Translator { let Some(parent) = s.turn.clone() else { return vec![]; }; - let mut assistant = json!({"role":"assistant","content":text}); + 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"] = json!(calls); + assistant.tool_calls = calls.clone(); } if let Some(reasoning) = reasoning { - assistant["reasoning"] = json!([{"id":"reasoning","content":reasoning}]); + assistant.reasoning = Some(vec![Reasoning { + id: "reasoning", + content: reasoning, + }]); } let input = step.context.map(normalize_context).unwrap_or_else(|| { s.history @@ -737,8 +765,7 @@ impl OpenCodeV2Translator { id: data.message_id.clone(), messages, }); - let tokens = data.tokens.unwrap_or_default(); - let prompt = tokens.input + tokens.cache.read + tokens.cache.write; + let metrics = Metrics::from_tokens(data.tokens.unwrap_or_default()); let provider = step .model .as_ref() @@ -749,10 +776,12 @@ impl OpenCodeV2Translator { .as_ref() .and_then(|m| m.id.as_deref()) .unwrap_or("unknown"); - let mut metadata = json!({"provider":provider,"model":model,"message_id":data.message_id}); - if let Some(variant) = step.model.as_ref().and_then(|m| m.variant.as_deref()) { - metadata["variant"] = json!(variant); - } + 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, @@ -765,12 +794,10 @@ impl OpenCodeV2Translator { span_type: SpanType::Llm, start_ms: Some(step.started), end_ms: Some(ts), - input: Some(json!(input)), - output: Some(json!([assistant])), - metadata: Some(metadata), - metrics: Some( - json!({"prompt_tokens":prompt,"completion_tokens":tokens.output,"tokens":prompt+tokens.output+tokens.reasoning,"prompt_cached_tokens":tokens.cache.read,"prompt_cache_creation_tokens":tokens.cache.write,"reasoning_tokens":tokens.reasoning}), - ), + 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() })] @@ -808,9 +835,7 @@ impl OpenCodeV2Translator { s.history.clear(); s.history.push(HistoryEntry { id: id.into(), - messages: vec![ - json!({"role":"assistant","content":text,"message_type":"compaction_summary"}), - ], + messages: vec![Message::summary(text.clone())], }); s.history.extend(kept); } @@ -822,8 +847,7 @@ impl OpenCodeV2Translator { .as_ref() .and_then(|m| m.id.as_deref()) .unwrap_or("unknown"); - let tokens = tokens.unwrap_or_default(); - let prompt = tokens.input + tokens.cache.read + tokens.cache.write; + 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(), @@ -833,31 +857,34 @@ impl OpenCodeV2Translator { span_type: SpanType::Llm, start_ms: Some(started), end_ms: Some(ts), - input: Some(json!(input)), - output: Some(json!([{"role":"assistant","content":text}])), - metadata: Some(json!({"provider":provider,"model":model,"message_id":id})), - metrics: Some( - json!({"prompt_tokens":prompt,"completion_tokens":tokens.output,"tokens":prompt+tokens.output+tokens.reasoning,"prompt_cached_tokens":tokens.cache.read,"prompt_cache_creation_tokens":tokens.cache.write,"reasoning_tokens":tokens.reasoning}), - ), + input: Some(span_value(input)), + output: Some(span_value([Message::new("assistant", Content::Text(text))])), + metadata: Some(span_value(LlmMetadata { + provider, + model, + message_id: id, + variant: None, + })), + metrics: Some(span_value(metrics)), error, tags: Some(vec!["compaction".into()]), ..Default::default() })] } } -fn normalize_context(context: Context) -> Vec { +fn normalize_context(context: Context) -> Vec { let mut messages = Vec::new(); let system = context.system.join("\n\n"); if !system.is_empty() { - messages.push(json!({"role":"system","content":system})); + messages.push(Message::new("system", Content::Text(system))); } for message in context.messages { match message.content { MessageContent::Text(text) => { - messages.push(json!({"role":message.role,"content":text})) + messages.push(Message::new(message.role, Content::Text(text))) } MessageContent::Other(content) => { - messages.push(json!({"role":message.role,"content":content})) + messages.push(Message::new(message.role, Content::Dynamic(content))) } MessageContent::Parts(parts) => { let mut content = Vec::new(); @@ -865,54 +892,83 @@ fn normalize_context(context: Context) -> Vec { let mut single_text = None; for part in parts { match part { - ContextPart::Known(KnownContextPart::Text { text }) => { single_text = Some(text.clone()); content.push(json!({"type":"text","text":text})); }, - ContextPart::Known(KnownContextPart::Media { media_type, data, media, extra }) => { + 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.as_ref().and_then(|source| source.media_type.as_deref()).or(media_type.as_deref()); - let bytes = source.as_ref().and_then(|source| source.data.as_deref()).or(data.as_deref()); - let image = mime.zip(bytes).filter(|(mime, _)| mime.starts_with("image/")); - if let Some((mime, bytes)) = image { content.push(json!({"type":"image_url","image_url":{"url":format!("data:{mime};base64,{bytes}")}})); } - else if let Ok(value) = serde_json::to_value(KnownContextPart::Media { media_type, data, media, extra }) { content.push(value); } + 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::ToolCall { id, name, input }) => calls.push(json!({"id":id,"type":"function","function":{"name":name,"arguments":input.to_string()}})), ContextPart::Known(KnownContextPart::ToolResult { id, result }) => { - let value = match serde_json::from_value::>(result.value.clone()) { Ok(parts) => normalize_tool_content(parts), Err(_) => result.value }; - messages.push(json!({"role":"tool","tool_call_id":id,"content":value})); + 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(value), + ContextPart::Other(value) => content.push(ContentPart::Dynamic(value)), } } if message.role == "tool" { continue; } - let value = match content.as_slice() { - [] => json!(""), - [_] if single_text.is_some() => json!(single_text), - _ => Value::Array(content), - }; - if calls.is_empty() { - messages.push(json!({"role":message.role,"content":value})); + 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 { - messages.push(json!({"role":message.role,"content":value,"tool_calls":calls})); - } + 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) -> Value { - Value::Array( - parts - .into_iter() - .map(|part| match part { - ToolContent::Known(KnownToolContent::File { mime, uri, .. }) - if mime.starts_with("image/") => - { - json!({"type":"image_url","image_url":{"url":uri}}) - } - other => serde_json::to_value(other).unwrap_or(Value::Null), - }) - .collect(), - ) +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 index e842deff..f2420abb 100644 --- a/bt-daemon/src/translate/opencode_v2/event.rs +++ b/bt-daemon/src/translate/opencode_v2/event.rs @@ -228,13 +228,13 @@ pub(super) struct NativeError { pub(super) struct ErrorResponse { pub body: String, } -#[derive(Deserialize, Serialize)] +#[derive(Clone, Deserialize, Serialize)] #[serde(untagged)] pub(super) enum ToolContent { Known(KnownToolContent), Other(Value), } -#[derive(Deserialize, Serialize)] +#[derive(Clone, Deserialize, Serialize)] #[serde(tag = "type")] pub(super) enum KnownToolContent { #[serde(rename = "text")] @@ -334,7 +334,7 @@ pub(super) enum ContextPart { Known(KnownContextPart), Other(Value), } -#[derive(Deserialize, Serialize)] +#[derive(Clone, Deserialize, Serialize)] #[serde(tag = "type")] pub(super) enum KnownContextPart { #[serde(rename = "text")] @@ -357,13 +357,13 @@ pub(super) enum KnownContextPart { #[serde(rename = "tool-result")] ToolResult { id: String, result: ToolResult }, } -#[derive(Deserialize, Serialize)] +#[derive(Clone, Deserialize, Serialize)] pub(super) struct Media { pub source: Option, #[serde(flatten)] pub extra: serde_json::Map, } -#[derive(Deserialize, Serialize)] +#[derive(Clone, Deserialize, Serialize)] pub(super) struct MediaSource { #[serde(rename = "mediaType")] pub media_type: Option, @@ -371,7 +371,7 @@ pub(super) struct MediaSource { #[serde(flatten)] pub extra: serde_json::Map, } -#[derive(Deserialize, Serialize)] +#[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/tests/opencode_translator.rs b/bt-daemon/tests/opencode_translator.rs index d9a3c16a..576a8772 100644 --- a/bt-daemon/tests/opencode_translator.rs +++ b/bt-daemon/tests/opencode_translator.rs @@ -1523,3 +1523,74 @@ fn opencode_v2_permission_denied_before_tool_call_creates_one_tool_span() { "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 mut envelope = event( + name, + i as i64 + 1, + json!({"id":format!("provider-{i}"),"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.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}}])) + ); +} From ed3703b75b17302bfaa2adba50aa035e4f61a2f2 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Thu, 5 Nov 2026 02:10:27 +0800 Subject: [PATCH 14/21] Trace OpenCode native compaction --- bt-daemon/src/translate/opencode_v2.rs | 134 ++++++++++++++----- bt-daemon/src/translate/opencode_v2/event.rs | 16 ++- bt-daemon/tests/agent_integration.rs | 54 +++++--- bt-daemon/tests/opencode_translator.rs | 119 ++++++++++++++++ src/plugins/opencode/content/README.md | 8 +- 5 files changed, 275 insertions(+), 56 deletions(-) diff --git a/bt-daemon/src/translate/opencode_v2.rs b/bt-daemon/src/translate/opencode_v2.rs index ea82705f..c804baeb 100644 --- a/bt-daemon/src/translate/opencode_v2.rs +++ b/bt-daemon/src/translate/opencode_v2.rs @@ -41,6 +41,7 @@ struct SessionState { idle: bool, output: Option, history: Vec, + native_window: Option, calls: HashMap>, results: HashMap>, tools: HashMap, @@ -69,6 +70,11 @@ struct HistoryEntry { id: String, messages: Vec, } +struct NativeWindow { + model: Option, + messages: Vec, + history_boundary: usize, +} struct Compaction { started: i64, input_id: Option, @@ -84,6 +90,7 @@ struct CompactionResult<'a> { error: Option, keep_id: Option, input_id: Option, + native_messages: Option, } pub(super) struct OpenCodeV2Translator { @@ -583,27 +590,22 @@ impl AgentTranslator for OpenCodeV2Translator { started: ts, input_id: None, }); - if data - .native - .as_ref() - .and_then(|n| n.messages.as_ref()) - .is_some() - { - vec![] - } else { - 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, - }) - } + 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 @@ -624,6 +626,7 @@ impl AgentTranslator for OpenCodeV2Translator { 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), @@ -748,10 +751,27 @@ impl OpenCodeV2Translator { }]); } let input = step.context.map(normalize_context).unwrap_or_else(|| { - s.history - .iter() - .flat_map(|entry| entry.messages.iter().cloned()) - .collect() + let window = s.native_window.as_ref().filter(|window| { + match (window.model.as_ref(), step.model.as_ref()) { + (Some(a), Some(b)) => { + a.id.is_some() + && a.provider_id.is_some() + && a.id == b.id + && a.provider_id == b.provider_id + } + _ => false, + } + }); + 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()); @@ -814,12 +834,20 @@ impl OpenCodeV2Translator { 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 @@ -830,15 +858,49 @@ impl OpenCodeV2Translator { .iter() .flat_map(|entry| entry.messages.iter().cloned()) .collect(); - 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); - } + 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()) @@ -858,12 +920,12 @@ impl OpenCodeV2Translator { start_ms: Some(started), end_ms: Some(ts), input: Some(span_value(input)), - output: Some(span_value([Message::new("assistant", Content::Text(text))])), + output: Some(output), metadata: Some(span_value(LlmMetadata { provider, model, message_id: id, - variant: None, + variant, })), metrics: Some(span_value(metrics)), error, diff --git a/bt-daemon/src/translate/opencode_v2/event.rs b/bt-daemon/src/translate/opencode_v2/event.rs index f2420abb..523cf87a 100644 --- a/bt-daemon/src/translate/opencode_v2/event.rs +++ b/bt-daemon/src/translate/opencode_v2/event.rs @@ -282,10 +282,24 @@ pub(super) struct CompactionEnded { 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, + 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 { diff --git a/bt-daemon/tests/agent_integration.rs b/bt-daemon/tests/agent_integration.rs index 26aac854..9b4a6711 100644 --- a/bt-daemon/tests/agent_integration.rs +++ b/bt-daemon/tests/agent_integration.rs @@ -953,7 +953,32 @@ async fn opencode_v2_subagent_is_nested() { #[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; @@ -967,7 +992,12 @@ async fn opencode_v2_compaction_after_idle() { 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(); - std::fs::write(plugins.join("compact.js"), "export default {id:'test.compact',async setup(ctx){await ctx.session.hook('compaction',e=>{e.result={summary:'LEGACY_COMPACTION_SUMMARY'}})}};").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() @@ -1065,13 +1095,13 @@ async fn opencode_v2_compaction_after_idle() { .unwrap(); let compacted_rows = world .wait_for_mock_ingest_scenario(&IngestScenario::new().expect("idle compaction", |row| { - row_contains(row, &[r#""type":"llm""#, "LEGACY_COMPACTION_SUMMARY"]) + row_contains(row, &[r#""type":"llm""#, summary]) })) .await; - assert!(compacted_rows.iter().any(|row| row_contains( - row, - &[r#""type":"llm""#, "LEGACY_COMPACTION_SUMMARY"] - ) && row["root_span_id"] == root_id)); + 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([ @@ -1103,11 +1133,7 @@ async fn opencode_v2_compaction_after_idle() { .wait_for_mock_ingest_scenario(&IngestScenario::new().expect("resumed context", |row| { row_contains( row, - &[ - r#""type":"llm""#, - "LEGACY_COMPACTION_SUMMARY", - "legacy after compaction", - ], + &[r#""type":"llm""#, summary, "legacy after compaction"], ) })) .await; @@ -1116,11 +1142,7 @@ async fn opencode_v2_compaction_after_idle() { .find(|row| { row_contains( row, - &[ - r#""type":"llm""#, - "LEGACY_COMPACTION_SUMMARY", - "legacy after compaction", - ], + &[r#""type":"llm""#, summary, "legacy after compaction"], ) }) .unwrap(); diff --git a/bt-daemon/tests/opencode_translator.rs b/bt-daemon/tests/opencode_translator.rs index 576a8772..3a786814 100644 --- a/bt-daemon/tests/opencode_translator.rs +++ b/bt-daemon/tests/opencode_translator.rs @@ -1594,3 +1594,122 @@ fn opencode_v2_preserves_provider_defined_context_and_tool_content() { 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 mut envelope = event( + name, + i as i64 + 1, + json!({"id":format!("native-{i}"),"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":"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 mut envelope = event( + name, + i as i64 + 1, + json!({"id":format!("opaque-{i}"),"data":data}), + ); + envelope.source_version = Some("2.2.5".into()); + 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)); +} diff --git a/src/plugins/opencode/content/README.md b/src/plugins/opencode/content/README.md index 10bcf2c6..f2036f00 100644 --- a/src/plugins/opencode/content/README.md +++ b/src/plugins/opencode/content/README.md @@ -34,10 +34,12 @@ 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. OpenCode 2 text and shell-tool sessions -are tested with the installed package. Coverage for subagents, attachments, -permissions, compaction and interrupted sessions is still being verified. +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. +Short-lived standalone OpenCode 2 processes may exit before the final trace event +is delivered; use a managed run when completion of the trace is required. The package declares `@opencode-ai/plugin` and `@opencode-ai/sdk` peers at `>=1.2.25`. CI checks package installation at that minimum and at the latest From 703fb23dcebc0a2b2e54ce4462cb04b42bbb52e6 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Thu, 5 Nov 2026 02:42:38 +0800 Subject: [PATCH 15/21] Preserve OpenCode event delivery and test parity --- bt-daemon/tests/agent_integration.rs | 160 +++++++++ bt-daemon/tests/opencode_translator.rs | 310 ++++++++++++++++++ bt-daemon/tests/pipeline.rs | 60 ++++ bt-daemon/tests/support/agent_process.rs | 5 + src/plugins/opencode/content/README.md | 5 +- .../opencode/content/src/server.test.ts | 49 ++- src/plugins/opencode/content/src/server.ts | 15 +- src/runtime/js-daemon-client/src/index.ts | 97 ++++-- .../js-daemon-client/tests/client.test.ts | 111 +++++++ 9 files changed, 773 insertions(+), 39 deletions(-) diff --git a/bt-daemon/tests/agent_integration.rs b/bt-daemon/tests/agent_integration.rs index 9b4a6711..494c2bca 100644 --- a/bt-daemon/tests/agent_integration.rs +++ b/bt-daemon/tests/agent_integration.rs @@ -541,6 +541,12 @@ fn request_helpers_recognize_tool_results_and_advertised_tools() { async fn opencode_v2_session_emits_traces() { use base64::Engine; let inference = OpenAiMock::new(|_, request| { + if request.contains_text("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() @@ -649,6 +655,14 @@ async fn opencode_v2_session_emits_traces() { && 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() @@ -663,6 +677,21 @@ async fn opencode_v2_session_emits_traces() { 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()]); @@ -803,6 +832,40 @@ async fn opencode_v2_session_emits_traces() { .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) + .env("PWD", &workspace) + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("XDG_CACHE_HOME", home.join("cache")); + 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"))); + 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 @@ -1153,3 +1216,100 @@ async fn run_v2_compaction_after_idle(native: bool) { 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::{AsyncReadExt, AsyncWriteExt}; + 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 (mut 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 buffer = [0u8; 8192]; + let mut client_closed = false; + while let Ok(n) = daemon_read.read(&mut buffer).await { + if n == 0 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(4000)).await; + if !client_closed && client_write.write_all(&buffer[..n]).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) + .env("PWD", &workspace) + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("XDG_CACHE_HOME", home.join("cache")); + 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 3a786814..7be3a0cf 100644 --- a/bt-daemon/tests/opencode_translator.rs +++ b/bt-daemon/tests/opencode_translator.rs @@ -1713,3 +1713,313 @@ fn opencode_v2_preserves_opaque_provider_compaction_output() { .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()])); + } +} diff --git a/bt-daemon/tests/pipeline.rs b/bt-daemon/tests/pipeline.rs index 9a27bce3..7a210569 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; diff --git a/bt-daemon/tests/support/agent_process.rs b/bt-daemon/tests/support/agent_process.rs index d80391de..6153221e 100644 --- a/bt-daemon/tests/support/agent_process.rs +++ b/bt-daemon/tests/support/agent_process.rs @@ -166,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()]; diff --git a/src/plugins/opencode/content/README.md b/src/plugins/opencode/content/README.md index f2036f00..505271ed 100644 --- a/src/plugins/opencode/content/README.md +++ b/src/plugins/opencode/content/README.md @@ -38,8 +38,9 @@ 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. -Short-lived standalone OpenCode 2 processes may exit before the final trace event -is delivered; use a managed run when completion of the trace is required. +Standalone delivery is tested with delayed daemon 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 the latest diff --git a/src/plugins/opencode/content/src/server.test.ts b/src/plugins/opencode/content/src/server.test.ts index b320806b..83aed215 100644 --- a/src/plugins/opencode/content/src/server.test.ts +++ b/src/plugins/opencode/content/src/server.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -const state = vi.hoisted(() => ({ logs: [] as Array>, closed: false })); +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, @@ -9,6 +13,11 @@ vi.mock("./runtime/daemon-client", () => ({ state.logs.push(event); return true; } + async logPipelined(event: Record) { + state.logs.push(event); + await state.gate; + return true; + } async close() { state.closed = true; } @@ -141,4 +150,42 @@ describe("OpenCode 2 capture", () => { payload: { data: { sessionID: "child" } }, }); }); + it("sends later events without waiting for acknowledgements and drains them on cleanup", 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() {} }, + tool: { + async transform(callback) { + callback({ add() {} }); + }, + }, + event: { + async *subscribe() { + yield { type: "session.created", data: { sessionID: "s" } }; + yield { type: "session.execution.succeeded", data: { sessionID: "s" } }; + }, + }, + }); + let finished = false; + const closing = cleanup?.().then(() => { + finished = true; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(state.logs.map((entry) => entry.event)).toEqual([ + "session.created", + "session.execution.succeeded", + ]); + expect(finished).toBe(false); + release(); + await closing; + expect(state.closed).toBe(true); + state.gate = undefined; + }); }); diff --git a/src/plugins/opencode/content/src/server.ts b/src/plugins/opencode/content/src/server.ts index 7d17ed6c..4bcc033e 100644 --- a/src/plugins/opencode/content/src/server.ts +++ b/src/plugins/opencode/content/src/server.ts @@ -150,13 +150,14 @@ export default { event.type === "session.deleted" ) contexts.delete(session); - pending = pending.then(async () => { - if (first && event.type !== "session.created") { - await daemon.log({ ...envelope, event: "session_start" }); - } - if (context) await daemon.log({ ...envelope, event: "model.context", ...context }); - await daemon.log(envelope); - }); + const writes = []; + if (first && event.type !== "session.created") { + writes.push(daemon.logPipelined({ ...envelope, event: "session_start" })); + } + if (context) + writes.push(daemon.logPipelined({ ...envelope, event: "model.context", ...context })); + writes.push(daemon.logPipelined(envelope)); + pending = Promise.all([pending, ...writes]).then(() => {}); } })().catch(() => {}); return async () => { diff --git a/src/runtime/js-daemon-client/src/index.ts b/src/runtime/js-daemon-client/src/index.ts index eed4c226..537adf62 100644 --- a/src/runtime/js-daemon-client/src/index.ts +++ b/src/runtime/js-daemon-client/src/index.ts @@ -158,7 +158,9 @@ export class DaemonClient { private nextId = 1 private pending = new Map() private connecting?: Promise + private writable?: { promise: Promise; resolve: () => void; reject: (error: unknown) => void } private queue: Promise = Promise.resolve() + private inFlightEvents = new Set>() private warned = new Set() constructor(options: DaemonClientOptions) { @@ -173,30 +175,43 @@ 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.sendEvent(envelope, true) + this.inFlightEvents.add(request) + void request.finally(() => this.inFlightEvents.delete(request)) + return request + } + + private async sendEvent(envelope: DaemonEnvelope, pipelined = false): 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, pipelined)) as { accepted?: boolean } + if (pipelined) await this.ensureConnected() + return result.accepted === true + } catch (error) { + if (this.socket) this.disconnect(error) try { - const result = (await this.request("event.log", event)) as { accepted?: boolean } + const result = (await this.request("event.log", event, pipelined)) as { accepted?: boolean } + if (pipelined) await this.ensureConnected() 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,6 +241,7 @@ export class DaemonClient { async close(): Promise { await this.queue + await Promise.all(this.inFlightEvents) this.disconnect(new Error("daemon client closed")) } @@ -238,8 +254,9 @@ export class DaemonClient { return next } - private async request(method: string, params: unknown): Promise { - await this.ensureConnected() + private async request(method: string, params: unknown, pipelined = false): Promise { + if (pipelined) await this.ensureWritable() + else await this.ensureConnected() const socket = this.socket if (!socket || socket.destroyed) throw new Error("daemon socket is unavailable") @@ -255,13 +272,27 @@ export class DaemonClient { return response } - private async ensureConnected(): Promise { + // The daemon reads requests in socket order. Write initialize first, then + // permit queued events to follow without waiting for its response. + private async ensureWritable(): Promise { if (this.socket && !this.socket.destroyed) return - if (!this.connecting) { - this.connecting = this.connectWithStartup().finally(() => { - this.connecting = undefined - }) + if (!this.writable) { + let resolve = () => {} + let reject = (_error: unknown) => {} + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + this.writable = { promise, resolve, reject } } + const writable = this.writable + void this.ensureConnected().catch((error) => writable.reject(error)) + return writable.promise + } + + private async ensureConnected(): Promise { + if (this.connecting) return this.connecting + if (this.socket && !this.socket.destroyed) return + this.connecting = this.connectWithStartup().finally(() => { + this.connecting = undefined + }) return this.connecting } @@ -296,14 +327,16 @@ export class DaemonClient { socket.off("error", onError) this.attach(socket) try { - const result = (await this.requestOnConnectedSocket("initialize", { + const initialization = this.requestOnConnectedSocket("initialize", { protocol_version: DAEMON_PROTOCOL_VERSION, client: { source: this.options.source, plugin_version: this.options.pluginVersion, pid: process.pid, }, - })) as { + }) + this.writable?.resolve() + const result = (await initialization) as { protocol_version: number capabilities?: { sources?: string[] } } @@ -315,7 +348,7 @@ export class DaemonClient { } resolve() } catch (error) { - this.disconnect(error) + if (this.socket === socket) this.disconnect(error) reject(error) } }) @@ -342,8 +375,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 { @@ -384,6 +421,8 @@ export class DaemonClient { } private disconnect(reason: unknown): void { + this.writable?.reject(reason) + this.writable = undefined const socket = this.socket this.socket = undefined if (socket && !socket.destroyed) socket.destroy() diff --git a/src/runtime/js-daemon-client/tests/client.test.ts b/src/runtime/js-daemon-client/tests/client.test.ts index 1583917b..c33eb556 100644 --- a/src/runtime/js-daemon-client/tests/client.test.ts +++ b/src/runtime/js-daemon-client/tests/client.test.ts @@ -193,3 +193,114 @@ 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"] } } })}\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"] } } })}\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") + 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) + 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) + await client.close() + await new Promise((resolve) => server.close(() => resolve())) + rmSync(temp, { recursive: true, force: true }) +}) From f7742d470bb40419d5d3c06897dbaf82a5cbc371 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Thu, 5 Nov 2026 02:45:51 +0800 Subject: [PATCH 16/21] Handle expected test socket disconnects --- src/runtime/js-daemon-client/tests/client.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/runtime/js-daemon-client/tests/client.test.ts b/src/runtime/js-daemon-client/tests/client.test.ts index c33eb556..f684c8e5 100644 --- a/src/runtime/js-daemon-client/tests/client.test.ts +++ b/src/runtime/js-daemon-client/tests/client.test.ts @@ -282,6 +282,9 @@ test("pipelined events do not report success when daemon initialization is incom 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") 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) => { From 84a630ca1b171bbbd32b1ed6d872cd1031375e09 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Thu, 5 Nov 2026 03:37:10 +0800 Subject: [PATCH 17/21] Fix OpenCode v2 trace parity gaps --- bt-daemon/src/translate/opencode_v2.rs | 138 +++++-- bt-daemon/src/translate/opencode_v2/event.rs | 4 + bt-daemon/tests/agent_integration.rs | 126 +++++- bt-daemon/tests/opencode_translator.rs | 388 ++++++++++++++++++ .../opencode/content/src/server.test.ts | 3 + src/plugins/opencode/content/src/server.ts | 1 + src/runtime/js-daemon-client/src/index.ts | 52 +-- .../js-daemon-client/tests/client.test.ts | 22 + 8 files changed, 669 insertions(+), 65 deletions(-) diff --git a/bt-daemon/src/translate/opencode_v2.rs b/bt-daemon/src/translate/opencode_v2.rs index c804baeb..f76a3889 100644 --- a/bt-daemon/src/translate/opencode_v2.rs +++ b/bt-daemon/src/translate/opencode_v2.rs @@ -33,10 +33,10 @@ struct SessionState { root: String, trace_root: String, parents: Vec, - parent_session: Option, owner_turn: Option, turn: Option, turn_number: u32, + turn_skills: Vec, tool_count: u32, idle: bool, output: Option, @@ -44,8 +44,8 @@ struct SessionState { native_window: Option, calls: HashMap>, results: HashMap>, - tools: HashMap, - denied_tools: HashSet, + tools: HashMap<(String, String), ToolState>, + denied_tools: HashSet<(String, String)>, completed_messages: RecentSet, } struct ToolState { @@ -64,6 +64,7 @@ struct Step { struct Permission { session_id: String, call_id: Option, + message_id: Option, action: String, } struct HistoryEntry { @@ -97,7 +98,7 @@ pub(super) struct OpenCodeV2Translator { daemon_session_id: String, sessions: HashMap, pending_inputs: HashMap<(String, String), Prompt>, - tool_names: HashMap<(String, String), String>, + tool_names: HashMap<(String, String, String), String>, steps: HashMap<(String, String), Step>, pending_context: HashMap, text_parts: HashMap<(String, String, bool), BTreeMap>, @@ -171,7 +172,6 @@ impl OpenCodeV2Translator { root: root.clone(), trace_root: trace_root.clone(), parents: parents.clone(), - parent_session: parent_id.map(str::to_owned), owner_turn: owner.clone(), ..Default::default() }, @@ -252,6 +252,7 @@ impl OpenCodeV2Translator { } } 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(), @@ -317,7 +318,7 @@ impl OpenCodeV2Translator { error, ..Default::default() })); - if !permanent && s.parent_session.is_none() { + if !permanent { s.idle = true; self.sessions.insert(sid.into(), s); } @@ -379,6 +380,21 @@ impl AgentTranslator for OpenCodeV2Translator { } 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 { @@ -387,17 +403,34 @@ impl AgentTranslator for OpenCodeV2Translator { context, }, ); - vec![] + turn_update.into_iter().collect() } NativeEvent::ToolInputStarted(data) => { - self.tool_names - .insert((data.session_id, data.id), data.name); + 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.id.clone())) + .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 { @@ -406,15 +439,16 @@ impl AgentTranslator for OpenCodeV2Translator { let Some(parent) = s.turn.clone() else { return Ok(vec![]); }; - if s.tools.contains_key(&data.id) { + 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(&data.id) { + 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.id), + &format!("tool:{}:{}:{}", data.session_id, data.message_id, data.id), ); let span_name = if name == "skill" { data.input @@ -438,7 +472,7 @@ impl AgentTranslator for OpenCodeV2Translator { .or_default() .push(ToolCall::new(data.id.clone(), name.clone(), &data.input)); s.tools.insert( - data.id.clone(), + tool_key, ToolState { name: name.clone(), skill_name, @@ -488,7 +522,8 @@ impl AgentTranslator for OpenCodeV2Translator { data.id, Permission { session_id: data.session_id, - call_id: data.source.map(|source| source.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, }, ); @@ -510,12 +545,36 @@ impl AgentTranslator for OpenCodeV2Translator { 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(), call.clone())) + .get(&(p.session_id.clone(), message_id.clone(), call.clone())) .cloned() .unwrap_or_else(|| p.action.clone()); - let existing = s.tools.get_mut(&call); + 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) @@ -526,13 +585,13 @@ impl AgentTranslator for OpenCodeV2Translator { ( ids::span_id( &self.daemon_session_id, - &format!("tool:{}:{call}", p.session_id), + &format!("tool:{}:{message_id}:{call}", p.session_id), ), parent, false, ) }; - s.denied_tools.insert(call.clone()); + 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); @@ -631,12 +690,12 @@ impl AgentTranslator for OpenCodeV2Translator { } NativeEvent::Succeeded(data) => self.finish(&data.session_id, ts, false, None), NativeEvent::Failed(data) => { - self.finish(&data.session_id, ts, true, Some(data.error.message)) + self.finish(&data.session_id, ts, false, Some(data.error.message)) } NativeEvent::Interrupted(data) => self.finish( &data.session_id, ts, - true, + false, Some(format!( "OpenCode session interrupted ({})", data.reason.as_deref().unwrap_or("unknown") @@ -666,22 +725,26 @@ impl OpenCodeV2Translator { error: Option, ts: i64, ) -> Vec { - self.tool_names.remove(&(sid.into(), call.into())); + 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(call) { - s.tools.remove(call); + if s.denied_tools.remove(&key) { + s.tools.remove(&key); return vec![]; } if !s .tools - .get(call) + .get(&key) .is_some_and(|tool| tool.message_id == message_id) { return vec![]; } - let Some(tool) = s.tools.remove(call) else { + let Some(tool) = s.tools.remove(&key) else { return vec![]; }; if tool.denied { @@ -854,10 +917,23 @@ impl OpenCodeV2Translator { .as_deref() .and_then(|id| s.history.iter().position(|entry| entry.id == id)) .unwrap_or(s.history.len()); - let input: Vec<_> = s.history[..tail] - .iter() - .flat_map(|entry| entry.messages.iter().cloned()) - .collect(); + let window = s.native_window.as_ref().filter(|window| { + window.history_boundary <= tail + && matches!( + (window.model.as_ref(), model.as_ref()), + (Some(a), Some(b)) if a.id.is_some() && a.provider_id.is_some() + && a.id == b.id && a.provider_id == b.provider_id + ) + }); + 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 { @@ -1002,7 +1078,7 @@ fn normalize_context(context: Context) -> Vec { ContextPart::Other(value) => content.push(ContentPart::Dynamic(value)), } } - if message.role == "tool" { + if message.role == "tool" && content.is_empty() && calls.is_empty() { continue; } let value = if content.is_empty() { diff --git a/bt-daemon/src/translate/opencode_v2/event.rs b/bt-daemon/src/translate/opencode_v2/event.rs index 523cf87a..a5a40ba7 100644 --- a/bt-daemon/src/translate/opencode_v2/event.rs +++ b/bt-daemon/src/translate/opencode_v2/event.rs @@ -184,6 +184,8 @@ pub(super) struct TextEnded { pub(super) struct ToolInputStarted { #[serde(rename = "sessionID")] pub session_id: String, + #[serde(rename = "assistantMessageID")] + pub message_id: Option, pub id: String, pub name: String, } @@ -258,6 +260,8 @@ pub(super) struct PermissionAsked { #[derive(Deserialize)] pub(super) struct PermissionSource { pub id: String, + #[serde(rename = "messageID")] + pub message_id: Option, } #[derive(Deserialize)] pub(super) struct PermissionReplied { diff --git a/bt-daemon/tests/agent_integration.rs b/bt-daemon/tests/agent_integration.rs index 494c2bca..7041227b 100644 --- a/bt-daemon/tests/agent_integration.rs +++ b/bt-daemon/tests/agent_integration.rs @@ -541,7 +541,16 @@ fn request_helpers_recognize_tool_results_and_advertised_tools() { async fn opencode_v2_session_emits_traces() { use base64::Engine; let inference = OpenAiMock::new(|_, request| { - if request.contains_text("model failure marker") { + 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"}}), @@ -866,6 +875,55 @@ async fn opencode_v2_session_emits_traces() { .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) + .env("PWD", &workspace) + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("XDG_CACHE_HOME", home.join("cache")); + 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 @@ -938,15 +996,33 @@ async fn opencode_v2_subagent_is_nested() { .and_then(|ms| ms.iter().rev().find(|m| m["role"] == "user")) .map(Value::to_string) .unwrap_or_default(); - let tool_result = messages.is_some_and(|ms| ms.iter().any(|m| m["role"] == "tool")); + 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() && !tool_result { + } 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")) } @@ -1011,6 +1087,15 @@ async fn opencode_v2_subagent_is_nested() { 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)] @@ -1213,6 +1298,41 @@ async fn run_v2_compaction_after_idle(native: bool) { assert!(!resumed_llm["input"] .to_string() .contains("legacy compaction")); + if native { + client + .post(format!("{url}/api/session/{session_id}/compact")) + .basic_auth("opencode", Some("integration-test-password")) + .json(&json!({"delivery":"queue"})) + .send() + .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(); } diff --git a/bt-daemon/tests/opencode_translator.rs b/bt-daemon/tests/opencode_translator.rs index 7be3a0cf..a2d36f9c 100644 --- a/bt-daemon/tests/opencode_translator.rs +++ b/bt-daemon/tests/opencode_translator.rs @@ -2023,3 +2023,391 @@ fn opencode_v2_routes_resumed_nested_sessions_under_external_parent() { 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/src/plugins/opencode/content/src/server.test.ts b/src/plugins/opencode/content/src/server.test.ts index 83aed215..0a29bad8 100644 --- a/src/plugins/opencode/content/src/server.test.ts +++ b/src/plugins/opencode/content/src/server.test.ts @@ -9,6 +9,9 @@ vi.mock("./runtime/daemon-client", () => ({ claimManagedTracingInstance: () => true, resolveDaemonTraceSettings: (settings: unknown) => settings, DaemonClient: class { + async prepare() { + return true; + } async log(event: Record) { state.logs.push(event); return true; diff --git a/src/plugins/opencode/content/src/server.ts b/src/plugins/opencode/content/src/server.ts index 4bcc033e..7d463345 100644 --- a/src/plugins/opencode/content/src/server.ts +++ b/src/plugins/opencode/content/src/server.ts @@ -93,6 +93,7 @@ export default { } if (!config.tracingEnabled || !claimManagedTracingInstance("opencode")) return; const daemon = new DaemonClient({ source: "opencode", pluginVersion: PLUGIN_VERSION }); + await daemon.prepare(); const abort = new AbortController(); const roots = new Map(); const started = new Set(); diff --git a/src/runtime/js-daemon-client/src/index.ts b/src/runtime/js-daemon-client/src/index.ts index 537adf62..1ecb2b0e 100644 --- a/src/runtime/js-daemon-client/src/index.ts +++ b/src/runtime/js-daemon-client/src/index.ts @@ -158,7 +158,6 @@ export class DaemonClient { private nextId = 1 private pending = new Map() private connecting?: Promise - private writable?: { promise: Promise; resolve: () => void; reject: (error: unknown) => void } private queue: Promise = Promise.resolve() private inFlightEvents = new Set>() private warned = new Set() @@ -174,33 +173,44 @@ export class DaemonClient { } } + /** Start the connection early without delaying the host indefinitely. */ + async prepare(timeoutMs = 5_000): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + this.ensureConnected().then(() => true, () => false), + new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs) }), + ]) + } finally { + if (timer) clearTimeout(timer) + } + } + async log(envelope: DaemonEnvelope): Promise { return this.serial(() => this.sendEvent(envelope)) } /** Submit events in call order without waiting for earlier acknowledgements. */ logPipelined(envelope: DaemonEnvelope): Promise { - const request = this.sendEvent(envelope, true) + const request = this.sendEvent(envelope) this.inFlightEvents.add(request) void request.finally(() => this.inFlightEvents.delete(request)) return request } - private async sendEvent(envelope: DaemonEnvelope, pipelined = false): Promise { + 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, pipelined)) as { accepted?: boolean } - if (pipelined) await this.ensureConnected() + 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, pipelined)) as { accepted?: boolean } - if (pipelined) await this.ensureConnected() + const result = (await this.request("event.log", event)) as { accepted?: boolean } return result.accepted === true } catch (retryError) { this.warnOnce(`event:${String(retryError)}`) @@ -254,9 +264,8 @@ export class DaemonClient { return next } - private async request(method: string, params: unknown, pipelined = false): Promise { - if (pipelined) await this.ensureWritable() - else await this.ensureConnected() + private async request(method: string, params: unknown): Promise { + await this.ensureConnected() const socket = this.socket if (!socket || socket.destroyed) throw new Error("daemon socket is unavailable") @@ -272,21 +281,6 @@ export class DaemonClient { return response } - // The daemon reads requests in socket order. Write initialize first, then - // permit queued events to follow without waiting for its response. - private async ensureWritable(): Promise { - if (this.socket && !this.socket.destroyed) return - if (!this.writable) { - let resolve = () => {} - let reject = (_error: unknown) => {} - const promise = new Promise((res, rej) => { resolve = res; reject = rej }) - this.writable = { promise, resolve, reject } - } - const writable = this.writable - void this.ensureConnected().catch((error) => writable.reject(error)) - return writable.promise - } - private async ensureConnected(): Promise { if (this.connecting) return this.connecting if (this.socket && !this.socket.destroyed) return @@ -327,16 +321,14 @@ export class DaemonClient { socket.off("error", onError) this.attach(socket) try { - const initialization = this.requestOnConnectedSocket("initialize", { + const result = (await this.requestOnConnectedSocket("initialize", { protocol_version: DAEMON_PROTOCOL_VERSION, client: { source: this.options.source, plugin_version: this.options.pluginVersion, pid: process.pid, }, - }) - this.writable?.resolve() - const result = (await initialization) as { + })) as { protocol_version: number capabilities?: { sources?: string[] } } @@ -421,8 +413,6 @@ export class DaemonClient { } private disconnect(reason: unknown): void { - this.writable?.reject(reason) - this.writable = undefined const socket = this.socket this.socket = undefined if (socket && !socket.destroyed) socket.destroy() diff --git a/src/runtime/js-daemon-client/tests/client.test.ts b/src/runtime/js-daemon-client/tests/client.test.ts index f684c8e5..9ff52910 100644 --- a/src/runtime/js-daemon-client/tests/client.test.ts +++ b/src/runtime/js-daemon-client/tests/client.test.ts @@ -281,6 +281,7 @@ test("retries pipelined events in order after a lost connection", async () => { 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. @@ -293,6 +294,7 @@ test("pipelined events do not report success when daemon initialization is incom 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`) } @@ -303,7 +305,27 @@ test("pipelined events do not report success when daemon initialization is incom 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 }) }) + +test("connection preparation is bounded when the daemon is slow", async () => { + const temp = mkdtempSync(join(tmpdir(), "bt-js-prepare-")) + const endpoint = process.platform === "win32" ? `\\\\.\\pipe\\bt-js-prepare-${process.pid}-${Date.now()}` : join(temp, "daemon.sock") + const server = createServer((socket) => { + socket.on("error", () => {}) + socket.once("data", (data) => { + const request = JSON.parse(data.toString().trim()) + setTimeout(() => socket.write(`${JSON.stringify({jsonrpc:"2.0",id:request.id,result:{protocol_version:1,capabilities:{sources:["opencode"]}}})}\n`), 80) + }) + }) + await new Promise((resolve, reject) => server.listen(endpoint, resolve).once("error", reject)) + const client = new DaemonClient({ source:"opencode", socketPath:endpoint, requestTimeoutMs:500 }) + assert.equal(await client.prepare(10), false) + assert.equal(await client.prepare(1_000), true) + await client.close() + await new Promise((resolve) => server.close(() => resolve())) + rmSync(temp, { recursive:true, force:true }) +}) From 95b512a88021f875f89e245bb3e7b59898872c33 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Thu, 5 Nov 2026 03:48:06 +0800 Subject: [PATCH 18/21] Keep OpenCode startup nonblocking --- bt-daemon/tests/agent_integration.rs | 36 +++++++++++-------- src/plugins/opencode/content/README.md | 2 +- .../opencode/content/src/server.test.ts | 3 +- src/plugins/opencode/content/src/server.ts | 1 - src/runtime/js-daemon-client/src/index.ts | 13 ------- .../js-daemon-client/tests/client.test.ts | 19 ---------- 6 files changed, 25 insertions(+), 49 deletions(-) diff --git a/bt-daemon/tests/agent_integration.rs b/bt-daemon/tests/agent_integration.rs index 7041227b..8d2cbb16 100644 --- a/bt-daemon/tests/agent_integration.rs +++ b/bt-daemon/tests/agent_integration.rs @@ -1232,11 +1232,14 @@ async fn run_v2_compaction_after_idle(native: bool) { .find(|row| row_contains(row, &["Turn 1", "legacy compaction"])) .unwrap()["root_span_id"] .clone(); - client + let compact_request = client .post(format!("{url}/api/session/{session_id}/compact")) .basic_auth("opencode", Some("integration-test-password")) .json(&json!({"delivery":"queue"})) - .send() + .build() + .unwrap(); + client + .execute(compact_request.try_clone().unwrap()) .await .unwrap() .error_for_status() @@ -1300,10 +1303,7 @@ async fn run_v2_compaction_after_idle(native: bool) { .contains("legacy compaction")); if native { client - .post(format!("{url}/api/session/{session_id}/compact")) - .basic_auth("opencode", Some("integration-test-password")) - .json(&json!({"delivery":"queue"})) - .send() + .execute(compact_request) .await .unwrap() .error_for_status() @@ -1341,7 +1341,7 @@ async fn run_v2_compaction_after_idle(native: bool) { #[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::{AsyncReadExt, AsyncWriteExt}; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; let inference = @@ -1367,19 +1367,27 @@ async fn opencode_v2_standalone_delivers_events_when_daemon_acknowledgements_are return; }; let (mut client_read, mut client_write) = incoming.into_split(); - let (mut daemon_read, mut daemon_write) = outgoing.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 buffer = [0u8; 8192]; + let mut daemon_read = BufReader::new(daemon_read); + let mut buffer = Vec::new(); let mut client_closed = false; - while let Ok(n) = daemon_read.read(&mut buffer).await { - if n == 0 { - break; + 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; } - tokio::time::sleep(std::time::Duration::from_millis(4000)).await; - if !client_closed && client_write.write_all(&buffer[..n]).await.is_err() { + if !client_closed && client_write.write_all(&buffer).await.is_err() { client_closed = true; } } diff --git a/src/plugins/opencode/content/README.md b/src/plugins/opencode/content/README.md index 505271ed..fede56c5 100644 --- a/src/plugins/opencode/content/README.md +++ b/src/plugins/opencode/content/README.md @@ -38,7 +38,7 @@ 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 daemon acknowledgements. As with +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. diff --git a/src/plugins/opencode/content/src/server.test.ts b/src/plugins/opencode/content/src/server.test.ts index 0a29bad8..fc8fe813 100644 --- a/src/plugins/opencode/content/src/server.test.ts +++ b/src/plugins/opencode/content/src/server.test.ts @@ -10,6 +10,7 @@ vi.mock("./runtime/daemon-client", () => ({ resolveDaemonTraceSettings: (settings: unknown) => settings, DaemonClient: class { async prepare() { + await state.gate; return true; } async log(event: Record) { @@ -153,7 +154,7 @@ describe("OpenCode 2 capture", () => { payload: { data: { sessionID: "child" } }, }); }); - it("sends later events without waiting for acknowledgements and drains them on cleanup", async () => { + it("starts and forwards events without waiting for the daemon, then drains on cleanup", async () => { state.logs.length = 0; state.closed = false; let release = () => {}; diff --git a/src/plugins/opencode/content/src/server.ts b/src/plugins/opencode/content/src/server.ts index 7d463345..4bcc033e 100644 --- a/src/plugins/opencode/content/src/server.ts +++ b/src/plugins/opencode/content/src/server.ts @@ -93,7 +93,6 @@ export default { } if (!config.tracingEnabled || !claimManagedTracingInstance("opencode")) return; const daemon = new DaemonClient({ source: "opencode", pluginVersion: PLUGIN_VERSION }); - await daemon.prepare(); const abort = new AbortController(); const roots = new Map(); const started = new Set(); diff --git a/src/runtime/js-daemon-client/src/index.ts b/src/runtime/js-daemon-client/src/index.ts index 1ecb2b0e..295db4f3 100644 --- a/src/runtime/js-daemon-client/src/index.ts +++ b/src/runtime/js-daemon-client/src/index.ts @@ -173,19 +173,6 @@ export class DaemonClient { } } - /** Start the connection early without delaying the host indefinitely. */ - async prepare(timeoutMs = 5_000): Promise { - let timer: ReturnType | undefined - try { - return await Promise.race([ - this.ensureConnected().then(() => true, () => false), - new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs) }), - ]) - } finally { - if (timer) clearTimeout(timer) - } - } - async log(envelope: DaemonEnvelope): Promise { return this.serial(() => this.sendEvent(envelope)) } diff --git a/src/runtime/js-daemon-client/tests/client.test.ts b/src/runtime/js-daemon-client/tests/client.test.ts index 9ff52910..dd5da4b9 100644 --- a/src/runtime/js-daemon-client/tests/client.test.ts +++ b/src/runtime/js-daemon-client/tests/client.test.ts @@ -310,22 +310,3 @@ test("pipelined events do not report success when daemon initialization is incom await new Promise((resolve) => server.close(() => resolve())) rmSync(temp, { recursive: true, force: true }) }) - -test("connection preparation is bounded when the daemon is slow", async () => { - const temp = mkdtempSync(join(tmpdir(), "bt-js-prepare-")) - const endpoint = process.platform === "win32" ? `\\\\.\\pipe\\bt-js-prepare-${process.pid}-${Date.now()}` : join(temp, "daemon.sock") - const server = createServer((socket) => { - socket.on("error", () => {}) - socket.once("data", (data) => { - const request = JSON.parse(data.toString().trim()) - setTimeout(() => socket.write(`${JSON.stringify({jsonrpc:"2.0",id:request.id,result:{protocol_version:1,capabilities:{sources:["opencode"]}}})}\n`), 80) - }) - }) - await new Promise((resolve, reject) => server.listen(endpoint, resolve).once("error", reject)) - const client = new DaemonClient({ source:"opencode", socketPath:endpoint, requestTimeoutMs:500 }) - assert.equal(await client.prepare(10), false) - assert.equal(await client.prepare(1_000), true) - await client.close() - await new Promise((resolve) => server.close(() => resolve())) - rmSync(temp, { recursive:true, force:true }) -}) From b4a3d3e4b50f06a40e7f563206d3932ecd12fc47 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Thu, 5 Nov 2026 20:19:26 +0800 Subject: [PATCH 19/21] Harden OpenCode event delivery and resume --- bt-daemon/src/managed_run/mod.rs | 8 +- bt-daemon/src/managed_run/opencode.rs | 99 ++++++++++++------- bt-daemon/src/server/rpc.rs | 13 +++ bt-daemon/src/translate/opencode_v2.rs | 31 +++--- bt-daemon/src/wire/methods.rs | 3 + bt-daemon/tests/agent_integration.rs | 98 +++++------------- bt-daemon/tests/opencode_translator.rs | 49 ++------- bt-daemon/tests/pipeline.rs | 26 +++++ .../opencode/content/src/server.test.ts | 69 ++++++++++--- src/plugins/opencode/content/src/server.ts | 35 +++++-- src/runtime/js-daemon-client/src/index.ts | 22 +++-- .../js-daemon-client/tests/client.test.ts | 53 +++++++++- 12 files changed, 306 insertions(+), 200 deletions(-) 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 78476b66..b302efa3 100644 --- a/bt-daemon/src/managed_run/opencode.rs +++ b/bt-daemon/src/managed_run/opencode.rs @@ -5,19 +5,49 @@ use crate::agents::OpenCode; use crate::args::RunHookCommand; use std::ffi::OsString; -fn is_v2() -> bool { - let executable = crate::env::var_os("OPENCODE_BIN").unwrap_or_else(|| "opencode".into()); - crate::subprocess::background_command(executable) - .arg("--version") - .output() - .ok() - .filter(|output| output.status.success()) - .is_some_and(|output| { - String::from_utf8_lossy(&output.stdout) - .split_whitespace() - .last() - .is_some_and(|v| v.trim_start_matches('v').starts_with("2.")) +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 { @@ -53,19 +83,6 @@ impl ManagedRun for OpenCode { ("OPENCODE_BIN", "opencode") } - fn check(&self, args: &[OsString], _terminal: super::Terminal) -> anyhow::Result<()> { - if is_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"); - } - Ok(()) - } - 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") @@ -92,18 +109,7 @@ impl ManagedRun for OpenCode { _hook_command: &RunHookCommand, _managed_run_id: &str, ) -> anyhow::Result { - let v2 = is_v2(); - 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() - }) + inject_for_version(false, &[]) } } @@ -148,6 +154,25 @@ mod tests { ); } + #[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")]; 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/opencode_v2.rs b/bt-daemon/src/translate/opencode_v2.rs index f76a3889..9226d6b1 100644 --- a/bt-daemon/src/translate/opencode_v2.rs +++ b/bt-daemon/src/translate/opencode_v2.rs @@ -76,6 +76,15 @@ struct NativeWindow { 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, @@ -814,17 +823,10 @@ impl OpenCodeV2Translator { }]); } let input = step.context.map(normalize_context).unwrap_or_else(|| { - let window = s.native_window.as_ref().filter(|window| { - match (window.model.as_ref(), step.model.as_ref()) { - (Some(a), Some(b)) => { - a.id.is_some() - && a.provider_id.is_some() - && a.id == b.id - && a.provider_id == b.provider_id - } - _ => false, - } - }); + 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), @@ -918,12 +920,7 @@ impl OpenCodeV2Translator { .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 - && matches!( - (window.model.as_ref(), model.as_ref()), - (Some(a), Some(b)) if a.id.is_some() && a.provider_id.is_some() - && a.id == b.id && a.provider_id == b.provider_id - ) + window.history_boundary <= tail && window.matches_model(model.as_ref()) }); let (mut input, start) = window.map_or_else( || (Vec::new(), 0), 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 8d2cbb16..4d94f597 100644 --- a/bt-daemon/tests/agent_integration.rs +++ b/bt-daemon/tests/agent_integration.rs @@ -54,6 +54,20 @@ fn row_contains(row: &Value, fragments: &[&str]) -> bool { } #[cfg(unix)] +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()), + ] +} + #[tokio::test] async fn agent_output_does_not_wait_for_inherited_descendant_handles() { let world = AgentTestWorld::start().await; @@ -618,12 +632,7 @@ async fn opencode_v2_session_emits_traces() { "say hello", ]) .current_dir(&workspace) - .env("PWD", &workspace) - .env("HOME", &home) - .env("USERPROFILE", &home) - .env("XDG_CONFIG_HOME", home.join("config")) - .env("XDG_DATA_HOME", home.join("data")) - .env("XDG_CACHE_HOME", home.join("cache")) + .envs(opencode_test_env(&workspace, &home)) .env( "OPENCODE_BIN", std::env::var_os("OPENCODE_V2_BIN").expect("set OPENCODE_V2_BIN"), @@ -732,12 +741,7 @@ async fn opencode_v2_session_emits_traces() { "persistent marker", ]) .current_dir(&workspace) - .env("PWD", &workspace) - .env("HOME", &home) - .env("USERPROFILE", &home) - .env("XDG_CONFIG_HOME", home.join("config")) - .env("XDG_DATA_HOME", home.join("data")) - .env("XDG_CACHE_HOME", home.join("cache")); + .envs(opencode_test_env(&workspace, &home)); world.configure(&mut persistent); let output = world.output(&mut persistent).await; assert!( @@ -778,12 +782,7 @@ async fn opencode_v2_session_emits_traces() { "permission marker", ]) .current_dir(&workspace) - .env("PWD", &workspace) - .env("HOME", &home) - .env("USERPROFILE", &home) - .env("XDG_CONFIG_HOME", home.join("config")) - .env("XDG_DATA_HOME", home.join("data")) - .env("XDG_CACHE_HOME", home.join("cache")); + .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; @@ -823,12 +822,7 @@ async fn opencode_v2_session_emits_traces() { "interrupt marker", ]) .current_dir(&workspace) - .env("PWD", &workspace) - .env("HOME", &home) - .env("USERPROFILE", &home) - .env("XDG_CONFIG_HOME", home.join("config")) - .env("XDG_DATA_HOME", home.join("data")) - .env("XDG_CACHE_HOME", home.join("cache")); + .envs(opencode_test_env(&workspace, &home)); world.configure(&mut interrupted); let _output = world.output(&mut interrupted).await; world @@ -852,12 +846,7 @@ async fn opencode_v2_session_emits_traces() { "model failure marker", ]) .current_dir(&workspace) - .env("PWD", &workspace) - .env("HOME", &home) - .env("USERPROFILE", &home) - .env("XDG_CONFIG_HOME", home.join("config")) - .env("XDG_DATA_HOME", home.join("data")) - .env("XDG_CACHE_HOME", home.join("cache")); + .envs(opencode_test_env(&workspace, &home)); world.configure(&mut failed); let _output = world.output(&mut failed).await; let failure_rows = world @@ -899,12 +888,7 @@ async fn opencode_v2_session_emits_traces() { "model recovery marker", ]) .current_dir(&workspace) - .env("PWD", &workspace) - .env("HOME", &home) - .env("USERPROFILE", &home) - .env("XDG_CONFIG_HOME", home.join("config")) - .env("XDG_DATA_HOME", home.join("data")) - .env("XDG_CACHE_HOME", home.join("cache")); + .envs(opencode_test_env(&workspace, &home)); world.configure(&mut resumed); let output = world.output(&mut resumed).await; assert!( @@ -939,12 +923,7 @@ async fn opencode_v2_session_emits_traces() { "image tool marker", ]) .current_dir(&workspace) - .env("PWD", &workspace) - .env("HOME", &home) - .env("USERPROFILE", &home) - .env("XDG_CONFIG_HOME", home.join("config")) - .env("XDG_DATA_HOME", home.join("data")) - .env("XDG_CACHE_HOME", home.join("cache")) + .envs(opencode_test_env(&workspace, &home)) .env("OPENCODE_BIN", std::env::var_os("OPENCODE_V2_BIN").unwrap()) .env( "BRAINTRUST_TRACE_OPENCODE_PLUGIN_SPEC", @@ -1049,12 +1028,7 @@ async fn opencode_v2_subagent_is_nested() { "spawn subagent", ]) .current_dir(&workspace) - .env("PWD", &workspace) - .env("HOME", &home) - .env("USERPROFILE", &home) - .env("XDG_CONFIG_HOME", home.join("config")) - .env("XDG_DATA_HOME", home.join("data")) - .env("XDG_CACHE_HOME", home.join("cache")) + .envs(opencode_test_env(&workspace, &home)) .env( "OPENCODE_BIN", std::env::var_os("OPENCODE_V2_BIN").expect("set OPENCODE_V2_BIN"), @@ -1162,12 +1136,7 @@ async fn run_v2_compaction_after_idle(native: bool) { &port.to_string(), ]) .current_dir(&workspace) - .env("PWD", &workspace) - .env("HOME", &home) - .env("USERPROFILE", &home) - .env("XDG_CONFIG_HOME", home.join("config")) - .env("XDG_DATA_HOME", home.join("data")) - .env("XDG_CACHE_HOME", home.join("cache")) + .envs(opencode_test_env(&workspace, &home)) .env("OPENCODE_PASSWORD", "integration-test-password") .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) @@ -1203,12 +1172,7 @@ async fn run_v2_compaction_after_idle(native: bool) { "legacy compaction", ]) .current_dir(&workspace) - .env("PWD", &workspace) - .env("HOME", &home) - .env("USERPROFILE", &home) - .env("XDG_CONFIG_HOME", home.join("config")) - .env("XDG_DATA_HOME", home.join("data")) - .env("XDG_CACHE_HOME", home.join("cache")) + .envs(opencode_test_env(&workspace, &home)) .env("OPENCODE_PASSWORD", "integration-test-password"); let output = world.output(&mut run).await; assert!( @@ -1267,12 +1231,7 @@ async fn run_v2_compaction_after_idle(native: bool) { "legacy after compaction", ]) .current_dir(&workspace) - .env("PWD", &workspace) - .env("HOME", &home) - .env("USERPROFILE", &home) - .env("XDG_CONFIG_HOME", home.join("config")) - .env("XDG_DATA_HOME", home.join("data")) - .env("XDG_CACHE_HOME", home.join("cache")) + .envs(opencode_test_env(&workspace, &home)) .env("OPENCODE_PASSWORD", "integration-test-password"); let output = world.output(&mut resume).await; assert!( @@ -1406,12 +1365,7 @@ async fn opencode_v2_standalone_delivers_events_when_daemon_acknowledgements_are "slow ack marker", ]) .current_dir(&workspace) - .env("PWD", &workspace) - .env("HOME", &home) - .env("USERPROFILE", &home) - .env("XDG_CONFIG_HOME", home.join("config")) - .env("XDG_DATA_HOME", home.join("data")) - .env("XDG_CACHE_HOME", home.join("cache")); + .envs(opencode_test_env(&workspace, &home)); world.configure(&mut run); run.env("BRAINTRUST_DAEMON_SOCKET", &proxy_path); let output = world.output(&mut run).await; diff --git a/bt-daemon/tests/opencode_translator.rs b/bt-daemon/tests/opencode_translator.rs index a2d36f9c..7d5564d9 100644 --- a/bt-daemon/tests/opencode_translator.rs +++ b/bt-daemon/tests/opencode_translator.rs @@ -1081,12 +1081,7 @@ fn opencode_v2_emits_tool_span_from_native_events() { ]; let mut ops = Vec::new(); for (i, (name, data)) in events.into_iter().enumerate() { - let mut e = event( - name, - i as i64 + 1, - json!({"id":format!("event-{i}"),"type":name,"data":data}), - ); - e.source_version = Some("2.2.5".into()); + let e = v2_event(name, i as i64 + 1, data); ops.extend(translator.handle(&e, &ctx).unwrap()); } let rows = reduce(ops); @@ -1134,12 +1129,7 @@ fn opencode_v2_preserves_attached_images_in_turn_and_model_input() { ]; let mut ops = Vec::new(); for (i, (name, data)) in events.into_iter().enumerate() { - let mut e = event( - name, - i as i64 + 1, - json!({"id":format!("event-{i}"),"type":name,"data":data}), - ); - e.source_version = Some("2.2.5".into()); + let e = v2_event(name, i as i64 + 1, data); ops.extend(translator.handle(&e, &ctx).unwrap()); } let rows = reduce(ops); @@ -1346,12 +1336,7 @@ fn opencode_v2_distinguishes_denied_and_failed_tools() { ]; let mut ops = Vec::new(); for (i, (name, data)) in events.into_iter().enumerate() { - let mut e = event( - name, - i as i64 + 1, - json!({"id":format!("v2-{i}"),"data":data}), - ); - e.source_version = Some("2.2.5".into()); + let e = v2_event(name, i as i64 + 1, data); ops.extend(translator.handle(&e, &ctx).unwrap()); } let rows = reduce(ops); @@ -1503,12 +1488,7 @@ fn opencode_v2_permission_denied_before_tool_call_creates_one_tool_span() { ]; let mut ops = Vec::new(); for (i, (name, data)) in events.into_iter().enumerate() { - let mut e = event( - name, - i as i64 + 1, - json!({"id":format!("permission-{i}"),"data":data}), - ); - e.source_version = Some("2.2.5".into()); + let e = v2_event(name, i as i64 + 1, data); ops.extend(translator.handle(&e, &ctx).unwrap()); } let rows = reduce(ops); @@ -1567,12 +1547,7 @@ fn opencode_v2_preserves_provider_defined_context_and_tool_content() { ]; 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!("provider-{i}"),"data":data}), - ); - envelope.source_version = Some("2.2.5".into()); + let envelope = v2_event(name, i as i64 + 1, data); ops.extend(translator.handle(&envelope, &ctx).unwrap()); } let rows = reduce(ops); @@ -1636,12 +1611,7 @@ fn opencode_v2_native_compaction_window_is_scoped_to_its_model() { ]; 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!("native-{i}"),"data":data}), - ); - envelope.source_version = Some("2.2.5".into()); + let envelope = v2_event(name, i as i64 + 1, data); ops.extend(translator.handle(&envelope, &ctx).unwrap()); } let rows = reduce(ops); @@ -1694,12 +1664,7 @@ fn opencode_v2_preserves_opaque_provider_compaction_output() { ]; 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!("opaque-{i}"),"data":data}), - ); - envelope.source_version = Some("2.2.5".into()); + let envelope = v2_event(name, i as i64 + 1, data); ops.extend(translator.handle(&envelope, &ctx).unwrap()); } let rows = reduce(ops); diff --git a/bt-daemon/tests/pipeline.rs b/bt-daemon/tests/pipeline.rs index 7a210569..d488da16 100644 --- a/bt-daemon/tests/pipeline.rs +++ b/bt-daemon/tests/pipeline.rs @@ -4578,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/src/plugins/opencode/content/src/server.test.ts b/src/plugins/opencode/content/src/server.test.ts index fc8fe813..06d1ca48 100644 --- a/src/plugins/opencode/content/src/server.test.ts +++ b/src/plugins/opencode/content/src/server.test.ts @@ -51,7 +51,12 @@ describe("OpenCode 2 capture", () => { }> = []; const cleanup = await plugin.setup({ app: { version: "2.2.5" }, - session: { async hook() {} }, + session: { + async hook() {}, + async get() { + return {}; + }, + }, location: { directory: "/test" }, event: { async *subscribe() {} }, tool: { @@ -83,7 +88,12 @@ describe("OpenCode 2 capture", () => { ]; const cleanup = await plugin.setup({ app: { version: "2.2.5" }, - session: { async hook() {} }, + session: { + async hook() {}, + async get() { + return {}; + }, + }, location: { directory: "/test" }, tool: { async transform(callback) { @@ -96,7 +106,9 @@ describe("OpenCode 2 capture", () => { }, }, }); + 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), @@ -122,6 +134,9 @@ describe("OpenCode 2 capture", () => { app: { version: "2.2.5" }, location: { directory: "/test" }, session: { + async get() { + return {}; + }, async hook(_name, callback) { capture = callback; }, @@ -143,7 +158,9 @@ describe("OpenCode 2 capture", () => { }, }, }); + 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", @@ -154,7 +171,7 @@ describe("OpenCode 2 capture", () => { payload: { data: { sessionID: "child" } }, }); }); - it("starts and forwards events without waiting for the daemon, then drains on cleanup", async () => { + it("starts and forwards without waiting for daemon acknowledgements", async () => { state.logs.length = 0; state.closed = false; let release = () => {}; @@ -164,7 +181,12 @@ describe("OpenCode 2 capture", () => { const cleanup = await plugin.setup({ app: { version: "2.0.0" }, location: { directory: "/test" }, - session: { async hook() {} }, + session: { + async hook() {}, + async get() { + return {}; + }, + }, tool: { async transform(callback) { callback({ add() {} }); @@ -177,19 +199,44 @@ describe("OpenCode 2 capture", () => { }, }, }); - let finished = false; - const closing = cleanup?.().then(() => { - finished = true; - }); - await new Promise((resolve) => setTimeout(resolve, 0)); + await vi.waitFor(() => expect(state.logs).toHaveLength(2)); + const closing = cleanup?.(); expect(state.logs.map((entry) => entry.event)).toEqual([ "session.created", "session.execution.succeeded", ]); - expect(finished).toBe(false); release(); await closing; - expect(state.closed).toBe(true); + 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 index 4bcc033e..a72c1970 100644 --- a/src/plugins/opencode/content/src/server.ts +++ b/src/plugins/opencode/content/src/server.ts @@ -19,6 +19,7 @@ interface Context { location: { directory: string }; event: { subscribe(options: { signal: AbortSignal }): AsyncIterable }; session: { + get(input: { sessionID: string }): Promise<{ parentID?: string | null }>; hook( name: "context", callback: (event: { @@ -96,8 +97,26 @@ export default { 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>(); - let pending = Promise.resolve(); await ctx.session.hook("context", (event) => { try { const queue = contexts.get(event.sessionID) ?? []; @@ -124,9 +143,9 @@ export default { if (!session) continue; if (event.type === "session.created") { const parent = event.data?.parentID; - roots.set(session, parent ? (roots.get(parent) ?? parent) : session); + roots.set(session, parent ? await rootFor(parent) : session); } - const root = roots.get(session) ?? session; + const root = roots.get(session) ?? (await rootFor(session)); const envelope = { source: "opencode", source_version: ctx.app.version, @@ -150,20 +169,16 @@ export default { event.type === "session.deleted" ) contexts.delete(session); - const writes = []; if (first && event.type !== "session.created") { - writes.push(daemon.logPipelined({ ...envelope, event: "session_start" })); + void daemon.logPipelined({ ...envelope, event: "session_start" }); } - if (context) - writes.push(daemon.logPipelined({ ...envelope, event: "model.context", ...context })); - writes.push(daemon.logPipelined(envelope)); - pending = Promise.all([pending, ...writes]).then(() => {}); + if (context) void daemon.logPipelined({ ...envelope, event: "model.context", ...context }); + void daemon.logPipelined(envelope); } })().catch(() => {}); return async () => { abort.abort(); await task; - await pending; await daemon.close(); }; }, diff --git a/src/runtime/js-daemon-client/src/index.ts b/src/runtime/js-daemon-client/src/index.ts index 295db4f3..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,9 @@ 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() @@ -179,7 +177,10 @@ export class DaemonClient { /** Submit events in call order without waiting for earlier acknowledgements. */ logPipelined(envelope: DaemonEnvelope): Promise { - const request = this.sendEvent(envelope) + 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 @@ -242,6 +243,12 @@ export class DaemonClient { 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( @@ -284,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 @@ -317,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}`) @@ -325,6 +332,7 @@ 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) { if (this.socket === socket) this.disconnect(error) diff --git a/src/runtime/js-daemon-client/tests/client.test.ts b/src/runtime/js-daemon-client/tests/client.test.ts index dd5da4b9..c40d0c73 100644 --- a/src/runtime/js-daemon-client/tests/client.test.ts +++ b/src/runtime/js-daemon-client/tests/client.test.ts @@ -211,7 +211,7 @@ test("pipelines ordered events before receiving daemon acknowledgements", async 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"] } } })}\n`), 100) + 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`) @@ -257,7 +257,7 @@ test("retries pipelined events in order after a lost connection", async () => { 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"] } } })}\n`) + 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 @@ -310,3 +310,52 @@ test("pipelined events do not report success when daemon initialization is incom 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) { + 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}) + } +}) From 3f3fa4043146d651b8c37343421f95e0085417fe Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Thu, 5 Nov 2026 20:23:03 +0800 Subject: [PATCH 20/21] Stop mock daemon reading after pipeline failure --- src/runtime/js-daemon-client/tests/client.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/runtime/js-daemon-client/tests/client.test.ts b/src/runtime/js-daemon-client/tests/client.test.ts index c40d0c73..89ca33f2 100644 --- a/src/runtime/js-daemon-client/tests/client.test.ts +++ b/src/runtime/js-daemon-client/tests/client.test.ts @@ -335,6 +335,10 @@ for (const orderedPipeline of [false, true]) test(`does not deliver later events 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 } From aca4641059caf6b35af1a79db6dedc802b470248 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Thu, 5 Nov 2026 20:33:13 +0800 Subject: [PATCH 21/21] Fix OpenCode test helper on Windows --- bt-daemon/tests/agent_integration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bt-daemon/tests/agent_integration.rs b/bt-daemon/tests/agent_integration.rs index 4d94f597..c2807683 100644 --- a/bt-daemon/tests/agent_integration.rs +++ b/bt-daemon/tests/agent_integration.rs @@ -53,7 +53,6 @@ fn row_contains(row: &Value, fragments: &[&str]) -> bool { .all(|fragment| serialized.contains(fragment)) } -#[cfg(unix)] fn opencode_test_env( workspace: &std::path::Path, home: &std::path::Path, @@ -68,6 +67,7 @@ fn opencode_test_env( ] } +#[cfg(unix)] #[tokio::test] async fn agent_output_does_not_wait_for_inherited_descendant_handles() { let world = AgentTestWorld::start().await;