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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -227,6 +257,7 @@ jobs:
codex: ${{ steps.versions.outputs.codex }}
claude: ${{ steps.versions.outputs.claude }}
opencode: ${{ steps.versions.outputs.opencode }}
opencode_v2: ${{ steps.versions.outputs.opencode_v2 }}
pi: ${{ steps.versions.outputs.pi }}
steps:
- id: versions
Expand All @@ -236,6 +267,7 @@ jobs:
echo "codex=$(npm view @openai/codex version)" >> "$GITHUB_OUTPUT"
echo "claude=$(npm view @anthropic-ai/claude-code version)" >> "$GITHUB_OUTPUT"
echo "opencode=$(npm view opencode-ai version)" >> "$GITHUB_OUTPUT"
echo "opencode_v2=$(npm view @opencode/cli version)" >> "$GITHUB_OUTPUT"
echo "pi=$(npm view @earendil-works/pi-coding-agent version)" >> "$GITHUB_OUTPUT"

daemon:
Expand Down Expand Up @@ -319,6 +351,9 @@ jobs:
fi
sleep 10
done
- name: Install OpenCode 2
shell: bash
run: npm install --prefix "${{ runner.temp }}/opencode-v2" --no-audit --no-fund "@opencode/cli@${{ needs.resolve-coding-agent-versions.outputs.opencode_v2 }}"
- name: Set up Node and pnpm
uses: ./.github/actions/setup-node-pnpm
with:
Expand All @@ -339,6 +374,7 @@ jobs:
"$tarball" @opencode-ai/plugin@latest @opencode-ai/sdk@latest
echo "OPENCODE_PLUGIN=$install_dir/node_modules/@braintrust/trace-opencode/dist/index.mjs" \
>> "$GITHUB_ENV"
echo "OPENCODE_V2_PLUGIN_DIR=$install_dir/node_modules/@braintrust/trace-opencode" >> "$GITHUB_ENV"
- name: Build and install Pi extension for integration harness
shell: bash
run: |
Expand Down Expand Up @@ -376,6 +412,7 @@ jobs:
CODEX_BIN: ${{ runner.temp }}/coding-agents/node_modules/.bin/codex${{ matrix.agent_suffix }}
CLAUDE_BIN: ${{ runner.temp }}/coding-agents/node_modules/.bin/claude${{ matrix.agent_suffix }}
OPENCODE_BIN: ${{ runner.temp }}/coding-agents/node_modules/.bin/opencode${{ matrix.agent_suffix }}
OPENCODE_V2_BIN: ${{ runner.temp }}/opencode-v2/node_modules/.bin/opencode${{ matrix.agent_suffix }}
PI_BIN: ${{ runner.temp }}/coding-agents/node_modules/.bin/pi${{ matrix.agent_suffix }}
PI_EXTENSION_PATH: ${{ runner.temp }}/pi-extension/node_modules/@braintrust/pi-extension/dist/index.mjs
run: cargo test --manifest-path bt-daemon/Cargo.toml --all-features --locked --test agent_integration -- --ignored --nocapture --test-threads=1
Expand Down Expand Up @@ -426,6 +463,7 @@ jobs:
- plugins
- js-packages
- opencode-compatibility
- opencode-v2-minimum
- resolve-pi-compatibility
- pi-compatibility
- resolve-coding-agent-versions
Expand Down
44 changes: 24 additions & 20 deletions bt-daemon/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -395,26 +395,30 @@ pub(crate) async fn flush_managed_run_in(
timeout_ms: u64,
data_dir: &std::path::Path,
) -> anyhow::Result<wire::FlushResult> {
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.
Expand Down
8 changes: 7 additions & 1 deletion bt-daemon/src/hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
8 changes: 6 additions & 2 deletions bt-daemon/src/managed_run/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
137 changes: 127 additions & 10 deletions bt-daemon/src/managed_run/opencode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,54 @@
use super::{Injection, ManagedRun};
use crate::agents::OpenCode;
use crate::args::RunHookCommand;
use std::ffi::OsString;

fn opencode_managed_config(existing: Option<&str>) -> anyhow::Result<String> {
pub(super) async fn is_v2(executable: &std::ffi::OsStr) -> anyhow::Result<bool> {
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<Injection> {
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<String> {
let mut config = match existing {
Some(raw) => serde_json::from_str::<serde_json::Value>(raw)
.map_err(|error| anyhow::anyhow!("invalid OPENCODE_CONFIG_CONTENT: {error}"))?,
Expand All @@ -18,8 +64,14 @@ fn opencode_managed_config(existing: Option<&str>) -> anyhow::Result<String> {
.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));
}
Expand All @@ -31,17 +83,33 @@ impl ManagedRun for OpenCode {
("OPENCODE_BIN", "opencode")
}

fn invocation_args(&self, injection: Vec<OsString>, mut args: Vec<OsString>) -> Vec<OsString> {
if injection.first().is_some_and(|arg| arg == "--standalone")
&& args.first().is_some_and(|arg| arg == "serve")
{
// `serve` already starts its own server and does not accept --standalone.
return args;
}
if injection.first().is_some_and(|arg| arg == "--standalone")
&& args
.first()
.is_some_and(|arg| arg == "run" || arg == "mini")
{
let command = args.remove(0);
return std::iter::once(command)
.chain(injection)
.chain(args)
.collect();
}
injection.into_iter().chain(args).collect()
}

fn inject(
&self,
_hook_command: &RunHookCommand,
_managed_run_id: &str,
) -> anyhow::Result<Injection> {
let config =
opencode_managed_config(std::env::var("OPENCODE_CONFIG_CONTENT").ok().as_deref())?;
Ok(Injection {
env: vec![("OPENCODE_CONFIG_CONTENT", config)],
..Injection::default()
})
inject_for_version(false, &[])
}
}

Expand All @@ -52,7 +120,8 @@ mod tests {
#[test]
fn opencode_managed_run_preserves_inline_config_and_adds_plugin() {
let config =
opencode_managed_config(Some(r#"{"model":"test/model","plugin":["other"]}"#)).unwrap();
opencode_managed_config(Some(r#"{"model":"test/model","plugin":["other"]}"#), false)
.unwrap();
let config: serde_json::Value = serde_json::from_str(&config).unwrap();
assert_eq!(config["model"], "test/model");
assert_eq!(
Expand All @@ -71,4 +140,52 @@ mod tests {
.args
.is_empty());
}

#[test]
fn opencode_v2_managed_run_uses_server_package() {
let config =
opencode_managed_config(Some(r#"{"model":"test/model","plugin":["other"]}"#), true)
.unwrap();
let config: serde_json::Value = serde_json::from_str(&config).unwrap();
assert_eq!(config["model"], "test/model");
assert_eq!(
config["plugin"],
serde_json::json!(["other", "@braintrust/trace-opencode"])
);
}

#[test]
fn opencode_v2_rejects_external_server_arguments() {
assert!(inject_for_version(true, &["--server=http://localhost".into()]).is_err());
assert!(inject_for_version(false, &["--server=http://localhost".into()]).is_ok());
}

#[cfg(unix)]
#[tokio::test]
async fn version_detection_executes_the_selected_binary() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().unwrap();
let executable = temp.path().join("opencode");
std::fs::write(&executable, "#!/bin/sh\nprintf '2.2.5\\n'\n").unwrap();
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(is_v2(executable.as_os_str()).await.unwrap());
std::fs::write(&executable, "#!/bin/sh\nprintf '1.18.0\\n'\n").unwrap();
assert!(!is_v2(executable.as_os_str()).await.unwrap());
}

#[test]
fn opencode_v2_private_flag_follows_subcommand() {
let private = vec![OsString::from("--standalone")];
assert_eq!(
OpenCode.invocation_args(private.clone(), vec!["run".into(), "hello".into()]),
vec!["run", "--standalone", "hello"]
);
assert_eq!(
OpenCode.invocation_args(
private,
vec!["serve".into(), "--port".into(), "1234".into()]
),
vec!["serve", "--port", "1234"]
);
}
}
13 changes: 13 additions & 0 deletions bt-daemon/src/server/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand Down Expand Up @@ -139,6 +148,9 @@ pub(super) async fn serve_connection(
daemon.trigger_shutdown();
}
write_result?;
if stop_after_response {
break;
}
}
}
Ok(())
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions bt-daemon/src/translate/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading