sync from monorepo @ 2452e92e

This commit is contained in:
2026-05-08 01:59:04 +02:00
commit b03dc15371
459 changed files with 129586 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# Package: opencode_client
Rust client library for interacting with the OpenCode.ai API.
## Quick Facts
- **Type**: Library
- **Main Entry**: src/lib.rs
- **Dependencies**: reqwest, serde, serde_json, chrono
## Key Files
- `src/lib.rs` - Public API exports
- `src/types.rs` - OpenCode API type definitions (Session, Message, Part, etc.)
- `src/client.rs` - HTTP client implementation with optional logging callbacks
## Main Exports
- `OpenCodeClient` - Main API client with methods: list_sessions, list_messages, send_message
- `Session` - Session metadata and configuration
- `Message` - User or Assistant message (tagged enum)
- `MessageWithParts` - Message info + content parts
- `Part` - Text, Reasoning, or workflow parts (StepStart, StepFinish, Tool)
- `ClientError` - Error types: Http, Request, Serialization
- `LogCallback` - Type alias for logging callbacks
## Related
- Used by: web, mobile (future), desktop (future)
- Independent: Can be used in any Rust project
## Documentation
- README: ./README.md
- API spec: ../../docs/api/opencode.md
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "opencode_client"
version = "0.1.0"
edition = "2021"
[dependencies]
chrono = "0.4"
futures = "0.3"
reqwest = { version = "0.12", features = ["json", "stream"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["sync"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = "0.3"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = [
"Event",
"EventSource",
"MessageEvent",
"console"
] }
+183
View File
@@ -0,0 +1,183 @@
# opencode_client
A Rust client library for interacting with the OpenCode.ai API.
## Purpose
This package provides a type-safe, ergonomic Rust interface to the OpenCode.ai API, enabling any Rust application to interact with OpenCode sessions and messages. It's designed to be reusable across different UI frameworks and platforms (web, mobile, desktop, CLI).
**⚠️ Important: API Ownership**
**ALL OpenCode.ai API functionality lives in this package.** This includes:
- ✅ REST API endpoints (sessions, messages, files, etc.)
- 🚧 Server-Sent Events (SSE) for real-time streaming (planned)
- 🔮 WebSocket connections (if needed in future)
- 🔮 Authentication flows
- 🔮 Rate limiting and retry logic
UI packages (`web`, `mobile`, `desktop`) should remain thin presentation layers that consume this client library. Never implement API calls directly in UI code.
## Features
- **Type-safe API**: Full Rust type definitions for all OpenCode API structures
- **Async/await**: Built on `reqwest` for non-blocking HTTP operations
- **Optional logging**: Flexible logging callbacks for integration with any logging system
- **WASM compatible**: Works in browser environments via WebAssembly
- **Zero UI dependencies**: Pure business logic, usable anywhere
## Usage
### Basic Example
```rust
use opencode_client::OpenCodeClient;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = OpenCodeClient::new("http://localhost:12225");
// List all sessions
let sessions = client.list_sessions().await?;
println!("Found {} sessions", sessions.len());
// Get messages from a session
if let Some(session) = sessions.first() {
let messages = client.list_messages(&session.id).await?;
println!("Session has {} messages", messages.len());
}
Ok(())
}
```
### With Logging Callbacks
```rust
use opencode_client::OpenCodeClient;
use std::sync::Arc;
let client = OpenCodeClient::new("http://localhost:12225")
.with_logging(
Arc::new(|cat, msg| println!("[INFO] {}: {}", cat, msg)),
Arc::new(|cat, msg| println!("[SUCCESS] {}: {}", cat, msg)),
Arc::new(|cat, msg| eprintln!("[ERROR] {}: {}", cat, msg)),
);
// Now all API calls will log through your callbacks
let sessions = client.list_sessions().await?;
```
### Sending Messages
```rust
let message_with_parts = client
.send_message("session_id", "Hello, world!".to_string())
.await?;
// Access the message info and parts
println!("Message ID: {}", get_message_id(&message_with_parts.info));
for part in &message_with_parts.parts {
match part {
Part::Text(text) => println!("Text: {}", text.text),
Part::Reasoning(reasoning) => println!("Reasoning: {}", reasoning.text),
Part::Tool(tool) => println!("Tool: {} ({})", tool.tool, tool.state),
_ => {}
}
}
```
## Architecture
The client is organized into three main modules:
- **types.rs**: All OpenCode API type definitions (Session, Message, Part, etc.)
- **client.rs**: HTTP client implementation with CRUD operations
- **lib.rs**: Public API exports
The client uses tagged enums for discriminated unions (e.g., `Message::User` vs `Message::Assistant`) and serde for JSON serialization/deserialization.
## API Reference
For detailed API documentation, see:
- [OpenCode API Documentation](../../docs/api/opencode.md)
- Or run: `cargo doc --open -p opencode_client`
## Key Types
- **OpenCodeClient**: Main client struct
- **Session**: Session metadata (id, title, project, timestamps)
- **Message**: Tagged enum for User or Assistant messages
- `UserMessage`: User-sent messages with optional summary
- `AssistantMessage`: AI responses with tokens, cost, system prompt, and metadata
- **MessageWithParts**: Complete message with content parts (info + parts array)
- **Part**: Message content enum:
- `Text(TextPart)`: Regular text content
- `Reasoning(ReasoningPart)`: AI reasoning/thinking process
- `Tool(ToolPart)`: Tool execution with state tracking
- `StepStart(GenericPart)`: Step boundary marker
- `StepFinish(GenericPart)`: Step completion marker
- `Unknown`: Future-proof fallback
- **ToolPart**: Tool execution details with:
- `tool`: Tool name (string)
- `call_id`: Unique call identifier
- `state`: ToolState enum (Pending, Running, Completed, Error)
- **ToolState**: Execution state with status-specific data:
- `Pending`: Waiting to start
- `Running { input, title?, metadata?, time }`: Currently executing
- `Completed { input, output, title, metadata, time, attachments? }`: Successful completion
- `Error { input, error, metadata?, time }`: Failed execution
- **ClientError**: Error types (Http, Request, Serialization)
## Dependencies
- **reqwest**: Async HTTP client (with JSON support)
- **serde/serde_json**: JSON serialization
- **chrono**: Timestamp handling
- **wasm-bindgen/web-sys**: WASM compatibility (target-specific)
## Related Packages
- **web**: Uses this client for browser-based UI
- **mobile** (future): Will use this client for mobile apps
- **desktop** (future): Will use this client for desktop apps
## Development
```bash
# Build
cargo build -p opencode_client
# Test
cargo test -p opencode_client
# Documentation
cargo doc --open -p opencode_client
```
## Roadmap
See detailed implementation plans:
- [SSE Implementation](../../docs/building/00/sse_implementation.md) - Real-time event streaming
- [OpenCode API Reference](../../docs/building/general/opencode_api.md) - Complete API documentation
### Upcoming Features
- **SSE Event Streaming** 🚧
- Real-time message part updates
- Live session state changes
- Connection resilience and reconnection
- See: `docs/building/sse_implementation.md`
- **File Operations** 📋
- Read files from workspace
- Search files and symbols
- Track file status
- **Advanced Session Management** 📋
- Create/delete sessions
- Fork sessions
- Session diffs and summaries
## Known Issues & Differences
See [OpenCode API Documentation](../../docs/building/general/opencode_api.md) for details on differences between the official API spec and actual implementation (e.g., optional fields, missing fields, additional part types).
+337
View File
@@ -0,0 +1,337 @@
//! OpenCode API Client
//!
//! HTTP client for interacting with opencode.ai API
use crate::sse::{SseClient, SseError, SseStream};
use crate::types::{MessageWithParts, Session};
use serde::Serialize;
use std::sync::Arc;
/// Logging callback type for API client events
pub type LogCallback = Arc<dyn Fn(&str, &str) + Send + Sync>;
#[derive(Clone)]
pub struct OpenCodeClient {
base_url: String,
client: reqwest::Client,
log_info: Option<LogCallback>,
log_success: Option<LogCallback>,
log_error: Option<LogCallback>,
}
impl OpenCodeClient {
/// Create a new OpenCode API client
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
client: reqwest::Client::new(),
log_info: None,
log_success: None,
log_error: None,
}
}
/// Set logging callbacks for the client
pub fn with_logging(
mut self,
log_info: LogCallback,
log_success: LogCallback,
log_error: LogCallback,
) -> Self {
self.log_info = Some(log_info);
self.log_success = Some(log_success);
self.log_error = Some(log_error);
self
}
fn log_info(&self, category: &str, message: &str) {
if let Some(logger) = &self.log_info {
logger(category, message);
}
}
fn log_success(&self, category: &str, message: &str) {
if let Some(logger) = &self.log_success {
logger(category, message);
}
}
fn log_error(&self, category: &str, message: &str) {
if let Some(logger) = &self.log_error {
logger(category, message);
}
}
/// List all sessions
pub async fn list_sessions(&self) -> Result<Vec<Session>, ClientError> {
let url = format!("{}/session", self.base_url);
self.log_info("API", &format!("GET {}", url));
let response = self.client.get(&url).send().await?;
let status = response.status();
if !status.is_success() {
let error_body = response
.text()
.await
.unwrap_or_else(|_| String::from("(no body)"));
self.log_error("API", &format!("GET {} failed: {}", url, status));
self.log_error("API", &format!("Response body: {}", error_body));
return Err(ClientError::Http(status));
}
let text = response.text().await?;
self.log_info("API", &format!("Response: {} bytes", text.len()));
match serde_json::from_str::<Vec<Session>>(&text) {
Ok(sessions) => {
self.log_success("API", &format!("Loaded {} sessions", sessions.len()));
Ok(sessions)
}
Err(e) => {
self.log_error("API", &format!("Failed to decode sessions: {}", e));
self.log_error("API", &format!("Response body: {}", text));
Err(ClientError::Serialization(e))
}
}
}
/// Get a specific session by ID
pub async fn get_session(&self, session_id: &str) -> Result<Session, ClientError> {
let url = format!("{}/session/{}", self.base_url, session_id);
let response = self.client.get(&url).send().await?;
if !response.status().is_success() {
return Err(ClientError::Http(response.status()));
}
let session = response.json::<Session>().await?;
Ok(session)
}
/// List messages in a session
pub async fn list_messages(
&self,
session_id: &str,
) -> Result<Vec<MessageWithParts>, ClientError> {
let url = format!("{}/session/{}/message", self.base_url, session_id);
self.log_info("API", &format!("GET {}", url));
let response = self.client.get(&url).send().await?;
let status = response.status();
if !status.is_success() {
let error_body = response
.text()
.await
.unwrap_or_else(|_| String::from("(no body)"));
self.log_error("API", &format!("GET {} failed: {}", url, status));
self.log_error("API", &format!("Response body: {}", error_body));
return Err(ClientError::Http(status));
}
let text = response.text().await?;
self.log_info("API", &format!("Response: {} bytes", text.len()));
// Try to parse as array first to get individual message values
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(serde_json::Value::Array(message_values)) => {
let mut messages = Vec::new();
for (index, msg_value) in message_values.iter().enumerate() {
match serde_json::from_value::<MessageWithParts>(msg_value.clone()) {
Ok(msg) => messages.push(msg),
Err(e) => {
self.log_error(
"API",
&format!("Failed to decode message at index {}: {}", index, e),
);
self.log_error(
"API",
&format!(
"Problematic message JSON: {}",
serde_json::to_string_pretty(msg_value)
.unwrap_or_else(|_| msg_value.to_string())
),
);
return Err(ClientError::Serialization(e));
}
}
}
self.log_success("API", &format!("Loaded {} messages", messages.len()));
Ok(messages)
}
Ok(_) => {
self.log_error("API", "Response is not an array");
self.log_error("API", &format!("Response body: {}", text));
// Create a dummy error by trying to parse invalid data
let dummy_err = serde_json::from_str::<Vec<MessageWithParts>>("null").unwrap_err();
Err(ClientError::Serialization(dummy_err))
}
Err(e) => {
self.log_error("API", &format!("Failed to parse JSON: {}", e));
self.log_error(
"API",
&format!(
"Response body (first 1000 chars): {}",
if text.len() > 1000 {
&text[..1000]
} else {
&text
}
),
);
Err(ClientError::Serialization(e))
}
}
}
/// Send a chat message to a session
pub async fn send_message(
&self,
session_id: &str,
text: String,
) -> Result<MessageWithParts, ClientError> {
#[derive(Debug, Serialize)]
struct ChatPart {
#[serde(rename = "type")]
part_type: String,
text: String,
}
#[derive(Debug, Serialize)]
struct ChatInput {
parts: Vec<ChatPart>,
}
let url = format!("{}/session/{}/message", self.base_url, session_id);
self.log_info("API", &format!("POST {} (text: {} chars)", url, text.len()));
let input = ChatInput {
parts: vec![ChatPart {
part_type: "text".to_string(),
text,
}],
};
let response = self.client.post(&url).json(&input).send().await?;
let status = response.status();
if !status.is_success() {
let error_body = response
.text()
.await
.unwrap_or_else(|_| String::from("(no body)"));
self.log_error("API", &format!("POST {} failed: {}", url, status));
self.log_error("API", &format!("Response body: {}", error_body));
return Err(ClientError::Http(status));
}
let response_text = response.text().await?;
self.log_info("API", &format!("Response: {} bytes", response_text.len()));
match serde_json::from_str::<MessageWithParts>(&response_text) {
Ok(message) => {
self.log_success("API", "Message sent successfully");
Ok(message)
}
Err(e) => {
self.log_error("API", &format!("Failed to decode message: {}", e));
self.log_error("API", &format!("Response body: {}", response_text));
Err(ClientError::Serialization(e))
}
}
}
/// Subscribe to server-sent events (SSE) for real-time updates
///
/// Returns a stream of events including message updates, session changes, etc.
/// The stream will automatically handle the SSE protocol and deserialize events.
///
/// # Example
///
/// ```no_run
/// # use opencode_client::OpenCodeClient;
/// # use futures::stream::StreamExt;
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let client = OpenCodeClient::new("http://localhost:12225");
/// let mut stream = client.subscribe_events()?;
///
/// while let Some(event_result) = stream.next().await {
/// match event_result {
/// Ok(event) => println!("Received event: {:?}", event),
/// Err(e) => eprintln!("Event error: {}", e),
/// }
/// }
/// # Ok(())
/// # }
/// ```
pub fn subscribe_events(&self) -> Result<SseStream, SseError> {
self.log_info("SSE", &format!("Connecting to {}/event", self.base_url));
let sse_client = SseClient::new(&self.base_url);
let stream = sse_client.connect()?;
self.log_success("SSE", "Connected to event stream");
Ok(stream)
}
/// Abort an in-progress message generation in a session
///
/// Sends a POST request to `/session/:id/abort` to cancel the current generation.
///
/// # Returns
///
/// Returns `Ok(true)` if the abort was successful, `Ok(false)` if there was nothing to abort,
/// or an error if the request failed.
pub async fn abort_session(&self, session_id: &str) -> Result<bool, ClientError> {
let url = format!("{}/session/{}/abort", self.base_url, session_id);
self.log_info("API", &format!("POST {} (abort)", url));
let response = self.client.post(&url).send().await?;
let status = response.status();
if !status.is_success() {
let error_body = response
.text()
.await
.unwrap_or_else(|_| String::from("(no body)"));
self.log_error("API", &format!("POST {} failed: {}", url, status));
self.log_error("API", &format!("Response body: {}", error_body));
return Err(ClientError::Http(status));
}
self.log_success("API", "Generation aborted successfully");
Ok(true)
}
}
#[derive(Debug)]
pub enum ClientError {
Http(reqwest::StatusCode),
Request(reqwest::Error),
Serialization(serde_json::Error),
}
impl From<reqwest::Error> for ClientError {
fn from(err: reqwest::Error) -> Self {
ClientError::Request(err)
}
}
impl From<serde_json::Error> for ClientError {
fn from(err: serde_json::Error) -> Self {
ClientError::Serialization(err)
}
}
impl std::fmt::Display for ClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ClientError::Http(status) => write!(f, "HTTP error: {}", status),
ClientError::Request(err) => write!(f, "Request error: {}", err),
ClientError::Serialization(err) => write!(f, "Serialization error: {}", err),
}
}
}
impl std::error::Error for ClientError {}
+34
View File
@@ -0,0 +1,34 @@
//! OpenCode API Client Library
//!
//! A Rust client for interacting with the OpenCode.ai API.
//!
//! # Example
//!
//! ```no_run
//! use opencode_client::OpenCodeClient;
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! let client = OpenCodeClient::new("http://localhost:12225");
//!
//! // List all sessions
//! let sessions = client.list_sessions().await?;
//!
//! // Get messages from a session
//! let messages = client.list_messages("session_id").await?;
//!
//! // Send a message
//! let response = client.send_message("session_id", "Hello!".to_string()).await?;
//! # Ok(())
//! # }
//! ```
pub mod client;
pub mod sse;
pub mod types;
pub use client::{ClientError, LogCallback, OpenCodeClient};
pub use sse::{ConnectionState, SseClient, SseError, SseStream};
pub use types::{
AssistantMessage, AssistantMessageTime, Event, Message, MessageTime, MessageWithParts, Part,
ReasoningPart, Session, TextPart, ToolPart, ToolState, UserMessage,
};
+299
View File
@@ -0,0 +1,299 @@
//! SSE (Server-Sent Events) client for OpenCode API
//!
//! Provides cross-platform SSE streaming for real-time event updates
use crate::types::Event;
use futures::stream::Stream;
use std::pin::Pin;
/// SSE-specific errors
#[derive(Debug)]
pub enum SseError {
Network(String),
Parse(String),
Closed,
}
impl std::fmt::Display for SseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SseError::Network(msg) => write!(f, "Network error: {}", msg),
SseError::Parse(msg) => write!(f, "Parse error: {}", msg),
SseError::Closed => write!(f, "Connection closed"),
}
}
}
impl std::error::Error for SseError {}
/// Connection state
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectionState {
Connecting,
Connected,
Disconnected,
Error,
}
/// Stream of SSE events
pub type SseStream = Pin<Box<dyn Stream<Item = Result<Event, SseError>> + Send>>;
/// SSE Client
pub struct SseClient {
base_url: String,
}
impl SseClient {
/// Create new SSE client
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
}
}
/// Connect to SSE endpoint and return event stream
pub fn connect(&self) -> Result<SseStream, SseError> {
let url = format!("{}/event", self.base_url);
#[cfg(target_arch = "wasm32")]
{
wasm::connect_sse(&url)
}
#[cfg(not(target_arch = "wasm32"))]
{
native::connect_sse(&url)
}
}
}
#[cfg(target_arch = "wasm32")]
mod wasm {
use super::*;
use futures::channel::mpsc;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{EventSource, MessageEvent};
pub fn connect_sse(url: &str) -> Result<SseStream, SseError> {
web_sys::console::log_1(&format!("SSE: Creating EventSource for {}", url).into());
let event_source = EventSource::new(url)
.map_err(|e| SseError::Network(format!("EventSource creation failed: {:?}", e)))?;
web_sys::console::log_1(
&format!(
"SSE: EventSource created, readyState: {}",
event_source.ready_state()
)
.into(),
);
let (tx, rx) = mpsc::unbounded::<Result<Event, SseError>>();
setup_listeners(&event_source, tx)?;
Ok(Box::pin(rx))
}
fn setup_listeners(
source: &EventSource,
mut tx: mpsc::UnboundedSender<Result<Event, SseError>>,
) -> Result<(), SseError> {
// Add open event listener
{
let cb = Closure::wrap(Box::new(move |_e: web_sys::Event| {
web_sys::console::log_1(&"SSE: Connection opened".into());
}) as Box<dyn FnMut(web_sys::Event)>);
source
.add_event_listener_with_callback("open", cb.as_ref().unchecked_ref())
.map_err(|e| SseError::Network(format!("Open listener setup failed: {:?}", e)))?;
cb.forget();
}
// Add error event listener
{
let cb = Closure::wrap(Box::new(move |e: web_sys::Event| {
web_sys::console::error_1(&format!("SSE: Error event: {:?}", e).into());
}) as Box<dyn FnMut(web_sys::Event)>);
source
.add_event_listener_with_callback("error", cb.as_ref().unchecked_ref())
.map_err(|e| SseError::Network(format!("Error listener setup failed: {:?}", e)))?;
cb.forget();
}
let events = [
"message.updated",
"message.removed",
"message.part.updated",
"message.part.removed",
"session.updated",
"session.created",
"session.deleted",
"session.compacted",
"server.connected",
"session.idle",
"session.error",
"permission.updated",
"file.edited",
"todo.updated",
];
web_sys::console::log_1(
&format!("SSE: Setting up listeners for {} event types", events.len()).into(),
);
for event_type in events {
let tx_clone = tx.clone();
let evt = event_type.to_string();
let evt_for_log = evt.clone();
let cb = Closure::wrap(Box::new(move |e: MessageEvent| {
web_sys::console::log_1(&format!("SSE: Received {} event", evt_for_log).into());
if let Some(data) = e.data().as_string() {
web_sys::console::log_1(&format!("SSE: Event data: {}", data).into());
// OpenCode sends the event type INSIDE the data JSON, not in the event: field
// So we parse the data directly instead of wrapping it
match serde_json::from_str(&data) {
Ok(event) => {
web_sys::console::log_1(
&format!("SSE: Parsed event successfully").into(),
);
let _ = tx_clone.unbounded_send(Ok(event));
}
Err(e) => {
web_sys::console::error_1(&format!("SSE: Parse error: {}", e).into());
let _ = tx_clone.unbounded_send(Err(SseError::Parse(e.to_string())));
}
}
} else {
web_sys::console::warn_1(
&format!("SSE: {} event has no data", evt_for_log).into(),
);
}
}) as Box<dyn FnMut(MessageEvent)>);
source
.add_event_listener_with_callback(event_type, cb.as_ref().unchecked_ref())
.map_err(|e| SseError::Network(format!("Listener setup failed: {:?}", e)))?;
cb.forget();
}
web_sys::console::log_1(&"SSE: All listeners setup complete".into());
Ok(())
}
}
#[cfg(not(target_arch = "wasm32"))]
mod native {
use super::*;
use futures::channel::mpsc;
use futures::stream::StreamExt;
use futures::SinkExt;
pub fn connect_sse(url: &str) -> Result<SseStream, SseError> {
let url = url.to_string();
let (tx, rx) = mpsc::unbounded::<Result<Event, SseError>>();
tokio::spawn(async move {
let _ = stream_events(url, tx).await;
});
Ok(Box::pin(rx))
}
async fn stream_events(
url: String,
mut tx: mpsc::UnboundedSender<Result<Event, SseError>>,
) -> Result<(), SseError> {
let response = reqwest::get(&url)
.await
.map_err(|e| SseError::Network(e.to_string()))?;
let mut stream = response.bytes_stream();
let mut buffer = String::new();
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| SseError::Network(e.to_string()))?;
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some((event, remaining)) = parse_sse(&buffer) {
buffer = remaining;
if tx.send(Ok(event)).await.is_err() {
return Ok(());
}
}
}
let _ = tx.send(Err(SseError::Closed)).await;
Ok(())
}
/// Filter system prompts from JSON data for cleaner logging
fn filter_system_prompt(data: &str) -> String {
// Try to parse as JSON and filter out system prompts
match serde_json::from_str::<serde_json::Value>(data) {
Ok(mut json) => {
if let Some(properties) = json.get_mut("properties") {
if let Some(info) = properties.get_mut("info") {
if let Some(system) = info.get_mut("system") {
if let Some(arr) = system.as_array() {
if !arr.is_empty() {
*system = serde_json::json!(format!("[FILTERED {} items]", arr.len()));
}
}
}
}
}
serde_json::to_string(&json).unwrap_or_else(|_| data.to_string())
}
Err(_) => data.to_string()
}
}
fn parse_sse(buffer: &str) -> Option<(Event, String)> {
let mut event_type = String::new();
let mut data_lines = Vec::new();
let mut pos = 0;
for line in buffer.lines() {
pos += line.len() + 1;
if line.is_empty() {
if !data_lines.is_empty() {
let data = data_lines.join("\n");
// Filter system prompts for cleaner logging
let filtered_data = filter_system_prompt(&data);
eprintln!("[OpenCode SSE] event_type='{}', data={}", event_type, filtered_data);
// OpenCode sends the event type INSIDE the data JSON, not in the event: field
// So we parse the data directly instead of wrapping it
match serde_json::from_str(&data) {
Ok(event) => {
eprintln!("[OpenCode SSE] Parsed event successfully");
return Some((event, buffer[pos..].to_string()));
}
Err(e) => {
eprintln!("[OpenCode SSE] Parse error: {} for data: {}", e, filtered_data);
return None;
}
}
}
} else if let Some(val) = line.strip_prefix("event:") {
event_type = val.trim().to_string();
} else if let Some(val) = line.strip_prefix("data:") {
data_lines.push(val.trim().to_string());
}
}
None
}
}
+572
View File
@@ -0,0 +1,572 @@
//! OpenCode API Types
//!
//! Rust representations of the OpenCode API types based on opencode_types.ts
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Session {
pub id: String,
#[serde(rename = "projectID")]
pub project_id: String,
pub directory: String,
#[serde(rename = "parentID", skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<SessionSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub share: Option<SessionShare>,
pub title: String,
pub version: String,
pub time: SessionTime,
#[serde(skip_serializing_if = "Option::is_none")]
pub revert: Option<SessionRevert>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionSummary {
pub diffs: Vec<FileDiff>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionShare {
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionTime {
pub created: u64,
pub updated: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub compacting: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionRevert {
#[serde(rename = "messageID")]
pub message_id: String,
#[serde(rename = "partID", skip_serializing_if = "Option::is_none")]
pub part_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub snapshot: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub diff: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FileDiff {
#[serde(alias = "path")]
pub file: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub additions: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deletions: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub before: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub after: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "role")]
pub enum Message {
#[serde(rename = "user")]
User(UserMessage),
#[serde(rename = "assistant")]
Assistant(AssistantMessage),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UserMessage {
pub id: String,
#[serde(rename = "sessionID")]
pub session_id: String,
pub time: MessageTime,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<UserMessageSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UserMessageSummary {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
pub diffs: Vec<FileDiff>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AssistantMessage {
pub id: String,
#[serde(rename = "sessionID")]
pub session_id: String,
pub time: AssistantMessageTime,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<MessageError>,
#[serde(default)]
pub system: Vec<String>,
#[serde(rename = "parentID", skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
#[serde(rename = "modelID", skip_serializing_if = "Option::is_none")]
pub model_id: Option<String>,
#[serde(rename = "providerID", skip_serializing_if = "Option::is_none")]
pub provider_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<MessagePath>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<bool>,
#[serde(default)]
pub cost: f64,
#[serde(default)]
pub tokens: TokenUsage,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MessageTime {
pub created: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AssistantMessageTime {
pub created: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub completed: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MessagePath {
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub root: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct TokenUsage {
pub input: u64,
pub output: u64,
pub reasoning: u64,
#[serde(default)]
pub cache: CacheUsage,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct CacheUsage {
pub read: u64,
pub write: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "name")]
pub enum MessageError {
ProviderAuthError { data: ProviderAuthErrorData },
UnknownError { data: UnknownErrorData },
MessageOutputLengthError,
MessageAbortedError { data: MessageAbortedErrorData },
ApiError { data: ApiErrorData },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProviderAuthErrorData {
#[serde(rename = "providerID")]
pub provider_id: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UnknownErrorData {
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MessageAbortedErrorData {
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiErrorData {
pub message: String,
#[serde(rename = "statusCode", skip_serializing_if = "Option::is_none")]
pub status_code: Option<u16>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum Part {
#[serde(rename = "text")]
Text(TextPart),
#[serde(rename = "reasoning")]
Reasoning(ReasoningPart),
#[serde(rename = "step-start")]
StepStart(GenericPart),
#[serde(rename = "step-finish")]
StepFinish(GenericPart),
#[serde(rename = "tool")]
Tool(ToolPart),
#[serde(rename = "file")]
File(FilePart),
#[serde(rename = "snapshot")]
Snapshot(SnapshotPart),
#[serde(rename = "patch")]
Patch(PatchPart),
#[serde(rename = "agent")]
Agent(AgentPart),
#[serde(rename = "retry")]
Retry(RetryPart),
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PartTime {
pub start: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub end: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GenericPart {
pub id: String,
#[serde(rename = "sessionID")]
pub session_id: String,
#[serde(rename = "messageID")]
pub message_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TextPart {
pub id: String,
#[serde(rename = "sessionID")]
pub session_id: String,
#[serde(rename = "messageID")]
pub message_id: String,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub synthetic: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub time: Option<PartTime>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ReasoningPart {
pub id: String,
#[serde(rename = "sessionID")]
pub session_id: String,
#[serde(rename = "messageID")]
pub message_id: String,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub time: Option<PartTime>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "status")]
pub enum ToolState {
#[serde(rename = "pending")]
Pending,
#[serde(rename = "running")]
Running {
input: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
metadata: Option<serde_json::Value>,
time: PartTime,
},
#[serde(rename = "completed")]
Completed {
input: serde_json::Value,
output: String,
title: String,
metadata: serde_json::Value,
time: PartTime,
#[serde(skip_serializing_if = "Option::is_none")]
attachments: Option<Vec<serde_json::Value>>,
},
#[serde(rename = "error")]
Error {
input: serde_json::Value,
error: String,
#[serde(skip_serializing_if = "Option::is_none")]
metadata: Option<serde_json::Value>,
time: PartTime,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ToolPart {
pub id: String,
#[serde(rename = "sessionID")]
pub session_id: String,
#[serde(rename = "messageID")]
pub message_id: String,
#[serde(rename = "callID")]
pub call_id: String,
pub tool: String,
pub state: ToolState,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FilePart {
pub id: String,
#[serde(rename = "sessionID")]
pub session_id: String,
#[serde(rename = "messageID")]
pub message_id: String,
pub mime: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<serde_json::Value>, // Simplified - can expand later
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SnapshotPart {
pub id: String,
#[serde(rename = "sessionID")]
pub session_id: String,
#[serde(rename = "messageID")]
pub message_id: String,
pub snapshot: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PatchPart {
pub id: String,
#[serde(rename = "sessionID")]
pub session_id: String,
#[serde(rename = "messageID")]
pub message_id: String,
pub hash: String,
pub files: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AgentPart {
pub id: String,
#[serde(rename = "sessionID")]
pub session_id: String,
#[serde(rename = "messageID")]
pub message_id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<AgentSource>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AgentSource {
pub value: String,
pub start: u64,
pub end: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RetryPart {
pub id: String,
#[serde(rename = "sessionID")]
pub session_id: String,
#[serde(rename = "messageID")]
pub message_id: String,
pub attempt: u32,
pub error: ApiErrorData, // Reusing existing type
pub time: RetryTime,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RetryTime {
pub created: u64,
}
/// Response containing message info and its parts
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MessageWithParts {
pub info: Message,
pub parts: Vec<Part>,
}
// ============================================================================
// SSE Event Types
// ============================================================================
/// SSE Event types from OpenCode API
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum Event {
#[serde(rename = "server.connected")]
ServerConnected { properties: serde_json::Value },
#[serde(rename = "session.created")]
SessionCreated { properties: SessionEventInfo },
#[serde(rename = "session.updated")]
SessionUpdated { properties: SessionEventInfo },
#[serde(rename = "session.deleted")]
SessionDeleted { properties: SessionEventInfo },
#[serde(rename = "session.compacted")]
SessionCompacted { properties: SessionIdOnly },
#[serde(rename = "session.idle")]
SessionIdle { properties: SessionIdOnly },
#[serde(rename = "session.error")]
SessionError { properties: SessionErrorInfo },
#[serde(rename = "message.updated")]
MessageUpdated { properties: MessageEventInfo },
#[serde(rename = "message.removed")]
MessageRemoved { properties: MessageRemovedInfo },
#[serde(rename = "message.part.updated")]
MessagePartUpdated { properties: MessagePartEventInfo },
#[serde(rename = "message.part.removed")]
MessagePartRemoved { properties: MessagePartRemovedInfo },
#[serde(rename = "permission.updated")]
PermissionUpdated { properties: Permission },
#[serde(rename = "permission.replied")]
PermissionReplied { properties: PermissionReplyInfo },
#[serde(rename = "file.edited")]
FileEdited { properties: FileEditedInfo },
#[serde(rename = "file.watcher.updated")]
FileWatcherUpdated { properties: FileWatcherInfo },
#[serde(rename = "todo.updated")]
TodoUpdated { properties: TodoEventInfo },
#[serde(rename = "lsp.client.diagnostics")]
LspClientDiagnostics { properties: LspDiagnosticsInfo },
#[serde(rename = "installation.updated")]
InstallationUpdated { properties: InstallationInfo },
#[serde(rename = "ide.installed")]
IdeInstalled { properties: IdeInfo },
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionEventInfo {
pub info: Session,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionIdOnly {
#[serde(rename = "sessionID")]
pub session_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionErrorInfo {
#[serde(rename = "sessionID", skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<MessageError>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MessageEventInfo {
pub info: Message,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MessageRemovedInfo {
#[serde(rename = "sessionID")]
pub session_id: String,
#[serde(rename = "messageID")]
pub message_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MessagePartEventInfo {
pub part: Part,
#[serde(skip_serializing_if = "Option::is_none")]
pub delta: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MessagePartRemovedInfo {
#[serde(rename = "sessionID")]
pub session_id: String,
#[serde(rename = "messageID")]
pub message_id: String,
#[serde(rename = "partID")]
pub part_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Permission {
pub id: String,
#[serde(rename = "type")]
pub permission_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub pattern: Option<serde_json::Value>, // Can be string or array
#[serde(rename = "sessionID")]
pub session_id: String,
#[serde(rename = "messageID")]
pub message_id: String,
#[serde(rename = "callID", skip_serializing_if = "Option::is_none")]
pub call_id: Option<String>,
pub title: String,
pub metadata: serde_json::Value,
pub time: PermissionTime,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PermissionTime {
pub created: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PermissionReplyInfo {
#[serde(rename = "sessionID")]
pub session_id: String,
#[serde(rename = "permissionID")]
pub permission_id: String,
pub response: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FileEditedInfo {
pub file: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FileWatcherInfo {
pub file: String,
pub event: FileWatcherEvent,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum FileWatcherEvent {
Add,
Change,
Unlink,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TodoEventInfo {
#[serde(rename = "sessionID")]
pub session_id: String,
pub todos: Vec<Todo>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Todo {
pub content: String,
pub status: String,
pub priority: String,
pub id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LspDiagnosticsInfo {
#[serde(rename = "serverID")]
pub server_id: String,
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct InstallationInfo {
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IdeInfo {
pub ide: String,
}