fix: OpenCode event streaming + bypass permission mode

Three independent fixes for the OpenCode agent adapter:

1. Wrong API endpoints: /event/subscribe → /event, /session/{id}/prompt → /session/{id}/message
2. Untagged enum mis-dispatch: replace serde_json::from_value with manual type-field dispatch
3. Wire permissionMode "bypass" for OpenCode: allow in normalize_permission_mode() and pass
   --dangerously-skip-permissions to CLI (both spawn and spawn_streaming)

Tested with OpenCode 1.1.48 + Kimi K2.5.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
WellDunDun 2026-02-01 10:59:34 +03:00 committed by Nathan Flurry
parent 87a4e81d31
commit 9c7a08a165
2 changed files with 99 additions and 14 deletions

View file

@ -268,6 +268,9 @@ impl AgentManager {
if let Some(variant) = options.variant.as_deref() {
command.arg("--variant").arg(variant);
}
if options.permission_mode.as_deref() == Some("bypass") {
command.arg("--dangerously-skip-permissions");
}
if let Some(session_id) = options.session_id.as_deref() {
command.arg("-s").arg(session_id);
}
@ -632,6 +635,9 @@ impl AgentManager {
if let Some(variant) = options.variant.as_deref() {
command.arg("--variant").arg(variant);
}
if options.permission_mode.as_deref() == Some("bypass") {
command.arg("--dangerously-skip-permissions");
}
if let Some(session_id) = options.session_id.as_deref() {
command.arg("-s").arg(session_id);
}

View file

@ -24,12 +24,12 @@ use reqwest::Client;
use sandbox_agent_error::{AgentError, ErrorType, ProblemDetails, SandboxError};
use sandbox_agent_universal_agent_schema::{
codex as codex_schema, convert_amp, convert_claude, convert_codex, convert_opencode,
turn_ended_event, turn_started_event, AgentUnparsedData, ContentPart, ErrorData,
EventConversion, EventSource, FileAction, ItemDeltaData, ItemEventData, ItemKind, ItemRole,
ItemStatus, PermissionEventData, PermissionStatus, QuestionEventData, QuestionStatus,
ReasoningVisibility, SessionEndReason, SessionEndedData, SessionStartedData, StderrOutput,
TerminatedBy, TurnEventData, TurnPhase, UniversalEvent, UniversalEventData, UniversalEventType,
UniversalItem,
opencode as opencode_schema, turn_ended_event, turn_started_event, AgentUnparsedData,
ContentPart, ErrorData, EventConversion, EventSource, FileAction, ItemDeltaData, ItemEventData,
ItemKind, ItemRole, ItemStatus, PermissionEventData, PermissionStatus, QuestionEventData,
QuestionStatus, ReasoningVisibility, SessionEndReason, SessionEndedData, SessionStartedData,
StderrOutput, TerminatedBy, TurnEventData, TurnPhase, UniversalEvent, UniversalEventData,
UniversalEventType, UniversalItem,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@ -3655,7 +3655,7 @@ impl SessionManager {
}
};
let url = format!("{base_url}/event/subscribe");
let url = format!("{base_url}/event");
let response = match self.http_client.get(url).send().await {
Ok(response) => response,
Err(err) => {
@ -3746,12 +3746,91 @@ impl SessionManager {
if !opencode_event_matches_session(&value, &native_session_id) {
continue;
}
let conversions = match serde_json::from_value(value.clone()) {
Ok(event) => match convert_opencode::event_to_universal(&event) {
Ok(conversions) => conversions,
// Manual type-based dispatch to bypass broken #[serde(untagged)]
// enum ordering where ServerConnected (variant #5, empty properties)
// matches all events before MessageUpdated (variant #10) gets tried.
let event_type = value.get("type").and_then(|t| t.as_str()).unwrap_or("");
let conversions = match event_type {
"message.updated" => {
match serde_json::from_value::<opencode_schema::EventMessageUpdated>(value.clone()) {
Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::MessageUpdated(e)) {
Ok(c) => c,
Err(err) => vec![agent_unparsed("opencode", &err, value.clone())],
},
Err(err) => vec![agent_unparsed("opencode", &err.to_string(), value.clone())],
Err(err) => vec![agent_unparsed("opencode", &format!("message.updated: {}", err), value.clone())],
}
}
"message.part.updated" => {
match serde_json::from_value::<opencode_schema::EventMessagePartUpdated>(value.clone()) {
Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::MessagePartUpdated(e)) {
Ok(c) => c,
Err(err) => vec![agent_unparsed("opencode", &err, value.clone())],
},
Err(err) => vec![agent_unparsed("opencode", &format!("message.part.updated: {}", err), value.clone())],
}
}
"question.asked" => {
match serde_json::from_value::<opencode_schema::EventQuestionAsked>(value.clone()) {
Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::QuestionAsked(e)) {
Ok(c) => c,
Err(err) => vec![agent_unparsed("opencode", &err, value.clone())],
},
Err(err) => vec![agent_unparsed("opencode", &format!("question.asked: {}", err), value.clone())],
}
}
"permission.asked" => {
match serde_json::from_value::<opencode_schema::EventPermissionAsked>(value.clone()) {
Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::PermissionAsked(e)) {
Ok(c) => c,
Err(err) => vec![agent_unparsed("opencode", &err, value.clone())],
},
Err(err) => vec![agent_unparsed("opencode", &format!("permission.asked: {}", err), value.clone())],
}
}
"session.created" => {
match serde_json::from_value::<opencode_schema::EventSessionCreated>(value.clone()) {
Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::SessionCreated(e)) {
Ok(c) => c,
Err(err) => vec![agent_unparsed("opencode", &err, value.clone())],
},
Err(err) => vec![agent_unparsed("opencode", &format!("session.created: {}", err), value.clone())],
}
}
"session.status" => {
match serde_json::from_value::<opencode_schema::EventSessionStatus>(value.clone()) {
Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::SessionStatus(e)) {
Ok(c) => c,
Err(err) => vec![agent_unparsed("opencode", &err, value.clone())],
},
Err(err) => vec![agent_unparsed("opencode", &format!("session.status: {}", err), value.clone())],
}
}
"session.idle" => {
match serde_json::from_value::<opencode_schema::EventSessionIdle>(value.clone()) {
Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::SessionIdle(e)) {
Ok(c) => c,
Err(err) => vec![agent_unparsed("opencode", &err, value.clone())],
},
Err(err) => vec![agent_unparsed("opencode", &format!("session.idle: {}", err), value.clone())],
}
}
"session.error" => {
match serde_json::from_value::<opencode_schema::EventSessionError>(value.clone()) {
Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::SessionError(e)) {
Ok(c) => c,
Err(err) => vec![agent_unparsed("opencode", &err, value.clone())],
},
Err(err) => vec![agent_unparsed("opencode", &format!("session.error: {}", err), value.clone())],
}
}
// Informational events we can safely skip
"server.connected" | "server.heartbeat" | "session.updated"
| "session.diff" | "file.watcher.updated" => {
continue;
}
_ => {
vec![agent_unparsed("opencode", &format!("unknown event type: {}", event_type), value.clone())]
}
};
let _ = self.record_conversions(&session_id, conversions).await;
}
@ -6447,7 +6526,7 @@ fn normalize_permission_mode(
AgentId::Claude => false,
AgentId::Codex => matches!(mode, "default" | "plan" | "bypass" | "acceptEdits"),
AgentId::Amp => matches!(mode, "default" | "bypass"),
AgentId::Opencode => matches!(mode, "default"),
AgentId::Opencode => matches!(mode, "default" | "bypass"),
AgentId::Mock => matches!(mode, "default" | "plan" | "bypass"),
};
if !supported {